diff --git a/LICENSE b/LICENSE
new file mode 100644
index 00000000..ea226ed1
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,189 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to the Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by the Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding any notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/README.md b/README.md
index 0e87fb1f..397fa8cb 100644
--- a/README.md
+++ b/README.md
@@ -1425,7 +1425,7 @@ pytest --cov=mcp_cli --cov-report=html
## ๐ License
-This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
+This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details.
## ๐ Acknowledgments
diff --git a/license.md b/license.md
deleted file mode 100644
index 371e6299..00000000
--- a/license.md
+++ /dev/null
@@ -1,8 +0,0 @@
-# Released under MIT License
-Copyright (c) Chris Hay.
-
-Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
index 5980f937..ea1c3bf4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,15 +4,13 @@ build-backend = "setuptools.build_meta"
[project]
name = "mcp-cli"
-version = "0.17"
+version = "0.18"
description = "A cli for the Model Context Provider"
requires-python = ">=3.11"
readme = "README.md"
-authors = [
- { name = "Chris Hay", email = "chrishayuk@somejunkmailbox.com" }
-]
+authors = []
keywords = ["llm", "openai", "claude", "mcp", "cli"]
-license = {text = "MIT"}
+license = {text = "Apache-2.0"}
dependencies = [
"asyncio>=3.4.3",
"chuk-ai-planner>=0.2",
diff --git a/server_config.json b/server_config.json
index cc46273a..5be83998 100644
--- a/server_config.json
+++ b/server_config.json
@@ -83,6 +83,12 @@
"her": {
"url": "https://her.chukai.io/mcp"
},
+ "chart": {
+ "url": "https://chart.chukai.io/mcp"
+ },
+ "map": {
+ "url": "https://map.chukai.io/mcp"
+ },
"view_demo": {
"url": "https://mcp-view-demo.fly.dev/mcp"
}
diff --git a/src/mcp_cli/apps/host.py b/src/mcp_cli/apps/host.py
index 78600daa..c1ff9d8d 100644
--- a/src/mcp_cli/apps/host.py
+++ b/src/mcp_cli/apps/host.py
@@ -73,6 +73,8 @@ async def launch_app(
resource_uri: str,
server_name: str,
tool_result: Any = None,
+ open_browser: bool = True,
+ view_url: str | None = None,
) -> AppInfo:
"""Launch an MCP App in the browser.
@@ -80,6 +82,9 @@ async def launch_app(
2. Start a local HTTP + WebSocket server
3. Open the user's default browser
4. Push the initial tool result once the WebSocket connects
+
+ ``view_url`` is an optional direct HTTPS fallback used when
+ ``resources/read`` for the ``resource_uri`` fails.
"""
# Close any previous instance of this tool's app
if tool_name in self._apps:
@@ -105,6 +110,27 @@ async def launch_app(
)
html_content = self._extract_html(resource)
+ # Fallback: retry without server filter (server may be registered
+ # under a different transport name than the tool namespace).
+ if not html_content and server_name:
+ logger.debug(
+ "Retrying resource %s without server filter (was: %s)",
+ resource_uri,
+ server_name,
+ )
+ resource = await self.tool_manager.read_resource(resource_uri)
+ html_content = self._extract_html(resource)
+
+ # Last resort: use viewUrl (direct HTTPS) if resources/read failed.
+ if not html_content and view_url:
+ logger.info(
+ "resources/read failed for %s, falling back to viewUrl %s",
+ resource_uri,
+ view_url,
+ )
+ html_content, resource = await self._fetch_http_resource(view_url)
+ resource_uri = view_url # update URI for app info
+
if not html_content:
raise RuntimeError(
f"Could not fetch UI resource {resource_uri} from {server_name}"
@@ -138,7 +164,7 @@ async def launch_app(
await self._start_server(app_info, bridge, tool_result)
# Open browser
- if DEFAULT_APP_AUTO_OPEN_BROWSER:
+ if open_browser and DEFAULT_APP_AUTO_OPEN_BROWSER:
try:
webbrowser.open(app_info.url)
logger.info("Opened MCP App for %s at %s", tool_name, app_info.url)
@@ -266,13 +292,38 @@ async def _start_server(
init_timeout=DEFAULT_APP_INIT_TIMEOUT,
)
host_page_bytes = host_page.encode("utf-8")
- app_html_bytes = app_info.html_content.encode("utf-8")
+
+ # Inject viewport-filling CSS into the app HTML. MCP App views
+ # are often designed for Claude.ai's inline display (fixed aspect
+ # ratio). When hosted inside an iframe panel, the root element
+ # chain needs to fill 100% height so canvas-based apps (Chart.js,
+ # Leaflet, D3) can use the available space.
+ app_html = app_info.html_content
+ _fill_css = (
+ ""
+ )
+ if "" in app_html:
+ app_html = app_html.replace("", _fill_css + "", 1)
+ elif "
Response | None:
- if request.path == "/" or request.path == "":
+ # Strip query string for path matching (?embedded=1 etc.)
+ path = request.path.split("?", 1)[0]
+
+ if path == "/" or path == "":
return Response(
http.HTTPStatus.OK,
"OK",
@@ -284,7 +335,7 @@ def process_request(
),
host_page_bytes,
)
- if request.path == "/app":
+ if path == "/app":
return Response(
http.HTTPStatus.OK,
"OK",
@@ -296,7 +347,7 @@ def process_request(
),
app_html_bytes,
)
- if request.path != "/ws":
+ if path != "/ws":
body = b"Not Found"
return Response(
http.HTTPStatus.NOT_FOUND,
diff --git a/src/mcp_cli/apps/host_page.py b/src/mcp_cli/apps/host_page.py
index def6b6e8..c19006a8 100644
--- a/src/mcp_cli/apps/host_page.py
+++ b/src/mcp_cli/apps/host_page.py
@@ -25,18 +25,24 @@
--status-err: #e74c3c;
}}
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
- body {{ background: var(--bg); color: var(--text); font-family: system-ui, -apple-system, sans-serif; }}
+ html, body {{ height: 100%; }}
+ body {{
+ background: var(--bg); color: var(--text);
+ font-family: system-ui, -apple-system, sans-serif;
+ display: flex; flex-direction: column;
+ }}
#header {{
display: flex; align-items: center; justify-content: space-between;
padding: 8px 16px; background: var(--header-bg);
border-bottom: 1px solid var(--accent); font-size: 14px;
+ flex-shrink: 0;
}}
#header .title {{ font-weight: 600; }}
#header .status {{ font-size: 12px; opacity: 0.7; }}
#header .status.connected {{ color: var(--status-ok); opacity: 1; }}
#header .status.error {{ color: var(--status-err); opacity: 1; }}
#app-container {{
- width: 100%; height: calc(100vh - 40px); overflow: hidden;
+ width: 100%; flex: 1; overflow: hidden; min-height: 0;
}}
#app-iframe {{
width: 100%; height: 100%; border: none; background: #fff;
@@ -220,10 +226,8 @@
var mode = msg.params.mode || "inline";
if (mode === "fullscreen") {{
document.getElementById("header").style.display = "none";
- document.getElementById("app-container").style.height = "100vh";
}} else {{
document.getElementById("header").style.display = "flex";
- document.getElementById("app-container").style.height = "calc(100vh - 40px)";
}}
postToApp({{ jsonrpc: "2.0", id: msg.id, result: {{ mode: mode }} }});
// Notify app of context change per MCP spec
@@ -289,6 +293,29 @@
}}, INIT_TIMEOUT);
}}
+ // ---- Embedded mode (hide host header when inside dashboard panel) ----
+ var isEmbedded = new URLSearchParams(window.location.search).has("embedded");
+ if (isEmbedded) {{
+ document.getElementById("header").style.display = "none";
+ document.body.style.background = "transparent";
+
+ // When embedded in a dashboard, the outer iframe starts display:none
+ // (0ร0 viewport). Canvas-based apps (Chart.js, maps) that initialise
+ // at 0ร0 never recover because their ResizeObserver sees no further
+ // change. Fix: strip the src so the app doesn't load until we have
+ // real dimensions, then restore it.
+ if (document.documentElement.clientWidth === 0 && typeof ResizeObserver !== "undefined") {{
+ iframe.removeAttribute("src");
+ var _ro = new ResizeObserver(function() {{
+ if (document.documentElement.clientWidth > 0) {{
+ _ro.disconnect();
+ iframe.src = "/app";
+ }}
+ }});
+ _ro.observe(document.documentElement);
+ }}
+ }}
+
// ---- Boot ----
connectWs();
startInitTimer();
diff --git a/src/mcp_cli/chat/tool_processor.py b/src/mcp_cli/chat/tool_processor.py
index 6a10492e..5610fbaa 100644
--- a/src/mcp_cli/chat/tool_processor.py
+++ b/src/mcp_cli/chat/tool_processor.py
@@ -1011,6 +1011,11 @@ async def _check_and_launch_app(self, tool_name: str, result: Any) -> None:
resource_uri = tool_info.app_resource_uri
server_name = tool_info.namespace
+ # Collect fallback viewUrl from definition and result meta
+ view_url = getattr(
+ tool_info, "app_view_url", None
+ ) or self._extract_result_view_url(result)
+
# Reuse existing app โ check by tool name, then by URI
bridge = app_host.get_bridge(tool_name)
if bridge is None and resource_uri:
@@ -1026,14 +1031,28 @@ async def _check_and_launch_app(self, tool_name: str, result: Any) -> None:
return
# No running app for this URI โ launch a new one
+ has_dashboard = (
+ getattr(self.context, "dashboard_bridge", None) is not None
+ )
logger.info("Tool %s has MCP App UI at %s", tool_name, resource_uri)
app_info = await app_host.launch_app(
tool_name=tool_name,
resource_uri=resource_uri,
server_name=server_name,
tool_result=result,
+ open_browser=not has_dashboard,
+ view_url=view_url,
)
logger.info("MCP App opened at %s", app_info.url)
+
+ # Notify dashboard to embed the app as a panel
+ if has_dashboard:
+ dash_bridge = getattr(self.context, "dashboard_bridge", None)
+ if dash_bridge is not None:
+ try:
+ await dash_bridge.on_app_launched(app_info)
+ except Exception as exc:
+ logger.debug("Dashboard on_app_launched error: %s", exc)
return
# โโ Case 2: no resourceUri โ route ui_patch to running app โโโ
@@ -1102,6 +1121,39 @@ def _result_contains_patch(result: Any) -> bool:
logger.debug("Error checking UI result: %s", e)
return False
+ @staticmethod
+ def _extract_result_view_url(result: Any) -> str | None:
+ """Extract viewUrl from a tool result's meta.ui if present.
+
+ The tool result (not the definition) may carry
+ ``meta.ui.viewUrl`` โ a direct HTTPS URL for the app's UI.
+ """
+ try:
+ raw = result
+ seen: set[int] = set()
+ while hasattr(raw, "result") and not isinstance(raw, (dict, str)):
+ rid = id(raw)
+ if rid in seen:
+ break
+ seen.add(rid)
+ raw = raw.result
+
+ meta: Any = None
+ if isinstance(raw, dict):
+ meta = raw.get("meta") or raw.get("_meta")
+ elif hasattr(raw, "meta"):
+ meta = raw.meta
+
+ if isinstance(meta, dict):
+ ui = meta.get("ui", {})
+ if isinstance(ui, dict):
+ url = ui.get("viewUrl")
+ if isinstance(url, str) and url.startswith(("http://", "https://")):
+ return url
+ except Exception:
+ pass
+ return None
+
def _track_transport_failures(self, success: bool, error: str | None) -> None:
"""Track transport failures for recovery detection."""
if not success and error:
diff --git a/src/mcp_cli/dashboard/MULTI_AGENT_SPEC.md b/src/mcp_cli/dashboard/MULTI_AGENT_SPEC.md
index 7955d8bb..9cdd24f6 100644
--- a/src/mcp_cli/dashboard/MULTI_AGENT_SPEC.md
+++ b/src/mcp_cli/dashboard/MULTI_AGENT_SPEC.md
@@ -337,26 +337,40 @@ Each event card gains an agent badge:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
```
-### 6.4 Message routing changes in shell.html
+### 6.4 Message routing in js/dispatcher.js
-```javascript
-// Current: direct routing
-case 'TOOL_RESULT':
- routeToViews('TOOL_RESULT', msg.payload);
- break;
+The shell UI is split into ES modules under `static/js/`. Message routing
+lives in `dispatcher.js` (`handleBridgeMessage`):
-// Future: agent-aware routing
+```javascript
+// Current (implemented): agent-aware routing
case 'TOOL_RESULT':
- const agentId = msg.payload.agent_id;
- // Always send to activity stream (it filters internally)
sendToActivityStream('TOOL_RESULT', msg.payload);
- // Only send to agent-terminal if this is the focused agent
- if (agentId === focusedAgentId) {
- routeToFocusedViews('TOOL_RESULT', msg.payload);
- }
+ if (isFocusedAgent(msg)) routeToViews('TOOL_RESULT', msg.payload);
break;
```
+Module layout:
+
+```
+static/js/
+โโโ state.js โ shared state, constants, SIDEBAR_VIEW_IDS
+โโโ utils.js โ esc(), showToast(), makeDraggable(), drawers
+โโโ theme.js โ loadThemes(), applyTheme()
+โโโ websocket.js โ connectWS(), sendToBridge(), agent tabs
+โโโ views.js โ iframe lifecycle, postMessage routing
+โโโ layout.js โ grid panels, resize handles, syncViewPositions
+โโโ sidebar.js โ collapsible sections, open/close, mobile mode
+โโโ apps.js โ handleAppLaunched(), handleAppClosed()
+โโโ config.js โ provider/model/server selects
+โโโ sessions.js โ session list, session switching
+โโโ export.js โ exportConversation()
+โโโ toolbar.js โ toolbar click handlers, overflow menu
+โโโ approval.js โ tool approval dialog
+โโโ dispatcher.js โ handleBridgeMessage() (big switch)
+โโโ init.js โ entry point, wires late-binding deps
+```
+
---
## 7. View Changes
diff --git a/src/mcp_cli/dashboard/bridge.py b/src/mcp_cli/dashboard/bridge.py
index 1afab2b4..b693c46b 100644
--- a/src/mcp_cli/dashboard/bridge.py
+++ b/src/mcp_cli/dashboard/bridge.py
@@ -71,6 +71,8 @@ def __init__(
self._seen_view_ids: set[str] = set()
# Pending tool approval futures keyed by call_id
self._pending_approvals: dict[str, asyncio.Future[bool]] = {}
+ # Running MCP Apps (for replay on client reconnect)
+ self._running_apps: dict[str, dict[str, Any]] = {}
# Dual-mode: router-managed vs direct-wiring
if isinstance(server, AgentRouter):
@@ -218,6 +220,39 @@ async def on_view_registry_update(self, views: list[dict[str, Any]]) -> None:
_envelope("VIEW_REGISTRY", {"agent_id": self.agent_id, "views": views})
)
+ async def on_app_launched(self, app_info: Any) -> None:
+ """Notify dashboard that an MCP App launched โ embed as panel."""
+ payload: dict[str, Any] = {
+ "agent_id": self.agent_id,
+ "tool_name": app_info.tool_name,
+ "url": app_info.url,
+ "port": app_info.port,
+ "server_name": app_info.server_name,
+ "resource_uri": app_info.resource_uri,
+ "state": (
+ app_info.state.value
+ if hasattr(app_info.state, "value")
+ else str(app_info.state)
+ ),
+ "timestamp": _now(),
+ }
+ self._running_apps[app_info.tool_name] = payload
+ await self._broadcast(_envelope("APP_LAUNCHED", payload))
+
+ async def on_app_closed(self, tool_name: str) -> None:
+ """Notify dashboard that an MCP App closed."""
+ self._running_apps.pop(tool_name, None)
+ await self._broadcast(
+ _envelope(
+ "APP_CLOSED",
+ {
+ "agent_id": self.agent_id,
+ "tool_name": tool_name,
+ "timestamp": _now(),
+ },
+ )
+ )
+
async def _discover_view(self, meta_ui: dict[str, Any], server_name: str) -> None:
"""Register a new view from a _meta.ui block and broadcast VIEW_REGISTRY."""
view_id: str = meta_ui["view"]
@@ -270,6 +305,9 @@ async def _on_client_connected(self, ws: Any) -> None:
await ws.send(
_json.dumps(_envelope("ACTIVITY_HISTORY", {"events": activity}))
)
+ # APP replay for reconnecting clients
+ for app_payload in self._running_apps.values():
+ await ws.send(_json.dumps(_envelope("APP_LAUNCHED", app_payload)))
except Exception as exc:
logger.debug("Error sending initial state to new client: %s", exc)
@@ -385,6 +423,9 @@ async def _on_browser_message(self, msg: dict[str, Any]) -> None:
await self._handle_delete_session(msg)
elif msg_type == "RENAME_SESSION":
await self._handle_rename_session(msg)
+ elif msg_type == "REQUEST_APP_LIST":
+ for app_payload in self._running_apps.values():
+ await self._broadcast(_envelope("APP_LAUNCHED", app_payload))
else:
logger.debug("Dashboard received unknown message type: %s", msg_type)
diff --git a/src/mcp_cli/dashboard/config.py b/src/mcp_cli/dashboard/config.py
index 082b2dd0..adafb52a 100644
--- a/src/mcp_cli/dashboard/config.py
+++ b/src/mcp_cli/dashboard/config.py
@@ -17,6 +17,19 @@
# ------------------------------------------------------------------ #
LAYOUT_PRESETS: dict[str, dict[str, Any]] = {
+ "Mobile": {
+ "name": "Mobile",
+ "layout": {
+ "rows": [
+ {
+ "height": "100%",
+ "columns": [
+ {"width": "100%", "view": "builtin:agent-terminal"},
+ ],
+ }
+ ]
+ },
+ },
"Minimal": {
"name": "Minimal",
"layout": {
diff --git a/src/mcp_cli/dashboard/server.py b/src/mcp_cli/dashboard/server.py
index 3746ad11..8e84d925 100644
--- a/src/mcp_cli/dashboard/server.py
+++ b/src/mcp_cli/dashboard/server.py
@@ -240,6 +240,12 @@ def _resolve_static(self, path: str) -> Path | None:
elif path.startswith("/themes/"):
name = path[len("/themes/") :]
candidate = _STATIC_DIR / "themes" / name
+ elif path.startswith("/css/"):
+ name = path[len("/css/") :]
+ candidate = _STATIC_DIR / "css" / name
+ elif path.startswith("/js/"):
+ name = path[len("/js/") :]
+ candidate = _STATIC_DIR / "js" / name
else:
# Reject unknown paths
return None
diff --git a/src/mcp_cli/dashboard/static/css/agent-tabs.css b/src/mcp_cli/dashboard/static/css/agent-tabs.css
new file mode 100644
index 00000000..cdd8d1a9
--- /dev/null
+++ b/src/mcp_cli/dashboard/static/css/agent-tabs.css
@@ -0,0 +1,47 @@
+/* โโ Agent tab bar โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
+#agent-tabs {
+ display: none;
+ align-items: center;
+ gap: 4px;
+ padding: 0 12px;
+ height: 28px;
+ min-height: 28px;
+ background: var(--dash-bg);
+ border-bottom: 1px solid var(--dash-border);
+ flex-shrink: 0;
+ user-select: none;
+ overflow-x: auto;
+}
+#agent-tabs.visible { display: flex; }
+.agent-tab {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 3px 10px;
+ border: 1px solid transparent;
+ border-radius: var(--dash-radius);
+ background: transparent;
+ color: var(--dash-fg-muted);
+ font-size: 11px;
+ font-family: var(--dash-font-ui);
+ cursor: pointer;
+ white-space: nowrap;
+ transition: background 0.15s, color 0.15s, border-color 0.15s;
+}
+.agent-tab:hover { background: var(--dash-bg-hover); color: var(--dash-fg); }
+.agent-tab.focused {
+ background: var(--dash-bg-surface);
+ color: var(--dash-fg);
+ border-color: var(--dash-accent);
+}
+.agent-tab-indicator {
+ width: 6px; height: 6px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+.agent-tab-indicator.active { background: var(--dash-success); }
+.agent-tab-indicator.paused { background: var(--dash-warning); }
+.agent-tab-indicator.completed { background: var(--dash-fg-muted); }
+.agent-tab-indicator.failed { background: var(--dash-error); }
+
+/* (view-stash removed โ iframes now live permanently in #view-overlay) */
diff --git a/src/mcp_cli/dashboard/static/css/drawers.css b/src/mcp_cli/dashboard/static/css/drawers.css
new file mode 100644
index 00000000..5c07fac0
--- /dev/null
+++ b/src/mcp_cli/dashboard/static/css/drawers.css
@@ -0,0 +1,90 @@
+/* Settings panel */
+#settings-panel {
+ display: none;
+ position: fixed;
+ top: 40px; right: 8px;
+ width: 320px;
+ max-height: calc(100vh - 56px);
+ overflow-y: auto;
+ background: var(--dash-bg-surface);
+ border: 1px solid var(--dash-border);
+ border-radius: var(--dash-radius);
+ z-index: 200;
+ padding: 12px;
+ box-shadow: 0 4px 16px rgba(0,0,0,0.5);
+}
+#settings-panel.open { display: block; }
+#settings-panel h3 { font-size: 12px; font-weight: 600; margin-bottom: 8px; color: var(--dash-fg-muted); text-transform: uppercase; letter-spacing: .05em; }
+#settings-panel label { font-size: 12px; display: block; margin-bottom: 4px; }
+#settings-panel select { width: 100%; padding: 4px 6px; background: var(--dash-bg); border: 1px solid var(--dash-border); border-radius: var(--dash-radius); color: var(--dash-fg); font-size: 12px; }
+#settings-panel .section { margin-bottom: 12px; padding-bottom: 12px; border-bottom: 1px solid var(--dash-border); }
+#settings-panel .section:last-child { border-bottom: none; margin-bottom: 0; padding-bottom: 0; }
+#settings-panel textarea {
+ width: 100%; padding: 6px 8px; background: var(--dash-bg); border: 1px solid var(--dash-border);
+ border-radius: var(--dash-radius); color: var(--dash-fg); font-family: var(--dash-font-mono);
+ font-size: 11px; line-height: 1.4; resize: vertical; min-height: 80px; max-height: 300px;
+}
+#settings-panel textarea:focus { border-color: var(--dash-accent); outline: none; }
+#settings-panel .btn-row { display: flex; gap: 6px; margin-top: 6px; }
+#settings-panel .btn-sm {
+ background: transparent; border: 1px solid var(--dash-border); color: var(--dash-fg);
+ padding: 3px 10px; border-radius: var(--dash-radius); cursor: pointer; font-size: 11px;
+ font-family: var(--dash-font-ui);
+}
+#settings-panel .btn-sm:hover { background: var(--dash-bg-hover); }
+#settings-panel .btn-sm.primary { background: var(--dash-accent); color: var(--dash-bg); border-color: var(--dash-accent); }
+#settings-panel .btn-sm.primary:hover { opacity: 0.9; }
+.server-list { list-style: none; padding: 0; margin: 4px 0 0 0; }
+.server-item {
+ display: flex; align-items: center; gap: 6px; padding: 4px 0;
+ font-size: 12px; border-bottom: 1px solid var(--dash-border);
+}
+.server-item:last-child { border-bottom: none; }
+.server-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
+.server-dot.on { background: var(--dash-success); }
+.server-dot.off { background: var(--dash-error); }
+.server-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.server-tools { font-size: 10px; color: var(--dash-fg-muted); }
+
+/* โโ Session drawer โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
+#session-drawer {
+ display: none;
+ position: fixed;
+ top: 40px; left: 8px;
+ width: 300px;
+ max-height: calc(100vh - 56px);
+ overflow-y: auto;
+ background: var(--dash-bg-surface);
+ border: 1px solid var(--dash-border);
+ border-radius: var(--dash-radius);
+ z-index: 200;
+ padding: 12px;
+ box-shadow: 0 4px 16px rgba(0,0,0,0.5);
+}
+#session-drawer.open { display: block; }
+#session-drawer h3 {
+ font-size: 12px; font-weight: 600; margin-bottom: 8px;
+ color: var(--dash-fg-muted); text-transform: uppercase; letter-spacing: .05em;
+ display: flex; align-items: center; justify-content: space-between;
+}
+.session-list { list-style: none; padding: 0; margin: 0; }
+.session-item {
+ display: flex; align-items: center; gap: 6px; padding: 8px 6px;
+ font-size: 12px; border-bottom: 1px solid var(--dash-border);
+ cursor: pointer; border-radius: var(--dash-radius); transition: background 0.15s;
+}
+.session-item:last-child { border-bottom: none; }
+.session-item:hover { background: var(--dash-bg-hover); }
+.session-item.active { background: var(--dash-bg-hover); border-left: 2px solid var(--dash-accent); }
+.session-info { flex: 1; min-width: 0; }
+.session-id { font-family: var(--dash-font-mono); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.session-desc { font-size: 10px; color: var(--dash-fg-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.session-meta { font-size: 10px; color: var(--dash-fg-muted); display: flex; gap: 8px; margin-top: 2px; }
+.session-actions { display: flex; gap: 4px; flex-shrink: 0; }
+.session-actions button {
+ background: transparent; border: none; color: var(--dash-fg-muted);
+ cursor: pointer; font-size: 11px; padding: 2px 4px; border-radius: 3px;
+}
+.session-actions button:hover { color: var(--dash-fg); background: var(--dash-bg-hover); }
+.session-actions button.delete:hover { color: var(--dash-error); }
+.session-empty { color: var(--dash-fg-muted); font-size: 12px; text-align: center; padding: 16px 0; }
diff --git a/src/mcp_cli/dashboard/static/css/grid.css b/src/mcp_cli/dashboard/static/css/grid.css
new file mode 100644
index 00000000..3d9190be
--- /dev/null
+++ b/src/mcp_cli/dashboard/static/css/grid.css
@@ -0,0 +1,137 @@
+/* โโ Grid layout โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
+#grid-wrapper {
+ flex: 1;
+ overflow: hidden;
+ min-height: 0;
+ position: relative;
+}
+#grid-root {
+ position: absolute;
+ top: 0; left: 0; right: 0; bottom: 0;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+#view-overlay {
+ position: absolute;
+ top: 0; left: 0; right: 0; bottom: 0;
+ pointer-events: none;
+ z-index: 5;
+}
+#view-overlay iframe {
+ position: absolute;
+ pointer-events: auto;
+ border: none;
+ display: none;
+}
+.grid-row {
+ display: flex;
+ flex-direction: row;
+ overflow: hidden;
+ min-height: 150px;
+}
+.grid-row + .grid-row { border-top: 1px solid var(--dash-border); }
+
+/* Panel */
+.panel {
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ min-width: 200px;
+ position: relative;
+}
+.panel + .panel { border-left: 1px solid var(--dash-border); }
+.panel.minimized .panel-body { display: none; }
+.panel.minimized .panel-header { border-bottom: none; }
+
+/* Panel header */
+.panel-header {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 0 8px;
+ height: 28px;
+ min-height: 28px;
+ background: var(--dash-bg-surface);
+ border-bottom: 1px solid var(--dash-border);
+ flex-shrink: 0;
+ cursor: grab;
+ user-select: none;
+ position: relative; /* for view-picker dropdown */
+}
+.panel-header:active { cursor: grabbing; }
+.panel-icon { font-size: 13px; flex-shrink: 0; }
+.panel-view-toggle {
+ flex: 1;
+ min-width: 0;
+ background: transparent;
+ border: none;
+ color: var(--dash-fg);
+ font-size: 12px;
+ font-weight: 500;
+ font-family: var(--dash-font-ui);
+ cursor: pointer;
+ text-align: left;
+ padding: 0 4px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.panel-view-toggle:hover { color: var(--dash-accent); }
+.panel-view-menu {
+ position: absolute;
+ top: 28px;
+ left: 0;
+ min-width: 180px;
+ z-index: 50;
+}
+.panel-btn {
+ background: transparent;
+ border: none;
+ color: var(--dash-fg-muted);
+ cursor: pointer;
+ padding: 2px 4px;
+ border-radius: 3px;
+ font-size: 12px;
+ line-height: 1;
+}
+.panel-btn:hover { background: var(--dash-bg-hover); color: var(--dash-fg); }
+
+/* Panel body (empty slot โ iframes are positioned over it from #view-overlay) */
+.panel-body {
+ flex: 1;
+ overflow: hidden;
+ position: relative;
+}
+
+/* Panel placeholder */
+.panel-placeholder {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ color: var(--dash-fg-muted);
+ font-size: 12px;
+ padding: 16px;
+ text-align: center;
+}
+
+/* Resize handles */
+.resize-handle-col {
+ width: 4px;
+ cursor: col-resize;
+ background: transparent;
+ flex-shrink: 0;
+ position: relative;
+ z-index: 10;
+}
+.resize-handle-col:hover, .resize-handle-col.dragging { background: var(--dash-accent); }
+.resize-handle-row {
+ height: 4px;
+ cursor: row-resize;
+ background: transparent;
+ flex-shrink: 0;
+ position: relative;
+ z-index: 10;
+}
+.resize-handle-row:hover, .resize-handle-row.dragging { background: var(--dash-accent); }
diff --git a/src/mcp_cli/dashboard/static/css/notifications.css b/src/mcp_cli/dashboard/static/css/notifications.css
new file mode 100644
index 00000000..f0164f34
--- /dev/null
+++ b/src/mcp_cli/dashboard/static/css/notifications.css
@@ -0,0 +1,96 @@
+/* Notification toast */
+#toast-container {
+ position: fixed;
+ bottom: 16px;
+ right: 16px;
+ z-index: 500;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+.toast {
+ background: var(--dash-bg-surface);
+ border: 1px solid var(--dash-border);
+ border-radius: var(--dash-radius);
+ padding: 8px 12px;
+ font-size: 12px;
+ max-width: 300px;
+ box-shadow: 0 2px 8px rgba(0,0,0,0.4);
+ animation: slideIn 0.2s ease;
+}
+.toast.info { border-left: 3px solid var(--dash-info); }
+.toast.success { border-left: 3px solid var(--dash-success); }
+.toast.warning { border-left: 3px solid var(--dash-warning); }
+.toast.error { border-left: 3px solid var(--dash-error); }
+@keyframes slideIn { from { opacity:0; transform:translateY(8px); } to { opacity:1; transform:translateY(0); } }
+
+/* Tool approval dialog */
+#approval-overlay {
+ display: none;
+ position: fixed;
+ top: 0; left: 0; right: 0; bottom: 0;
+ background: rgba(0,0,0,0.5);
+ z-index: 600;
+ align-items: center;
+ justify-content: center;
+}
+#approval-overlay.open { display: flex; }
+#approval-dialog {
+ background: var(--dash-bg-surface);
+ border: 1px solid var(--dash-border);
+ border-radius: var(--dash-radius);
+ padding: 16px;
+ max-width: 500px;
+ width: 90%;
+ box-shadow: 0 4px 24px rgba(0,0,0,0.5);
+}
+#approval-dialog h3 {
+ font-size: 14px;
+ color: var(--dash-warning);
+ margin-bottom: 8px;
+}
+#approval-dialog .approval-tool {
+ font-family: var(--dash-font-mono);
+ font-size: 13px;
+ color: var(--dash-accent);
+ margin-bottom: 8px;
+}
+#approval-dialog .approval-args {
+ background: var(--dash-bg);
+ border: 1px solid var(--dash-border);
+ border-radius: var(--dash-radius);
+ padding: 8px;
+ font-family: var(--dash-font-mono);
+ font-size: 11px;
+ color: var(--dash-fg-muted);
+ max-height: 200px;
+ overflow-y: auto;
+ white-space: pre-wrap;
+ word-break: break-all;
+ margin-bottom: 12px;
+}
+#approval-dialog .approval-btns {
+ display: flex;
+ gap: 8px;
+ justify-content: flex-end;
+}
+#approval-dialog .approval-btns button {
+ padding: 6px 16px;
+ border-radius: var(--dash-radius);
+ border: 1px solid var(--dash-border);
+ cursor: pointer;
+ font-size: 12px;
+ font-family: var(--dash-font-ui);
+}
+#approval-dialog .btn-approve {
+ background: var(--dash-success);
+ color: var(--dash-bg);
+ border-color: var(--dash-success);
+}
+#approval-dialog .btn-deny {
+ background: transparent;
+ color: var(--dash-error);
+ border-color: var(--dash-error);
+}
+#approval-dialog .btn-approve:hover { opacity: 0.9; }
+#approval-dialog .btn-deny:hover { background: var(--dash-bg-hover); }
diff --git a/src/mcp_cli/dashboard/static/css/responsive.css b/src/mcp_cli/dashboard/static/css/responsive.css
new file mode 100644
index 00000000..2987ba41
--- /dev/null
+++ b/src/mcp_cli/dashboard/static/css/responsive.css
@@ -0,0 +1,129 @@
+/* โโ md: 768-1023px โ tablets, narrow desktop โโโโโโโโโโโโโโโโโโโโ */
+@media (max-width: 1023px) {
+ /* Keep side-by-side layout but allow narrower panels */
+ .panel { min-width: 180px; min-height: var(--dash-panel-min-height); }
+ #settings-panel { width: 360px; }
+ #session-drawer { width: 340px; }
+}
+
+/* โโ sm: 480-767px โ large phones, narrow embeds โโโโโโโโโโโโโโโโโ */
+@media (max-width: 767px) {
+ #toolbar {
+ gap: 6px;
+ padding: 0 8px;
+ height: var(--dash-toolbar-height);
+ min-height: var(--dash-toolbar-height);
+ flex-wrap: nowrap;
+ overflow: hidden;
+ }
+ #toolbar .spacer { display: none; }
+ #model-group { display: none !important; }
+ #add-panel-btn { display: none; }
+ #export-dropdown { display: none; }
+ #overflow-btn { display: inline-flex; margin-left: auto; }
+
+ #settings-panel {
+ width: 100%; right: 0; left: 0; top: auto; bottom: 0;
+ max-height: 70vh;
+ border-radius: 12px 12px 0 0;
+ box-shadow: 0 -4px 24px rgba(0,0,0,0.5);
+ }
+ #session-drawer {
+ width: 100%; right: 0; left: 0; top: auto; bottom: 0;
+ max-height: 70vh;
+ border-radius: 12px 12px 0 0;
+ box-shadow: 0 -4px 24px rgba(0,0,0,0.5);
+ }
+ #approval-dialog { max-width: 95%; }
+ .toast { max-width: 90vw; }
+}
+
+/* โโ xs: 0-479px โ phones โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
+@media (max-width: 479px) {
+ #toolbar .brand span:last-child { display: none; }
+ #sessions-btn { display: none; }
+ #new-session-btn { display: none; }
+ .tb-btn { font-size: 11px; padding: 4px 8px; }
+ .tb-select { max-width: 100px; font-size: 10px; }
+}
+
+/* โโ Touch-friendly tap targets โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
+@media (pointer: coarse) {
+ .tb-btn {
+ min-height: var(--dash-touch-target);
+ min-width: 36px;
+ padding: 8px 12px;
+ }
+ .panel-btn {
+ min-height: 36px;
+ min-width: 36px;
+ padding: 6px 8px;
+ font-size: 14px;
+ }
+ .panel-header {
+ height: 36px;
+ min-height: 36px;
+ }
+ .dropdown-item {
+ min-height: var(--dash-touch-target);
+ display: flex;
+ align-items: center;
+ padding: 10px 12px;
+ }
+ .session-actions button {
+ min-height: 36px;
+ min-width: 36px;
+ padding: 6px 8px;
+ }
+ #approval-dialog .approval-btns button {
+ min-height: 48px;
+ padding: 12px 24px;
+ font-size: 14px;
+ }
+ .sidebar-section-btn {
+ min-width: 28px;
+ min-height: 28px;
+ font-size: 13px;
+ padding: 4px 6px;
+ }
+ .sidebar-section-btns { opacity: 1; }
+ .sidebar-section-header {
+ height: 36px;
+ min-height: 36px;
+ }
+ .resize-handle-col, #sidebar-resize-handle {
+ width: 12px;
+ margin: 0 -4px;
+ }
+ .resize-handle-col::after {
+ content: '';
+ position: absolute;
+ top: 50%; left: 50%;
+ transform: translate(-50%, -50%);
+ width: 4px; height: 32px;
+ background: var(--dash-border);
+ border-radius: 2px;
+ }
+ .resize-handle-row {
+ height: 12px;
+ margin: -4px 0;
+ }
+ .resize-handle-row::after {
+ content: '';
+ position: absolute;
+ top: 50%; left: 50%;
+ transform: translate(-50%, -50%);
+ width: 32px; height: 4px;
+ background: var(--dash-border);
+ border-radius: 2px;
+ }
+ #sidebar-toggle {
+ padding: 16px 8px;
+ font-size: 16px;
+ }
+ #sidebar-close {
+ min-height: 36px;
+ min-width: 36px;
+ font-size: 18px;
+ }
+}
diff --git a/src/mcp_cli/dashboard/static/css/sidebar.css b/src/mcp_cli/dashboard/static/css/sidebar.css
new file mode 100644
index 00000000..b65a2d38
--- /dev/null
+++ b/src/mcp_cli/dashboard/static/css/sidebar.css
@@ -0,0 +1,244 @@
+/* โโ Responsive: 5-tier breakpoint system โโโโโโโโโโโโโโโโโโโโโโโโโโ
+ xs: 0-479px (phones)
+ sm: 480-767px (large phones, narrow embeds)
+ md: 768-1023px (tablets, narrow desktop)
+ lg: 1024-1439px (landscape tablets, IDE panels)
+ xl: 1440px+ (full desktop โ default, no overrides needed)
+ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
+
+/* โโ Overflow menu (mobile toolbar) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
+#overflow-btn { display: none; }
+#overflow-menu {
+ position: fixed;
+ right: 8px;
+ min-width: 180px;
+ z-index: 150;
+ background: var(--dash-bg-surface);
+ border: 1px solid var(--dash-border);
+ border-radius: var(--dash-radius);
+ box-shadow: 0 4px 16px rgba(0,0,0,0.5);
+}
+
+/* โโ Main content: grid + right sidebar โโโโโโโโโโโโโโโโโโโโโโโโโโโ */
+#main-content {
+ flex: 1;
+ display: flex;
+ flex-direction: row;
+ min-height: 0;
+ overflow: hidden;
+ position: relative;
+}
+
+/* โโ Sidebar resize handle โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
+#sidebar-resize-handle {
+ width: 6px;
+ cursor: col-resize;
+ background: transparent;
+ flex-shrink: 0;
+ z-index: 20; /* above #view-overlay (z-index:5) */
+ position: relative; /* establish stacking context for z-index */
+}
+/* Wider invisible hit area for easier grabbing */
+#sidebar-resize-handle::before {
+ content: '';
+ position: absolute;
+ top: 0; bottom: 0;
+ left: -6px; right: -6px;
+ cursor: col-resize;
+}
+#sidebar-resize-handle:hover, #sidebar-resize-handle.dragging { background: var(--dash-accent); }
+body.mobile-sidebar #sidebar-resize-handle { display: none; }
+
+/* โโ Right sidebar (always visible desktop, slide-over mobile) โโโ */
+#sidebar-toggle {
+ display: none;
+ position: fixed;
+ top: 50%;
+ right: 0;
+ transform: translateY(-50%);
+ z-index: 15;
+ background: var(--dash-bg-surface);
+ border: 1px solid var(--dash-border);
+ border-right: none;
+ border-radius: var(--dash-radius) 0 0 var(--dash-radius);
+ color: var(--dash-fg-muted);
+ font-size: 14px;
+ padding: 12px 6px;
+ cursor: pointer;
+ box-shadow: -2px 0 8px rgba(0,0,0,0.3);
+ line-height: 1;
+ writing-mode: vertical-lr;
+ letter-spacing: 0.1em;
+}
+#sidebar-toggle:hover { background: var(--dash-bg-hover); color: var(--dash-fg); }
+#sidebar-toggle.has-update { color: var(--dash-accent); }
+
+#sidebar-panel {
+ display: flex;
+ flex-direction: column;
+ width: 30%;
+ min-width: 240px;
+ max-width: 400px;
+ background: var(--dash-bg);
+ border-left: 1px solid var(--dash-border);
+ overflow: hidden;
+ flex-shrink: 0;
+}
+
+/* Sidebar header โ visible only on mobile (desktop sections have their own headers) */
+#sidebar-header {
+ display: none;
+ align-items: center;
+ padding: 0 10px;
+ height: 32px;
+ min-height: 32px;
+ background: var(--dash-bg-surface);
+ border-bottom: 1px solid var(--dash-border);
+ flex-shrink: 0;
+}
+#sidebar-title {
+ flex: 1;
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--dash-fg-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+#sidebar-close {
+ background: transparent;
+ border: none;
+ color: var(--dash-fg-muted);
+ cursor: pointer;
+ font-size: 16px;
+ padding: 4px 6px;
+ flex-shrink: 0;
+}
+#sidebar-close:hover { color: var(--dash-fg); }
+
+/* Sidebar view slot โ stacked collapsible sections */
+#sidebar-view-slot {
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+/* โโ Collapsible accordion sections โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
+.sidebar-section {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ overflow: hidden;
+ border-bottom: 1px solid var(--dash-border);
+}
+.sidebar-section:last-child { border-bottom: none; }
+.sidebar-section.expanded {
+ flex: 1;
+}
+.sidebar-section-header {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 10px;
+ height: 26px;
+ min-height: 26px;
+ background: var(--dash-bg-surface);
+ cursor: pointer;
+ user-select: none;
+ flex-shrink: 0;
+}
+.sidebar-section-header:hover { background: var(--dash-bg-hover); }
+.sidebar-section-chevron {
+ font-size: 10px;
+ color: var(--dash-fg-muted);
+ transition: transform 0.15s ease;
+ flex-shrink: 0;
+}
+.sidebar-section.expanded .sidebar-section-chevron { transform: rotate(90deg); }
+.sidebar-section-name {
+ font-size: 11px;
+ font-weight: 500;
+ color: var(--dash-fg-muted);
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.sidebar-section.expanded .sidebar-section-name { color: var(--dash-fg); }
+.sidebar-section-btns {
+ display: flex;
+ gap: 2px;
+ flex-shrink: 0;
+ opacity: 0;
+ transition: opacity 0.15s;
+}
+.sidebar-section-header:hover .sidebar-section-btns { opacity: 1; }
+.sidebar-section-btn {
+ background: transparent;
+ border: none;
+ color: var(--dash-fg-muted);
+ cursor: pointer;
+ padding: 1px 3px;
+ border-radius: 3px;
+ font-size: 11px;
+ line-height: 1;
+}
+.sidebar-section-btn:hover { background: var(--dash-bg-hover); color: var(--dash-fg); }
+.sidebar-section.maximized { flex: 100 !important; }
+.sidebar-section:not(.maximized):not(.expanded) { }
+/* When any section is maximized, collapse siblings */
+#sidebar-view-slot:has(.sidebar-section.maximized) .sidebar-section:not(.maximized) {
+ flex: 0 0 auto !important;
+}
+#sidebar-view-slot:has(.sidebar-section.maximized) .sidebar-section:not(.maximized) .sidebar-section-body {
+ display: none !important;
+}
+.sidebar-section-body {
+ flex: 1;
+ min-height: 0;
+ position: relative;
+ display: none;
+}
+.sidebar-section.expanded .sidebar-section-body {
+ display: block;
+}
+
+/* โโ Drawer backdrop โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
+#drawer-backdrop {
+ position: fixed;
+ inset: 0;
+ background: rgba(0,0,0,0.4);
+ z-index: 199;
+ display: none;
+}
+#drawer-backdrop.visible { display: block; }
+
+/* โโ Mobile sidebar mode โ sidebar becomes a fixed slide-over โโโโ */
+body.mobile-sidebar .grid-row { flex-direction: row; }
+body.mobile-sidebar .panel { min-width: unset; }
+body.mobile-sidebar .resize-handle-col,
+body.mobile-sidebar .resize-handle-row { display: none; }
+body.mobile-sidebar #sidebar-toggle { display: block; }
+/* Hide non-primary panels from the grid โ they live in the sidebar */
+body.mobile-sidebar .panel.sidebar-hosted { display: none; }
+body.mobile-sidebar .panel:not(.sidebar-hosted) { flex: 1 !important; }
+/* Sidebar becomes a fixed overlay on mobile */
+body.mobile-sidebar #sidebar-panel {
+ display: none;
+ position: fixed;
+ top: 0; right: 0; bottom: 0;
+ width: min(85vw, 360px);
+ max-width: none;
+ min-width: 0;
+ z-index: 210;
+ box-shadow: -4px 0 24px rgba(0,0,0,0.5);
+}
+body.mobile-sidebar #sidebar-panel.open { display: flex; }
+body.mobile-sidebar #sidebar-header { display: flex; }
+
+/* โโ Container-aware layout (ResizeObserver classes for IDE embedding) */
+body.container-narrow .grid-row { flex-direction: column; }
+body.container-narrow .resize-handle-col { display: none; }
+body.container-narrow .panel + .panel { border-left: none; border-top: 1px solid var(--dash-border); }
+body.container-narrow .panel { min-width: unset; }
diff --git a/src/mcp_cli/dashboard/static/css/toolbar.css b/src/mcp_cli/dashboard/static/css/toolbar.css
new file mode 100644
index 00000000..55bd867a
--- /dev/null
+++ b/src/mcp_cli/dashboard/static/css/toolbar.css
@@ -0,0 +1,94 @@
+/* โโ Toolbar โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
+#toolbar {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 0 12px;
+ height: 36px;
+ min-height: 36px;
+ background: var(--dash-bg-surface);
+ border-bottom: 1px solid var(--dash-border);
+ flex-shrink: 0;
+ user-select: none;
+}
+#toolbar .brand {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-weight: 600;
+ font-size: 13px;
+ color: var(--dash-fg);
+}
+#conn-dot {
+ width: 8px; height: 8px;
+ border-radius: 50%;
+ background: var(--dash-error);
+ flex-shrink: 0;
+ transition: background 0.3s;
+}
+#conn-dot.connected { background: var(--dash-success); }
+
+#toolbar .spacer { flex: 1; }
+
+.tb-btn {
+ background: transparent;
+ border: 1px solid var(--dash-border);
+ color: var(--dash-fg);
+ padding: 2px 10px;
+ border-radius: var(--dash-radius);
+ cursor: pointer;
+ font-size: 12px;
+ font-family: var(--dash-font-ui);
+ white-space: nowrap;
+}
+.tb-btn:hover { background: var(--dash-bg-hover); }
+
+/* Layout dropdown */
+.dropdown { position: relative; }
+.dropdown-menu {
+ display: none;
+ position: absolute;
+ top: calc(100% + 4px);
+ left: 0;
+ background: var(--dash-bg-surface);
+ border: 1px solid var(--dash-border);
+ border-radius: var(--dash-radius);
+ min-width: 140px;
+ z-index: 100;
+ box-shadow: 0 4px 12px rgba(0,0,0,0.4);
+}
+.dropdown-menu.open { display: block; }
+.dropdown-item {
+ padding: 6px 12px;
+ cursor: pointer;
+ font-size: 12px;
+ color: var(--dash-fg);
+}
+.dropdown-item:hover { background: var(--dash-bg-hover); }
+.dropdown-item.active { color: var(--dash-accent); }
+
+/* Toolbar selects (model/provider) */
+.tb-select {
+ background: var(--dash-bg);
+ border: 1px solid var(--dash-border);
+ color: var(--dash-fg);
+ padding: 2px 6px;
+ border-radius: var(--dash-radius);
+ font-size: 11px;
+ font-family: var(--dash-font-ui);
+ cursor: pointer;
+ max-width: 140px;
+ outline: none;
+}
+.tb-select:hover { border-color: var(--dash-accent); }
+.tb-label {
+ font-size: 10px;
+ color: var(--dash-fg-muted);
+ text-transform: uppercase;
+ letter-spacing: .04em;
+}
+.tb-group {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
diff --git a/src/mcp_cli/dashboard/static/css/variables.css b/src/mcp_cli/dashboard/static/css/variables.css
new file mode 100644
index 00000000..d63cb64f
--- /dev/null
+++ b/src/mcp_cli/dashboard/static/css/variables.css
@@ -0,0 +1,37 @@
+/* โโ Reset โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+html, body { height: 100%; overflow: hidden; }
+
+/* โโ Theme variables (overwritten by JS) โโโโโโโโโโโโโโโโโโโโโโโโโโ */
+:root {
+ --dash-bg: #1e1e2e;
+ --dash-bg-surface: #262637;
+ --dash-bg-hover: #2e2e42;
+ --dash-fg: #e0e0e0;
+ --dash-fg-muted: #888899;
+ --dash-accent: #7aa2f7;
+ --dash-success: #9ece6a;
+ --dash-warning: #e0af68;
+ --dash-error: #f7768e;
+ --dash-info: #7dcfff;
+ --dash-border: #3b3b52;
+ --dash-font-mono: 'JetBrains Mono','Fira Code','Cascadia Code',monospace;
+ --dash-font-ui: 'Inter',-apple-system,sans-serif;
+ --dash-font-size: 13px;
+ --dash-radius: 6px;
+ --dash-spacing: 8px;
+ --dash-toolbar-height: 36px;
+ --dash-touch-target: 44px;
+ --dash-panel-min-width: 200px;
+ --dash-panel-min-height: 150px;
+}
+
+body {
+ background: var(--dash-bg);
+ color: var(--dash-fg);
+ font-family: var(--dash-font-ui);
+ font-size: var(--dash-font-size);
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+}
diff --git a/src/mcp_cli/dashboard/static/js/approval.js b/src/mcp_cli/dashboard/static/js/approval.js
new file mode 100644
index 00000000..b7d3e68a
--- /dev/null
+++ b/src/mcp_cli/dashboard/static/js/approval.js
@@ -0,0 +1,46 @@
+// ================================================================
+// js/approval.js โ Tool approval dialog
+// ================================================================
+'use strict';
+
+import {
+ _pendingApprovalCallId,
+ setPendingApprovalCallId,
+} from './state.js';
+import { sendToBridge } from './websocket.js';
+import { showToast } from './utils.js';
+
+export function handleToolApprovalRequest(payload) {
+ // If another approval is already showing, deny it first (auto-deny stale)
+ if (_pendingApprovalCallId) {
+ sendToBridge({ type: 'TOOL_APPROVAL_RESPONSE', call_id: _pendingApprovalCallId, approved: false });
+ }
+ setPendingApprovalCallId(payload.call_id || '');
+ document.getElementById('approval-tool-name').textContent = payload.tool_name || 'unknown';
+ try {
+ document.getElementById('approval-args').textContent =
+ JSON.stringify(payload.arguments, null, 2);
+ } catch {
+ document.getElementById('approval-args').textContent = String(payload.arguments || '{}');
+ }
+ document.getElementById('approval-overlay').classList.add('open');
+ showToast('warning', `Approval needed: ${payload.tool_name}`);
+}
+
+export function wireApprovalEvents() {
+ document.getElementById('approval-approve').addEventListener('click', () => {
+ if (_pendingApprovalCallId) {
+ sendToBridge({ type: 'TOOL_APPROVAL_RESPONSE', call_id: _pendingApprovalCallId, approved: true });
+ }
+ document.getElementById('approval-overlay').classList.remove('open');
+ setPendingApprovalCallId(null);
+ });
+
+ document.getElementById('approval-deny').addEventListener('click', () => {
+ if (_pendingApprovalCallId) {
+ sendToBridge({ type: 'TOOL_APPROVAL_RESPONSE', call_id: _pendingApprovalCallId, approved: false });
+ }
+ document.getElementById('approval-overlay').classList.remove('open');
+ setPendingApprovalCallId(null);
+ });
+}
diff --git a/src/mcp_cli/dashboard/static/js/apps.js b/src/mcp_cli/dashboard/static/js/apps.js
new file mode 100644
index 00000000..acfdab1a
--- /dev/null
+++ b/src/mcp_cli/dashboard/static/js/apps.js
@@ -0,0 +1,126 @@
+// ================================================================
+// js/apps.js โ MCP App panel handlers
+// ================================================================
+'use strict';
+
+import {
+ viewRegistry, viewPool, popoutWindows,
+ setViewRegistry,
+} from './state.js';
+import { buildSidebarSections } from './sidebar.js';
+import { syncViewPositions, rebuildAddPanelMenu } from './layout.js';
+import { showToast } from './utils.js';
+
+export function handleAppLaunched(payload) {
+ const toolName = payload.tool_name;
+ const appUrl = payload.url;
+ const resourceUri = payload.resource_uri || null;
+ const viewId = 'app:' + toolName;
+ const prettyName = toolName.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
+
+ // Already registered by the same tool name โ update URL and refresh iframe
+ const existing = viewRegistry.find(v => v.id === viewId);
+ if (existing) {
+ existing.url = appUrl;
+ existing.resourceUri = resourceUri;
+ const view = viewPool.get(viewId);
+ if (view && view.iframe) {
+ view.iframe.src = appUrl + (appUrl.includes('?') ? '&' : '?') + 'embedded=1';
+ }
+ showToast('info', 'App updated: ' + prettyName);
+ return;
+ }
+
+ // Different tool name but same resourceUri โ reuse the existing view slot
+ if (resourceUri) {
+ const sameResource = viewRegistry.find(
+ v => v.type === 'app' && v.resourceUri === resourceUri
+ );
+ if (sameResource) {
+ const oldId = sameResource.id;
+ // Update registry entry in place (keep same slot in sidebar)
+ sameResource.id = viewId;
+ sameResource.name = prettyName;
+ sameResource.url = appUrl;
+ sameResource.resourceUri = resourceUri;
+ sameResource.source = payload.server_name || 'app';
+
+ // Migrate viewPool entry to new id
+ const oldView = viewPool.get(oldId);
+ if (oldView) {
+ viewPool.delete(oldId);
+ viewPool.set(viewId, oldView);
+ // Refresh iframe with new URL
+ if (oldView.iframe) {
+ oldView.iframe.src = appUrl + (appUrl.includes('?') ? '&' : '?') + 'embedded=1';
+ }
+ }
+
+ // Migrate popout window if any
+ const oldPopout = popoutWindows.get(oldId);
+ if (oldPopout) {
+ popoutWindows.delete(oldId);
+ popoutWindows.set(viewId, oldPopout);
+ }
+
+ // Rebuild sidebar sections so the data-view-id attributes update
+ buildSidebarSections();
+ rebuildAddPanelMenu();
+ requestAnimationFrame(() => syncViewPositions());
+ showToast('info', 'View updated: ' + prettyName);
+ return;
+ }
+ }
+
+ // Register as new view
+ viewRegistry.push({
+ id: viewId,
+ name: prettyName,
+ source: payload.server_name || 'app',
+ icon: '\u{1F5A5}',
+ type: 'app',
+ url: appUrl,
+ resourceUri: resourceUri,
+ });
+ rebuildAddPanelMenu();
+
+ // Add to sidebar as collapsible section (not a grid panel)
+ buildSidebarSections();
+
+ // Auto-expand the new app section
+ const newSection = document.querySelector(`.sidebar-section[data-view-id="${viewId}"]`);
+ if (newSection && !newSection.classList.contains('expanded')) {
+ newSection.classList.add('expanded');
+ }
+
+ // Mark auto-ready (app uses JSON-RPC, not mcp-dashboard READY)
+ const view = viewPool.get(viewId);
+ if (view) {
+ view.ready = true;
+ if (view._readyTimeout) { clearTimeout(view._readyTimeout); view._readyTimeout = null; }
+ }
+
+ requestAnimationFrame(() => syncViewPositions());
+ showToast('success', 'App launched: ' + prettyName);
+}
+
+export function handleAppClosed(payload) {
+ const viewId = 'app:' + payload.tool_name;
+
+ setViewRegistry(viewRegistry.filter(v => v.id !== viewId));
+
+ // Clean up iframe and popout
+ const view = viewPool.get(viewId);
+ if (view && view.iframe) view.iframe.remove();
+ viewPool.delete(viewId);
+
+ const popout = popoutWindows.get(viewId);
+ if (popout && !popout.win.closed) { popout.win.close(); clearInterval(popout.intervalId); }
+ popoutWindows.delete(viewId);
+
+ // Rebuild sidebar sections (app section disappears)
+ buildSidebarSections();
+ rebuildAddPanelMenu();
+ requestAnimationFrame(() => syncViewPositions());
+ showToast('info', 'App closed: ' + payload.tool_name);
+}
diff --git a/src/mcp_cli/dashboard/static/js/config.js b/src/mcp_cli/dashboard/static/js/config.js
new file mode 100644
index 00000000..8e219c0d
--- /dev/null
+++ b/src/mcp_cli/dashboard/static/js/config.js
@@ -0,0 +1,122 @@
+// ================================================================
+// js/config.js โ Config state (model, provider, servers, prompt)
+// ================================================================
+'use strict';
+
+import {
+ _configState, _availableProviders,
+ setConfigState, setAvailableProviders,
+} from './state.js';
+import { sendToBridge } from './websocket.js';
+import { esc, showToast } from './utils.js';
+
+export function handleConfigState(payload) {
+ setConfigState(payload);
+ setAvailableProviders(payload.available_providers || []);
+ updateProviderSelect(payload.provider);
+ updateModelSelect(payload.provider, payload.model);
+ updateServerList(payload.servers || []);
+ updateSystemPromptEditor(payload.system_prompt || '');
+ // Show model group once we have config
+ document.getElementById('model-group').style.display = 'flex';
+}
+
+// โโ Provider / Model selectors โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+export function updateProviderSelect(activeProvider) {
+ const sel = document.getElementById('provider-select');
+ const prev = sel.value;
+ sel.innerHTML = '';
+ for (const p of _availableProviders) {
+ const opt = document.createElement('option');
+ opt.value = p.name;
+ opt.textContent = p.name;
+ sel.appendChild(opt);
+ }
+ sel.value = activeProvider || prev;
+}
+
+export function updateModelSelect(provider, activeModel) {
+ const sel = document.getElementById('model-select');
+ sel.innerHTML = '';
+ const pInfo = _availableProviders.find(p => p.name === provider);
+ const models = pInfo ? pInfo.models : [];
+ if (models.length === 0) {
+ const opt = document.createElement('option');
+ opt.value = activeModel || '';
+ opt.textContent = activeModel || '(unknown)';
+ sel.appendChild(opt);
+ } else {
+ for (const m of models) {
+ const opt = document.createElement('option');
+ opt.value = m;
+ opt.textContent = m;
+ sel.appendChild(opt);
+ }
+ }
+ sel.value = activeModel || '';
+ // If active model not in list, add it
+ if (sel.value !== activeModel && activeModel) {
+ const opt = document.createElement('option');
+ opt.value = activeModel;
+ opt.textContent = activeModel;
+ sel.insertBefore(opt, sel.firstChild);
+ sel.value = activeModel;
+ }
+}
+
+export function updateServerList(servers) {
+ const list = document.getElementById('server-list');
+ list.innerHTML = '';
+ if (!servers.length) {
+ list.innerHTML = 'No servers connected ';
+ return;
+ }
+ for (const s of servers) {
+ const li = document.createElement('li');
+ li.className = 'server-item';
+ li.innerHTML = `
+
+ ${esc(s.name)}
+ ${esc(String(s.tool_count))} tools
+ `;
+ list.appendChild(li);
+ }
+}
+
+// โโ System prompt editor โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+export function updateSystemPromptEditor(prompt) {
+ const editor = document.getElementById('system-prompt-editor');
+ // Only update if not focused (avoid overwriting user edits)
+ if (document.activeElement !== editor) {
+ editor.value = prompt;
+ }
+}
+
+// โโ Wire config event listeners โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+export function wireConfigEvents() {
+ document.getElementById('provider-select').addEventListener('change', (e) => {
+ const provider = e.target.value;
+ const pInfo = _availableProviders.find(p => p.name === provider);
+ const models = pInfo ? pInfo.models : [];
+ const model = models[0] || '';
+ updateModelSelect(provider, model);
+ if (model) sendToBridge({ type: 'SWITCH_MODEL', provider, model });
+ });
+
+ document.getElementById('model-select').addEventListener('change', (e) => {
+ const model = e.target.value;
+ const provider = document.getElementById('provider-select').value;
+ if (provider && model) sendToBridge({ type: 'SWITCH_MODEL', provider, model });
+ });
+
+ document.getElementById('apply-prompt-btn').addEventListener('click', () => {
+ const text = document.getElementById('system-prompt-editor').value;
+ sendToBridge({ type: 'UPDATE_SYSTEM_PROMPT', system_prompt: text });
+ showToast('info', 'System prompt updated');
+ });
+
+ document.getElementById('reset-prompt-btn').addEventListener('click', () => {
+ sendToBridge({ type: 'UPDATE_SYSTEM_PROMPT', system_prompt: '' });
+ showToast('info', 'System prompt reset to default');
+ });
+}
diff --git a/src/mcp_cli/dashboard/static/js/dispatcher.js b/src/mcp_cli/dashboard/static/js/dispatcher.js
new file mode 100644
index 00000000..0787aaf9
--- /dev/null
+++ b/src/mcp_cli/dashboard/static/js/dispatcher.js
@@ -0,0 +1,124 @@
+// ================================================================
+// js/dispatcher.js โ Bridge message routing (big switch statement)
+// ================================================================
+'use strict';
+
+import { setCachedToolRegistry, setCachedPlanUpdate } from './state.js';
+import {
+ mergeViewRegistry, routeToViews, broadcastToViews,
+ broadcastToViewType, sendToActivityStream,
+} from './views.js';
+import { rebuildAddPanelMenu } from './layout.js';
+import {
+ handleAgentList, handleAgentRegistered,
+ handleAgentUnregistered, handleAgentStatus,
+ isFocusedAgent,
+} from './websocket.js';
+import { handleAppLaunched, handleAppClosed } from './apps.js';
+import { handleConfigState } from './config.js';
+import { handleSessionList, handleSessionState } from './sessions.js';
+import { handleToolApprovalRequest } from './approval.js';
+import { applyTheme } from './theme.js';
+
+export function handleBridgeMessage(msg) {
+ switch (msg.type) {
+ case 'VIEW_REGISTRY':
+ // Merge dynamic views from bridge without clobbering builtins
+ // Envelope format: msg.payload.views; legacy format: msg.views
+ mergeViewRegistry((msg.payload && msg.payload.views) || msg.views || []);
+ rebuildAddPanelMenu();
+ break;
+
+ // โโ MCP App lifecycle โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ case 'APP_LAUNCHED':
+ handleAppLaunched(msg.payload);
+ break;
+ case 'APP_CLOSED':
+ handleAppClosed(msg.payload);
+ break;
+
+ // โโ Agent lifecycle messages โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ case 'AGENT_LIST':
+ handleAgentList(msg.payload);
+ broadcastToViewType('agents', 'AGENT_LIST', msg.payload);
+ break;
+
+ case 'AGENT_REGISTERED':
+ handleAgentRegistered(msg.payload);
+ broadcastToViewType('agents', 'AGENT_REGISTERED', msg.payload);
+ break;
+
+ case 'AGENT_UNREGISTERED':
+ handleAgentUnregistered(msg.payload);
+ broadcastToViewType('agents', 'AGENT_UNREGISTERED', msg.payload);
+ break;
+
+ case 'AGENT_STATUS':
+ handleAgentStatus(msg.payload);
+ broadcastToViewType('agents', 'AGENT_STATUS', msg.payload);
+ break;
+
+ // โโ Agent-scoped messages โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ case 'TOOL_RESULT':
+ sendToActivityStream('TOOL_RESULT', msg.payload);
+ if (isFocusedAgent(msg)) routeToViews('TOOL_RESULT', msg.payload);
+ break;
+
+ case 'AGENT_STATE':
+ if (isFocusedAgent(msg)) broadcastToViews('AGENT_STATE', msg.payload);
+ broadcastToViewType('agents', 'AGENT_STATE', msg.payload);
+ break;
+
+ case 'CONVERSATION_MESSAGE':
+ sendToActivityStream('CONVERSATION_MESSAGE', msg.payload);
+ if (isFocusedAgent(msg)) broadcastToViewType('conversation', 'CONVERSATION_MESSAGE', msg.payload);
+ break;
+
+ case 'CONVERSATION_TOKEN':
+ if (isFocusedAgent(msg)) broadcastToViewType('conversation', 'CONVERSATION_TOKEN', msg.payload);
+ break;
+
+ case 'CONVERSATION_HISTORY':
+ if (isFocusedAgent(msg)) broadcastToViewType('conversation', 'CONVERSATION_HISTORY', msg.payload);
+ break;
+
+ case 'ACTIVITY_HISTORY':
+ sendToActivityStream('ACTIVITY_HISTORY', msg.payload);
+ break;
+
+ case 'CONFIG_STATE':
+ handleConfigState(msg.payload);
+ broadcastToViews('CONFIG_STATE', msg.payload);
+ break;
+
+ case 'TOOL_REGISTRY':
+ setCachedToolRegistry(msg.payload);
+ broadcastToViewType('tools', 'TOOL_REGISTRY', msg.payload);
+ break;
+
+ case 'TOOL_APPROVAL_REQUEST':
+ handleToolApprovalRequest(msg.payload);
+ break;
+
+ case 'PLAN_UPDATE':
+ setCachedPlanUpdate(msg.payload);
+ broadcastToViewType('plan', 'PLAN_UPDATE', msg.payload);
+ sendToActivityStream('PLAN_UPDATE', msg.payload);
+ break;
+
+ case 'SESSION_STATE':
+ handleSessionState(msg.payload);
+ break;
+
+ case 'SESSION_LIST':
+ handleSessionList(msg.payload);
+ break;
+
+ case 'THEME':
+ applyTheme(msg.payload);
+ break;
+
+ default:
+ break;
+ }
+}
diff --git a/src/mcp_cli/dashboard/static/js/export.js b/src/mcp_cli/dashboard/static/js/export.js
new file mode 100644
index 00000000..0c305572
--- /dev/null
+++ b/src/mcp_cli/dashboard/static/js/export.js
@@ -0,0 +1,67 @@
+// ================================================================
+// js/export.js โ Export conversation
+// ================================================================
+'use strict';
+
+import { viewPool } from './state.js';
+import { postToIframe } from './views.js';
+import { sendToBridge } from './websocket.js';
+import { showToast } from './utils.js';
+
+export function exportConversation(format) {
+ // Collect messages from the agent-terminal iframe
+ const msgs = collectConversationMessages();
+ if (!msgs.length) { showToast('warning', 'No messages to export'); return; }
+
+ let content, ext, mime;
+ if (format === 'json') {
+ content = JSON.stringify({ exported: new Date().toISOString(), messages: msgs }, null, 2);
+ ext = 'json'; mime = 'application/json';
+ } else {
+ const lines = [`# Conversation Export\n_${new Date().toISOString()}_\n`];
+ for (const m of msgs) {
+ const label = m.role === 'user' ? '**You**' : m.role === 'assistant' ? '**Agent**' : `**${m.role}**`;
+ lines.push(`### ${label}\n${m.content}\n`);
+ }
+ content = lines.join('\n');
+ ext = 'md'; mime = 'text/markdown';
+ }
+
+ const blob = new Blob([content], { type: mime });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `conversation-${Date.now()}.${ext}`;
+ a.click();
+ URL.revokeObjectURL(url);
+ showToast('success', `Exported as ${ext.toUpperCase()}`);
+}
+
+export function collectConversationMessages() {
+ // Find the agent-terminal iframe and read its messages
+ for (const [viewId, view] of viewPool) {
+ if (viewId !== 'builtin:agent-terminal' || !view.iframe) continue;
+ try {
+ const doc = view.iframe.contentDocument || view.iframe.contentWindow.document;
+ const msgEls = doc.querySelectorAll('.msg');
+ const messages = [];
+ for (const el of msgEls) {
+ const role = el.classList.contains('user') ? 'user' :
+ el.classList.contains('tool-call') ? 'tool' : 'assistant';
+ const contentEl = el.querySelector('.msg-content');
+ const toolNameEl = el.querySelector('.tool-name');
+ let content = '';
+ if (contentEl) content = contentEl.textContent || '';
+ else if (toolNameEl) content = `[Tool: ${toolNameEl.textContent}]`;
+ if (content) messages.push({ role, content });
+ }
+ return messages;
+ } catch (e) {
+ // Cross-origin โ fall back to asking the bridge
+ sendToBridge({ type: 'REQUEST_EXPORT' });
+ showToast('info', 'Export requested from server');
+ return [];
+ }
+ }
+ return [];
+}
diff --git a/src/mcp_cli/dashboard/static/js/init.js b/src/mcp_cli/dashboard/static/js/init.js
new file mode 100644
index 00000000..61b9f168
--- /dev/null
+++ b/src/mcp_cli/dashboard/static/js/init.js
@@ -0,0 +1,122 @@
+// ================================================================
+// js/init.js โ Entry point: wires everything together and boots
+// ================================================================
+'use strict';
+
+import {
+ layoutConfig, setLayoutConfig, setViewRegistry,
+} from './state.js';
+import { setCloseSidebarFn } from './utils.js';
+import { loadThemes } from './theme.js';
+import { connectWS, setMessageHandler } from './websocket.js';
+import { handleBridgeMessage } from './dispatcher.js';
+import {
+ renderLayout, buildLayoutMenu, defaultLayout,
+ rebuildAddPanelMenu, syncViewPositions,
+ showPanelError, notifyResize,
+ findPopoutViewIdByWindow, handlePopoutReady, postToPopout,
+ setLayoutDeps,
+} from './layout.js';
+import {
+ updateSidebarMode, setupSidebarResize,
+ wireSidebarEvents, closeSidebar,
+ buildSidebarSections, notifySidebarUpdate,
+} from './sidebar.js';
+import { buildOverflowMenu, wireToolbarEvents } from './toolbar.js';
+import { wireConfigEvents } from './config.js';
+import { wireApprovalEvents } from './approval.js';
+import { setViewDeps, setupViewMessageListener } from './views.js';
+
+// โโ Wire up late-binding deps to break circular imports โโโโโโโโโโโ
+
+// views.js needs layout.js + sidebar.js functions
+setViewDeps({
+ syncViewPositions,
+ showPanelError,
+ notifyResize,
+ findPopoutViewIdByWindow,
+ handlePopoutReady,
+ postToPopout,
+ notifySidebarUpdate,
+});
+
+// layout.js needs sidebar.js functions
+setLayoutDeps({
+ buildSidebarSections,
+});
+
+// utils.js closeAllDrawers needs closeSidebar from sidebar.js
+setCloseSidebarFn(closeSidebar);
+
+// websocket.js needs dispatcher.js handleBridgeMessage
+setMessageHandler(handleBridgeMessage);
+
+// โโ Container-aware layout (for IDE embedding) โโโโโโโโโโโโโโโโโโโโ
+function setupContainerObserver() {
+ if (typeof ResizeObserver === 'undefined') return;
+ const gridWrapper = document.getElementById('grid-wrapper');
+ const ro = new ResizeObserver(entries => {
+ for (const entry of entries) {
+ const w = entry.contentRect.width;
+ document.body.classList.toggle('container-narrow', w < 600);
+ document.body.classList.toggle('container-xs', w < 400);
+ }
+ requestAnimationFrame(() => syncViewPositions());
+ });
+ ro.observe(gridWrapper);
+}
+
+// โโ Responsive resize handler โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+window.addEventListener('resize', () => {
+ updateSidebarMode();
+ buildOverflowMenu();
+});
+
+// โโ Initialisation โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+async function init() {
+ await loadThemes();
+ buildLayoutMenu();
+
+ // Bootstrap view registry with builtins
+ setViewRegistry([
+ { id: 'builtin:agent-terminal', name: 'Agent Terminal', source: 'builtin', icon: 'โจ', type: 'conversation', url: '/views/agent-terminal.html' },
+ { id: 'builtin:activity-stream', name: 'Activity Stream', source: 'builtin', icon: 'โ', type: 'stream', url: '/views/activity-stream.html' },
+ { id: 'builtin:tool-browser', name: 'Tool Browser', source: 'builtin', icon: '๐ง', type: 'tools', url: '/views/tool-browser.html' },
+ { id: 'builtin:plan-viewer', name: 'Plan Viewer', source: 'builtin', icon: '๐', type: 'plan', url: '/views/plan-viewer.html' },
+ { id: 'builtin:agent-overview', name: 'Agent Overview', source: 'builtin', icon: '๐ฅ', type: 'agents', url: '/views/agent-overview.html' },
+ ]);
+ rebuildAddPanelMenu();
+
+ // Render layout โ auto-select based on screen size if no stored layout
+ let config = null;
+ try {
+ const stored = localStorage.getItem('dash-layout');
+ config = stored ? JSON.parse(stored) : null;
+ } catch (e) {
+ console.warn('Could not parse stored layout, using default:', e);
+ config = null;
+ }
+ if (!config) {
+ config = defaultLayout();
+ }
+ setLayoutConfig(config);
+ renderLayout(config);
+
+ // Wire up all event listeners
+ setupViewMessageListener();
+ wireSidebarEvents();
+ wireToolbarEvents();
+ wireConfigEvents();
+ wireApprovalEvents();
+
+ // Initialise responsive state
+ updateSidebarMode();
+ buildOverflowMenu();
+ setupContainerObserver();
+ setupSidebarResize();
+
+ // Connect WebSocket
+ connectWS();
+}
+
+init();
diff --git a/src/mcp_cli/dashboard/static/js/layout.js b/src/mcp_cli/dashboard/static/js/layout.js
new file mode 100644
index 00000000..59cca85a
--- /dev/null
+++ b/src/mcp_cli/dashboard/static/js/layout.js
@@ -0,0 +1,470 @@
+// ================================================================
+// js/layout.js โ Layout rendering, panel ops, resize handles
+// ================================================================
+'use strict';
+
+import {
+ panels, panelCounter, layoutConfig, viewRegistry, viewPool, popoutWindows,
+ PROTOCOL, VERSION, focusedAgentId, themes, activeTheme,
+ setPanels, incPanelCounter, setLayoutConfig,
+ isSidebarView, _sidebarOpen,
+} from './state.js';
+import { makeDraggable, showToast } from './utils.js';
+import {
+ getOrCreateView, attachViewToSlot, iconForView, labelForView, srcForView,
+ updatePanelHeader, switchPanelView, populateViewMenu, postToIframe,
+ broadcastToViews, findPanelHostingView,
+} from './views.js';
+import { themeToCSS } from './theme.js';
+
+// โโ Late-binding for sidebar deps to avoid circular imports โโโโโโโ
+let _buildSidebarSections = null;
+
+export function setLayoutDeps(deps) {
+ _buildSidebarSections = deps.buildSidebarSections;
+}
+
+// โโ Layout rendering โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+export function defaultLayout() {
+ // Terminal fills the left panel; activity stream + apps live in the sidebar
+ return {
+ rows: [
+ {
+ height: '100%',
+ columns: [
+ { width: '100%', view: 'builtin:agent-terminal' },
+ ],
+ },
+ ],
+ };
+}
+
+export function renderLayout(layout) {
+ const root = document.getElementById('grid-root');
+ // Iframes stay in #view-overlay โ we only rebuild the panel grid.
+ // No iframe reparenting occurs, so browsing context is preserved.
+ root.innerHTML = '';
+ setPanels({});
+
+ const rows = layout.rows || [];
+ rows.forEach((row, ri) => {
+ if (ri > 0) root.appendChild(makeRowHandle(root, ri));
+ const rowEl = document.createElement('div');
+ rowEl.className = 'grid-row';
+ rowEl.style.flex = parseFlex(row.height || '100%');
+ root.appendChild(rowEl);
+ (row.columns || []).forEach((col, ci) => {
+ if (ci > 0) rowEl.appendChild(makeColHandle(rowEl, ci));
+ const panelEl = createPanelSlot(col.view || 'auto', rowEl);
+ panelEl.style.flex = parseFlex(col.width || '100%');
+ rowEl.appendChild(panelEl);
+ });
+ });
+
+ // Position iframes over their panel body slots after browser layout
+ requestAnimationFrame(() => syncViewPositions());
+}
+
+export function parseFlex(pct) {
+ const n = parseFloat(pct);
+ return isNaN(n) ? '1' : String(n);
+}
+
+export function createPanelSlot(viewId, rowEl) {
+ const panelId = 'panel_' + incPanelCounter();
+ const resolvedViewId = resolveAutoView(viewId);
+
+ const panelEl = document.createElement('div');
+ panelEl.className = 'panel';
+ panelEl.dataset.panelId = panelId;
+
+ // Header
+ const header = document.createElement('div');
+ header.className = 'panel-header';
+ header.draggable = true;
+ header.innerHTML = `
+ ${iconForView(resolvedViewId)}
+ ${labelForView(resolvedViewId)} โพ
+
+ โคข
+ โ
+ ร
+ `;
+ panelEl.appendChild(header);
+
+ const body = document.createElement('div');
+ body.className = 'panel-body';
+ panelEl.appendChild(body);
+
+ const panel = { panelId, el: panelEl, viewId: resolvedViewId, rowEl };
+ panels[panelId] = panel;
+
+ if (resolvedViewId) {
+ attachViewToSlot(resolvedViewId);
+ } else {
+ const ph = document.createElement('div');
+ ph.className = 'panel-placeholder';
+ ph.textContent = 'Connect an MCP server with views to populate this panel.';
+ body.appendChild(ph);
+ }
+
+ // Button + view-picker click handler
+ header.addEventListener('click', (e) => {
+ const btn = e.target.closest('[data-action]');
+ if (btn) {
+ e.stopPropagation();
+ const action = btn.dataset.action;
+ if (action === 'minimize') { panelEl.classList.toggle('minimized'); requestAnimationFrame(() => syncViewPositions()); }
+ else if (action === 'close') closePanel(panelId);
+ else if (action === 'popout') popoutPanel(panel);
+ return;
+ }
+ if (e.target.classList.contains('panel-view-toggle')) {
+ e.stopPropagation();
+ const menu = header.querySelector('.panel-view-menu');
+ if (!menu.classList.contains('open')) populateViewMenu(menu, panelId);
+ menu.classList.toggle('open');
+ }
+ });
+
+ // Drag-to-swap
+ header.addEventListener('dragstart', (e) => e.dataTransfer.setData('text/plain', panelId));
+ panelEl.addEventListener('dragover', (e) => e.preventDefault());
+ panelEl.addEventListener('drop', (e) => {
+ e.preventDefault();
+ const srcId = e.dataTransfer.getData('text/plain');
+ if (srcId && srcId !== panelId) swapPanels(srcId, panelId);
+ });
+
+ return panelEl;
+}
+
+export function resolveAutoView(viewId) {
+ if (viewId !== 'auto') return viewId;
+ const placed = new Set(Object.values(panels).map(p => p.viewId));
+ const builtins = new Set(['builtin:agent-terminal', 'builtin:activity-stream']);
+ for (const v of viewRegistry) {
+ if (!builtins.has(v.id) && !placed.has(v.id)) return v.id;
+ }
+ return null;
+}
+
+// โโ Panel operations โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+export function closePanel(panelId) {
+ const panel = panels[panelId];
+ if (!panel) return;
+ // Don't touch the iframe โ it stays alive in #view-overlay.
+ // syncViewPositions() will hide it since no panel hosts it.
+ const rowEl = panel.rowEl;
+ panel.el.remove();
+ delete panels[panelId];
+ if (rowEl && rowEl.querySelectorAll('.panel').length === 0) rowEl.remove();
+ rebuildAddPanelMenu();
+ // Rebuild sidebar sections in case panel had a sidebar view
+ if (_buildSidebarSections) _buildSidebarSections();
+ requestAnimationFrame(() => syncViewPositions());
+}
+
+function popoutPanel(panel) {
+ popoutSidebarView(panel.viewId);
+}
+
+export function popoutSidebarView(viewId) {
+ const url = srcForView(viewId);
+ if (!url) return;
+ // Reuse existing popup if still open
+ const existing = popoutWindows.get(viewId);
+ if (existing && !existing.win.closed) { existing.win.focus(); return; }
+ const win = window.open(url, `dash-pop-${viewId}`, 'width=900,height=600,menubar=no,toolbar=no,location=no');
+ if (!win) return;
+ const intervalId = setInterval(() => {
+ if (win.closed) { popoutWindows.delete(viewId); clearInterval(intervalId); }
+ }, 1000);
+ popoutWindows.set(viewId, { win, intervalId });
+}
+
+export function findPopoutViewIdByWindow(win) {
+ for (const [viewId, entry] of popoutWindows) {
+ if (entry.win === win) return viewId;
+ }
+ return null;
+}
+
+export function handlePopoutReady(viewId, payload, win) {
+ const themeObj = themes[activeTheme] || themes['dark'] || {};
+ try {
+ win.postMessage({
+ protocol: PROTOCOL, version: VERSION, type: 'INIT',
+ payload: {
+ view_id: viewId, panel_id: null, agent_id: focusedAgentId,
+ theme: themeToCSS(themeObj),
+ dimensions: { width: win.innerWidth || 900, height: win.innerHeight || 600 },
+ },
+ }, '*');
+ } catch (e) { /* ignore */ }
+}
+
+export function postToPopout(win, type, payload) {
+ try { win.postMessage({ protocol: PROTOCOL, version: VERSION, type, payload }, '*'); } catch (e) { /* ignore */ }
+}
+
+export function swapPanels(aId, bId) {
+ const a = panels[aId], b = panels[bId];
+ if (!a || !b || a.viewId === b.viewId) return;
+ // Just swap the viewId mappings โ no iframe DOM manipulation.
+ // syncViewPositions() repositions iframes over the correct panel bodies.
+ const tmp = a.viewId;
+ a.viewId = b.viewId;
+ b.viewId = tmp;
+ updatePanelHeader(a);
+ updatePanelHeader(b);
+ requestAnimationFrame(() => syncViewPositions());
+}
+
+export function showPanelError(bodyEl, msg) {
+ const ph = document.createElement('div');
+ ph.className = 'panel-placeholder';
+ ph.style.color = 'var(--dash-error)';
+ ph.textContent = msg;
+ bodyEl.innerHTML = '';
+ bodyEl.appendChild(ph);
+}
+
+// โโ Resize handles (mouse + touch via makeDraggable) โโโโโโโโโโโโโโ
+export function makeColHandle(rowEl, idx) {
+ const handle = document.createElement('div');
+ handle.className = 'resize-handle-col';
+ handle.dataset.handleType = 'col';
+ const overlay = document.getElementById('view-overlay');
+
+ makeDraggable(handle, {
+ onStart(x, _y) {
+ handle.classList.add('dragging');
+ if (overlay) overlay.style.pointerEvents = 'none';
+ const handleIdx = Array.from(rowEl.children).indexOf(handle);
+ const panelsBefore = Array.from(rowEl.children).slice(0, handleIdx).filter(c => c.classList.contains('panel'));
+ const panelsAfter = Array.from(rowEl.children).slice(handleIdx + 1).filter(c => c.classList.contains('panel'));
+ const pBefore = panelsBefore[panelsBefore.length - 1];
+ const pAfter = panelsAfter[0];
+ if (!pBefore || !pAfter) return null;
+ return {
+ startX: x,
+ pBefore, pAfter,
+ startBefore: pBefore.getBoundingClientRect().width,
+ startAfter: pAfter.getBoundingClientRect().width,
+ };
+ },
+ onMove(state, x, _y) {
+ const dx = x - state.startX;
+ const nb = Math.max(200, state.startBefore + dx);
+ const na = Math.max(200, state.startAfter - dx);
+ state.pBefore.style.flex = `0 0 ${nb}px`;
+ state.pAfter.style.flex = `0 0 ${na}px`;
+ syncViewPositions();
+ notifyResize(state.pBefore.dataset.panelId);
+ notifyResize(state.pAfter.dataset.panelId);
+ },
+ onEnd(_state) {
+ handle.classList.remove('dragging');
+ if (overlay) overlay.style.pointerEvents = '';
+ },
+ });
+
+ handle.addEventListener('dblclick', () => {
+ Array.from(rowEl.children).filter(c => c.classList.contains('panel')).forEach(p => {
+ p.style.flex = '';
+ });
+ requestAnimationFrame(() => syncViewPositions());
+ });
+
+ return handle;
+}
+
+export function makeRowHandle(root, idx) {
+ const handle = document.createElement('div');
+ handle.className = 'resize-handle-row';
+ const overlay = document.getElementById('view-overlay');
+
+ makeDraggable(handle, {
+ onStart(_x, y) {
+ handle.classList.add('dragging');
+ if (overlay) overlay.style.pointerEvents = 'none';
+ const handleIdx = Array.from(root.children).indexOf(handle);
+ const rowsBefore = Array.from(root.children).slice(0, handleIdx).filter(c => c.classList.contains('grid-row'));
+ const rowsAfter = Array.from(root.children).slice(handleIdx + 1).filter(c => c.classList.contains('grid-row'));
+ const rBefore = rowsBefore[rowsBefore.length - 1];
+ const rAfter = rowsAfter[0];
+ if (!rBefore || !rAfter) return null;
+ return {
+ startY: y,
+ rBefore, rAfter,
+ startBefore: rBefore.getBoundingClientRect().height,
+ startAfter: rAfter.getBoundingClientRect().height,
+ };
+ },
+ onMove(state, _x, y) {
+ const dy = y - state.startY;
+ const nb = Math.max(150, state.startBefore + dy);
+ const na = Math.max(150, state.startAfter - dy);
+ state.rBefore.style.flex = `0 0 ${nb}px`;
+ state.rAfter.style.flex = `0 0 ${na}px`;
+ syncViewPositions();
+ },
+ onEnd(_state) {
+ handle.classList.remove('dragging');
+ if (overlay) overlay.style.pointerEvents = '';
+ },
+ });
+
+ handle.addEventListener('dblclick', () => {
+ Array.from(root.children).filter(c => c.classList.contains('grid-row')).forEach(r => {
+ r.style.flex = '';
+ });
+ requestAnimationFrame(() => syncViewPositions());
+ });
+
+ return handle;
+}
+
+export function notifyResize(panelId) {
+ const panel = panels[panelId];
+ if (!panel?.viewId) return;
+ const view = viewPool.get(panel.viewId);
+ if (!view?.ready || !view.iframe) return;
+ const body = panel.el.querySelector('.panel-body');
+ if (!body) return;
+ postToIframe(view.iframe, 'RESIZE', { width: body.clientWidth, height: body.clientHeight });
+}
+
+// โโ View overlay positioning โ positions iframes over panel body slots โโ
+export function syncViewPositions() {
+ const overlay = document.getElementById('view-overlay');
+ if (!overlay) return;
+ const overlayRect = overlay.getBoundingClientRect();
+ const inMobileSidebar = document.body.classList.contains('mobile-sidebar');
+
+ for (const [viewId, view] of viewPool) {
+ if (!view.iframe) continue;
+
+ // Sidebar views: position over their .sidebar-section-body in the sidebar panel
+ if (isSidebarView(viewId)) {
+ // On mobile, sidebar must be open; on desktop, sidebar is always in flow
+ const sidebarVisible = inMobileSidebar ? _sidebarOpen : true;
+ if (sidebarVisible) {
+ const sectionBody = document.querySelector(`.sidebar-section-body[data-view-id="${viewId}"]`);
+ if (sectionBody) {
+ const rect = sectionBody.getBoundingClientRect();
+ if (rect.width > 0 && rect.height > 0) {
+ view.iframe.style.display = 'block';
+ view.iframe.style.left = (rect.left - overlayRect.left) + 'px';
+ view.iframe.style.top = (rect.top - overlayRect.top) + 'px';
+ view.iframe.style.width = rect.width + 'px';
+ view.iframe.style.height = rect.height + 'px';
+ // On mobile, sidebar overlays content โ iframes need high z-index
+ view.iframe.style.zIndex = inMobileSidebar ? '220' : '';
+ continue;
+ }
+ }
+ }
+ view.iframe.style.display = 'none';
+ view.iframe.style.zIndex = '';
+ continue;
+ }
+
+ // Non-sidebar views: position over their panel body slot
+ const panel = findPanelHostingView(viewId);
+ if (panel && !panel.el.classList.contains('minimized')) {
+ const body = panel.el.querySelector('.panel-body');
+ if (body) {
+ const rect = body.getBoundingClientRect();
+ if (rect.width > 0 && rect.height > 0) {
+ view.iframe.style.display = 'block';
+ view.iframe.style.left = (rect.left - overlayRect.left) + 'px';
+ view.iframe.style.top = (rect.top - overlayRect.top) + 'px';
+ view.iframe.style.width = rect.width + 'px';
+ view.iframe.style.height = rect.height + 'px';
+ view.iframe.style.zIndex = '';
+ continue;
+ }
+ }
+ }
+ // Not placed, minimized, or zero-size โ hide
+ view.iframe.style.display = 'none';
+ view.iframe.style.zIndex = '';
+ }
+}
+
+// โโ Layout dropdown โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+const BUILTIN_PRESETS = ['Minimal', 'Standard', 'Full'];
+
+export function buildLayoutMenu() {
+ const menu = document.getElementById('layout-menu');
+ menu.innerHTML = '';
+
+ for (const name of BUILTIN_PRESETS) {
+ const item = document.createElement('div');
+ item.className = 'dropdown-item';
+ item.textContent = name;
+ item.addEventListener('click', () => {
+ applyPreset(name);
+ menu.classList.remove('open');
+ });
+ menu.appendChild(item);
+ }
+}
+
+export function applyPreset(name) {
+ // Grid presets control the left panel only; sidebar views (activity, tools, etc.) are always in the right sidebar
+ const presets = {
+ 'Minimal': { rows: [{ height: '100%', columns: [{ width: '100%', view: 'builtin:agent-terminal' }] }] },
+ 'Standard': { rows: [{ height: '100%', columns: [{ width: '100%', view: 'builtin:agent-terminal' }] }] },
+ 'Full': { rows: [{ height: '100%', columns: [{ width: '100%', view: 'builtin:agent-terminal' }] }] },
+ };
+ const layout = presets[name];
+ if (layout) { setLayoutConfig(layout); renderLayout(layout); }
+}
+
+// + Add Panel menu
+export function rebuildAddPanelMenu() {
+ const menu = document.getElementById('add-panel-menu');
+ menu.innerHTML = '';
+ const placed = new Set(Object.values(panels).map(p => p.viewId));
+
+ for (const v of viewRegistry) {
+ if (placed.has(v.id)) continue;
+ const item = document.createElement('div');
+ item.className = 'dropdown-item';
+ item.textContent = v.name;
+ item.addEventListener('click', () => {
+ addPanelWithView(v.id);
+ menu.classList.remove('open');
+ });
+ menu.appendChild(item);
+ }
+
+ if (!menu.children.length) {
+ const item = document.createElement('div');
+ item.className = 'dropdown-item';
+ item.style.color = 'var(--dash-fg-muted)';
+ item.textContent = 'No additional views';
+ menu.appendChild(item);
+ }
+}
+
+export function addPanelWithView(viewId) {
+ const root = document.getElementById('grid-root');
+ let lastRow = root.querySelector('.grid-row:last-child');
+ if (!lastRow) {
+ lastRow = document.createElement('div');
+ lastRow.className = 'grid-row';
+ lastRow.style.flex = '1';
+ root.appendChild(lastRow);
+ }
+ const existingPanels = lastRow.querySelectorAll('.panel');
+ if (existingPanels.length > 0) lastRow.appendChild(makeColHandle(lastRow, existingPanels.length));
+ const panelEl = createPanelSlot(viewId, lastRow);
+ panelEl.style.flex = '1';
+ lastRow.appendChild(panelEl);
+ rebuildAddPanelMenu();
+ requestAnimationFrame(() => syncViewPositions());
+}
diff --git a/src/mcp_cli/dashboard/static/js/sessions.js b/src/mcp_cli/dashboard/static/js/sessions.js
new file mode 100644
index 00000000..2414575a
--- /dev/null
+++ b/src/mcp_cli/dashboard/static/js/sessions.js
@@ -0,0 +1,108 @@
+// ================================================================
+// js/sessions.js โ Session list management
+// ================================================================
+'use strict';
+
+import {
+ _sessionList, _currentSessionId,
+ setSessionList, setCurrentSessionId,
+} from './state.js';
+import { sendToBridge } from './websocket.js';
+import { showToast } from './utils.js';
+
+export function handleSessionList(payload) {
+ setSessionList(payload.sessions || []);
+ const currentId = payload.current_session_id || _currentSessionId;
+ renderSessionList(_sessionList, currentId);
+}
+
+export function handleSessionState(payload) {
+ setCurrentSessionId(payload.session_id || null);
+ // Could update a session indicator in toolbar later
+}
+
+export function renderSessionList(sessions, currentId) {
+ const ul = document.getElementById('session-list');
+ if (!sessions.length) {
+ ul.innerHTML = 'No saved sessions ';
+ return;
+ }
+ ul.innerHTML = '';
+ for (const s of sessions) {
+ const li = document.createElement('li');
+ li.className = 'session-item' + (s.session_id === currentId ? ' active' : '');
+
+ const info = document.createElement('div');
+ info.className = 'session-info';
+
+ const idEl = document.createElement('div');
+ idEl.className = 'session-id';
+ idEl.textContent = s.description || s.session_id.slice(0, 12);
+ info.appendChild(idEl);
+
+ const meta = document.createElement('div');
+ meta.className = 'session-meta';
+ const msgs = document.createElement('span');
+ msgs.textContent = s.message_count + ' msgs';
+ meta.appendChild(msgs);
+ if (s.model) {
+ const model = document.createElement('span');
+ model.textContent = s.model;
+ meta.appendChild(model);
+ }
+ const time = document.createElement('span');
+ time.textContent = formatSessionTime(s.updated_at);
+ meta.appendChild(time);
+ info.appendChild(meta);
+
+ li.appendChild(info);
+
+ const actions = document.createElement('div');
+ actions.className = 'session-actions';
+
+ if (s.session_id !== currentId) {
+ const loadBtn = document.createElement('button');
+ loadBtn.textContent = 'Load';
+ loadBtn.title = 'Switch to this session';
+ loadBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ sendToBridge({ type: 'SWITCH_SESSION', session_id: s.session_id });
+ showToast('info', 'Switching session...');
+ });
+ actions.appendChild(loadBtn);
+
+ const delBtn = document.createElement('button');
+ delBtn.textContent = 'Del';
+ delBtn.className = 'delete';
+ delBtn.title = 'Delete this session';
+ delBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ if (confirm('Delete this session permanently?')) {
+ sendToBridge({ type: 'DELETE_SESSION', session_id: s.session_id });
+ }
+ });
+ actions.appendChild(delBtn);
+ } else {
+ const cur = document.createElement('span');
+ cur.textContent = 'current';
+ cur.style.cssText = 'font-size:10px;color:var(--dash-accent)';
+ actions.appendChild(cur);
+ }
+
+ li.appendChild(actions);
+ ul.appendChild(li);
+ }
+}
+
+export function formatSessionTime(iso) {
+ if (!iso) return '';
+ try {
+ const d = new Date(iso);
+ const now = new Date();
+ const diff = now - d;
+ if (diff < 60000) return 'just now';
+ if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago';
+ if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago';
+ return d.toLocaleDateString();
+ } catch { return ''; }
+}
diff --git a/src/mcp_cli/dashboard/static/js/sidebar.js b/src/mcp_cli/dashboard/static/js/sidebar.js
new file mode 100644
index 00000000..9916c457
--- /dev/null
+++ b/src/mcp_cli/dashboard/static/js/sidebar.js
@@ -0,0 +1,233 @@
+// ================================================================
+// js/sidebar.js โ Right sidebar: collapsible stacked sections
+// ================================================================
+'use strict';
+
+import {
+ _sidebarOpen, setSidebarOpen,
+ viewRegistry, isSidebarView, panels,
+} from './state.js';
+import { makeDraggable } from './utils.js';
+import { getOrCreateView, sendInitToSidebarView, labelForView } from './views.js';
+import { syncViewPositions, popoutSidebarView, rebuildAddPanelMenu } from './layout.js';
+
+export function updateSidebarMode() {
+ const isMobile = window.innerWidth < 768;
+ document.body.classList.toggle('mobile-sidebar', isMobile);
+
+ if (isMobile) {
+ // Mark panels whose views belong in the sidebar
+ for (const panel of Object.values(panels)) {
+ panel.el.classList.toggle('sidebar-hosted', isSidebarView(panel.viewId));
+ }
+ } else {
+ // Exiting mobile โ clear sidebar-hosted, close sidebar overlay
+ for (const panel of Object.values(panels)) {
+ panel.el.classList.remove('sidebar-hosted');
+ }
+ closeSidebar();
+ }
+ // Always rebuild sidebar sections (works on both desktop and mobile)
+ buildSidebarSections();
+ requestAnimationFrame(() => syncViewPositions());
+}
+
+export function getSidebarViewIds() {
+ // Collect viewIds that should appear in the sidebar, in order.
+ // Always include ALL SIDEBAR_VIEW_IDS (activity stream, tool browser,
+ // plan viewer, agent overview) plus any registered app: views.
+ const views = [];
+ // Activity stream always first
+ views.push('builtin:activity-stream');
+ // Then other builtin sidebar views in a stable order
+ for (const vid of ['builtin:tool-browser', 'builtin:plan-viewer', 'builtin:agent-overview']) {
+ views.push(vid);
+ }
+ // Then any app: views from viewRegistry (not viewPool โ may not be created yet)
+ for (const entry of viewRegistry) {
+ if (entry.id.startsWith('app:') && !views.includes(entry.id)) {
+ views.push(entry.id);
+ }
+ }
+ return views;
+}
+
+export function buildSidebarSections() {
+ // Build collapsible accordion sections inside #sidebar-view-slot.
+ // Each section has a clickable header + a body that's the iframe positioning target.
+ const slot = document.getElementById('sidebar-view-slot');
+ // Remember which sections were expanded
+ const expandedSet = new Set();
+ slot.querySelectorAll('.sidebar-section.expanded').forEach(el => {
+ expandedSet.add(el.dataset.viewId);
+ });
+
+ slot.innerHTML = '';
+ const views = getSidebarViewIds();
+ const isFirstBuild = expandedSet.size === 0;
+
+ for (let i = 0; i < views.length; i++) {
+ const viewId = views[i];
+ const section = document.createElement('div');
+ section.className = 'sidebar-section';
+ section.dataset.viewId = viewId;
+
+ // Expand: restore previous state, or expand first section on first build
+ if (isFirstBuild ? i === 0 : expandedSet.has(viewId)) {
+ section.classList.add('expanded');
+ }
+
+ // Header (click to toggle, with action buttons)
+ const header = document.createElement('div');
+ header.className = 'sidebar-section-header';
+ const chevron = document.createElement('span');
+ chevron.className = 'sidebar-section-chevron';
+ chevron.textContent = 'โธ';
+ const name = document.createElement('span');
+ name.className = 'sidebar-section-name';
+ name.textContent = labelForView(viewId);
+
+ // Action buttons (maximize, popout)
+ const btns = document.createElement('span');
+ btns.className = 'sidebar-section-btns';
+ const maxBtn = document.createElement('button');
+ maxBtn.className = 'sidebar-section-btn';
+ maxBtn.title = 'Maximize';
+ maxBtn.textContent = 'โคข';
+ maxBtn.dataset.action = 'maximize';
+ const popBtn = document.createElement('button');
+ popBtn.className = 'sidebar-section-btn';
+ popBtn.title = 'Pop out';
+ popBtn.textContent = 'โ';
+ popBtn.dataset.action = 'popout';
+ btns.appendChild(maxBtn);
+ btns.appendChild(popBtn);
+
+ header.appendChild(chevron);
+ header.appendChild(name);
+ header.appendChild(btns);
+
+ header.addEventListener('click', (e) => {
+ const actionBtn = e.target.closest('[data-action]');
+ if (actionBtn) {
+ e.stopPropagation();
+ const action = actionBtn.dataset.action;
+ if (action === 'maximize') {
+ const wasMaximized = section.classList.contains('maximized');
+ // Clear all maximized states first
+ slot.querySelectorAll('.sidebar-section.maximized').forEach(s => s.classList.remove('maximized'));
+ if (!wasMaximized) {
+ section.classList.add('expanded');
+ section.classList.add('maximized');
+ }
+ requestAnimationFrame(() => syncViewPositions());
+ } else if (action === 'popout') {
+ popoutSidebarView(viewId);
+ }
+ return;
+ }
+ // Clicking header toggles expand/collapse
+ section.classList.toggle('expanded');
+ // Clear maximized if collapsing
+ if (!section.classList.contains('expanded')) {
+ section.classList.remove('maximized');
+ }
+ requestAnimationFrame(() => syncViewPositions());
+ });
+
+ // Body (iframe positioning target)
+ const body = document.createElement('div');
+ body.className = 'sidebar-section-body';
+ body.dataset.viewId = viewId;
+
+ section.appendChild(header);
+ section.appendChild(body);
+ slot.appendChild(section);
+
+ // Ensure the iframe exists
+ getOrCreateView(viewId);
+ }
+}
+
+export function openSidebar() {
+ setSidebarOpen(true);
+ buildSidebarSections();
+ document.getElementById('sidebar-panel').classList.add('open');
+ document.getElementById('drawer-backdrop').classList.add('visible');
+ document.getElementById('sidebar-toggle').classList.remove('has-update');
+ requestAnimationFrame(() => syncViewPositions());
+}
+
+export function closeSidebar() {
+ if (!_sidebarOpen) return;
+ setSidebarOpen(false);
+ document.getElementById('sidebar-panel').classList.remove('open');
+ // Only hide backdrop if no drawers are also open
+ const settingsOpen = document.getElementById('settings-panel').classList.contains('open');
+ const sessionsOpen = document.getElementById('session-drawer').classList.contains('open');
+ if (!settingsOpen && !sessionsOpen) {
+ document.getElementById('drawer-backdrop').classList.remove('visible');
+ }
+}
+
+export function notifySidebarUpdate() {
+ // Highlight the sidebar toggle when new activity arrives while sidebar is closed on mobile
+ if (!_sidebarOpen && document.body.classList.contains('mobile-sidebar')) {
+ document.getElementById('sidebar-toggle').classList.add('has-update');
+ }
+}
+
+// Sidebar toggle + close event handlers
+export function wireSidebarEvents() {
+ document.getElementById('sidebar-toggle').addEventListener('click', () => {
+ if (_sidebarOpen) closeSidebar(); else openSidebar();
+ });
+ document.getElementById('sidebar-close').addEventListener('click', closeSidebar);
+}
+
+// โโ Sidebar resize handle โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+export function setupSidebarResize() {
+ const handle = document.getElementById('sidebar-resize-handle');
+ if (!handle) return;
+ const overlay = document.getElementById('view-overlay');
+
+ makeDraggable(handle, {
+ onStart(x, _y) {
+ handle.classList.add('dragging');
+ // Block pointer events on iframes during drag so they don't steal the mouse
+ if (overlay) overlay.style.pointerEvents = 'none';
+ const sidebar = document.getElementById('sidebar-panel');
+ const gridWrapper = document.getElementById('grid-wrapper');
+ const mainContent = document.getElementById('main-content');
+ if (!sidebar || !gridWrapper || !mainContent) return null;
+ const totalWidth = mainContent.getBoundingClientRect().width - handle.getBoundingClientRect().width;
+ return {
+ startX: x,
+ startSidebarWidth: sidebar.getBoundingClientRect().width,
+ sidebar,
+ gridWrapper,
+ totalWidth,
+ };
+ },
+ onMove(state, x, _y) {
+ const dx = x - state.startX;
+ // Dragging right โ sidebar narrower; dragging left โ sidebar wider
+ const newSidebar = Math.max(200, Math.min(600, state.startSidebarWidth - dx));
+ state.sidebar.style.width = newSidebar + 'px';
+ // Grid takes remaining space via flex: 1 (no fixed sizing needed)
+ syncViewPositions();
+ },
+ onEnd(_state) {
+ handle.classList.remove('dragging');
+ // Restore pointer events on overlay
+ if (overlay) overlay.style.pointerEvents = '';
+ },
+ });
+
+ // Double-click resets sidebar to default 30%
+ handle.addEventListener('dblclick', () => {
+ const sidebar = document.getElementById('sidebar-panel');
+ if (sidebar) sidebar.style.width = '';
+ requestAnimationFrame(() => syncViewPositions());
+ });
+}
diff --git a/src/mcp_cli/dashboard/static/js/state.js b/src/mcp_cli/dashboard/static/js/state.js
new file mode 100644
index 00000000..c6b34660
--- /dev/null
+++ b/src/mcp_cli/dashboard/static/js/state.js
@@ -0,0 +1,86 @@
+// ================================================================
+// js/state.js โ All shared state variables as module-level exports
+// ================================================================
+'use strict';
+
+// โโ Constants โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+export const PROTOCOL = 'mcp-dashboard';
+export const VERSION = 1;
+export const WS_URL = `ws://${location.host}/ws`;
+export const READY_TIMEOUT_MS = 5000;
+export const AGENT_COLORS = ['#7aa2f7','#9ece6a','#e0af68','#f7768e','#7dcfff','#bb9af7','#ff9e64','#73daca'];
+export const _WS_BACKOFF_MAX = 30000; // cap at 30 s
+
+// โโ Sidebar view constants (shared by layout.js and sidebar.js) โโโ
+export const SIDEBAR_VIEW_IDS = new Set([
+ 'builtin:activity-stream', 'builtin:tool-browser',
+ 'builtin:plan-viewer', 'builtin:agent-overview',
+]);
+
+export function isSidebarView(viewId) {
+ return SIDEBAR_VIEW_IDS.has(viewId) || (viewId && viewId.startsWith('app:'));
+}
+
+// โโ Mutable state โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+export let ws = null;
+export let connected = false;
+export let themes = {};
+export let activeTheme = localStorage.getItem('dash-theme') || 'dark';
+export let viewRegistry = [];
+export let layoutConfig = null;
+export let panels = {}; // panelId โ { panelId, el, viewId, rowEl }
+export let panelCounter = 0;
+export const viewPool = new Map(); // viewId โ { iframe, ready, accepts, _readyTimeout }
+export const popoutWindows = new Map(); // viewId โ { win, intervalId }
+
+// Multi-agent state
+export let agentList = []; // array of agent descriptors from AGENT_LIST
+export let focusedAgentId = null; // currently focused agent id
+export const agentColorMap = new Map(); // agent_id โ stable color
+
+// WebSocket backoff
+export let _wsBackoff = 1000; // current backoff delay (ms)
+export let _wsReconnectTimer = null;
+
+// Cached payloads for replaying to late-loading views
+export let _cachedToolRegistry = null;
+export let _cachedPlanUpdate = null;
+
+// Config state
+export let _configState = null; // cached CONFIG_STATE payload
+export let _availableProviders = []; // [{name, models: [...]}]
+
+// Session state
+export let _sessionList = [];
+export let _currentSessionId = null;
+
+// Tool approval
+export let _pendingApprovalCallId = null;
+export let _approvalQueue = [];
+
+// Sidebar
+export let _sidebarOpen = false;
+
+// โโ Setter functions โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+export function setWs(val) { ws = val; }
+export function setConnected(val) { connected = val; }
+export function setThemes(val) { themes = val; }
+export function setActiveTheme(val) { activeTheme = val; }
+export function setViewRegistry(val) { viewRegistry = val; }
+export function setLayoutConfig(val) { layoutConfig = val; }
+export function setPanels(val) { panels = val; }
+export function setPanelCounter(val) { panelCounter = val; }
+export function incPanelCounter() { return ++panelCounter; }
+export function setAgentList(val) { agentList = val; }
+export function setFocusedAgentId(val) { focusedAgentId = val; }
+export function setWsBackoff(val) { _wsBackoff = val; }
+export function setWsReconnectTimer(val) { _wsReconnectTimer = val; }
+export function setCachedToolRegistry(val) { _cachedToolRegistry = val; }
+export function setCachedPlanUpdate(val) { _cachedPlanUpdate = val; }
+export function setConfigState(val) { _configState = val; }
+export function setAvailableProviders(val) { _availableProviders = val; }
+export function setSessionList(val) { _sessionList = val; }
+export function setCurrentSessionId(val) { _currentSessionId = val; }
+export function setPendingApprovalCallId(val) { _pendingApprovalCallId = val; }
+export function setApprovalQueue(val) { _approvalQueue = val; }
+export function setSidebarOpen(val) { _sidebarOpen = val; }
diff --git a/src/mcp_cli/dashboard/static/js/theme.js b/src/mcp_cli/dashboard/static/js/theme.js
new file mode 100644
index 00000000..c022dea4
--- /dev/null
+++ b/src/mcp_cli/dashboard/static/js/theme.js
@@ -0,0 +1,81 @@
+// ================================================================
+// js/theme.js โ Theme loading, applying, and select widget
+// ================================================================
+'use strict';
+
+import {
+ themes, activeTheme,
+ setThemes, setActiveTheme,
+} from './state.js';
+import { broadcastToViews } from './views.js';
+
+export async function loadThemes() {
+ try {
+ const resp = await fetch('/themes/themes.json');
+ setThemes(await resp.json());
+ } catch {
+ setThemes({});
+ }
+ buildThemeSelect();
+ applyTheme(themes[activeTheme] || themes['dark'] || {});
+}
+
+export function themeToCSS(t) {
+ return {
+ name: t.name,
+ bg: t.bg, bg_surface: t.bg_surface, bg_hover: t.bg_hover,
+ fg: t.fg, fg_muted: t.fg_muted,
+ accent: t.accent, success: t.success, warning: t.warning,
+ error: t.error, info: t.info, border: t.border,
+ font_mono: t.font_mono, font_ui: t.font_ui,
+ font_size: t.font_size, radius: t.radius, spacing: t.spacing,
+ };
+}
+
+export function applyTheme(t) {
+ if (!t || !t.bg) return;
+ setActiveTheme(t.name || 'dark');
+ localStorage.setItem('dash-theme', activeTheme);
+
+ const root = document.documentElement;
+ root.style.setProperty('--dash-bg', t.bg || '');
+ root.style.setProperty('--dash-bg-surface', t.bg_surface || '');
+ root.style.setProperty('--dash-bg-hover', t.bg_hover || '');
+ root.style.setProperty('--dash-fg', t.fg || '');
+ root.style.setProperty('--dash-fg-muted', t.fg_muted || '');
+ root.style.setProperty('--dash-accent', t.accent || '');
+ root.style.setProperty('--dash-success', t.success || '');
+ root.style.setProperty('--dash-warning', t.warning || '');
+ root.style.setProperty('--dash-error', t.error || '');
+ root.style.setProperty('--dash-info', t.info || '');
+ root.style.setProperty('--dash-border', t.border || '');
+ root.style.setProperty('--dash-font-mono', t.font_mono || '');
+ root.style.setProperty('--dash-font-ui', t.font_ui || '');
+ root.style.setProperty('--dash-font-size', t.font_size || '');
+ root.style.setProperty('--dash-radius', t.radius || '');
+ root.style.setProperty('--dash-spacing', t.spacing || '');
+
+ // Update theme select
+ const sel = document.getElementById('theme-select');
+ if (sel) sel.value = activeTheme;
+
+ // Propagate THEME to all views
+ broadcastToViews('THEME', themeToCSS(t));
+}
+
+let _themeChangeHandler = null;
+export function buildThemeSelect() {
+ const sel = document.getElementById('theme-select');
+ sel.innerHTML = '';
+ for (const name of Object.keys(themes)) {
+ const opt = document.createElement('option');
+ opt.value = name;
+ opt.textContent = name.charAt(0).toUpperCase() + name.slice(1);
+ sel.appendChild(opt);
+ }
+ sel.value = activeTheme;
+ // Remove previous listener to avoid stacking
+ if (_themeChangeHandler) sel.removeEventListener('change', _themeChangeHandler);
+ _themeChangeHandler = () => { applyTheme(themes[sel.value] || {}); };
+ sel.addEventListener('change', _themeChangeHandler);
+}
diff --git a/src/mcp_cli/dashboard/static/js/toolbar.js b/src/mcp_cli/dashboard/static/js/toolbar.js
new file mode 100644
index 00000000..3ac9af60
--- /dev/null
+++ b/src/mcp_cli/dashboard/static/js/toolbar.js
@@ -0,0 +1,175 @@
+// ================================================================
+// js/toolbar.js โ Toolbar button wiring + overflow menu
+// ================================================================
+'use strict';
+
+import { layoutConfig, _configState, setLayoutConfig } from './state.js';
+import { sendToBridge } from './websocket.js';
+import { addPanelWithView, buildLayoutMenu, rebuildAddPanelMenu } from './layout.js';
+import { openDrawer, closeDrawer, closeAllDrawers, showToast } from './utils.js';
+import { exportConversation } from './export.js';
+
+export function buildOverflowMenu() {
+ const menu = document.getElementById('overflow-menu');
+ menu.innerHTML = '';
+ const isMobile = window.innerWidth < 768;
+ if (!isMobile) {
+ menu.classList.remove('open');
+ document.getElementById('overflow-btn').style.display = 'none';
+ return;
+ }
+
+ const items = [];
+ // Items hidden at sm (480-767)
+ items.push({ label: '+ Add Panel', action: () => {
+ rebuildAddPanelMenu();
+ const m = document.getElementById('add-panel-menu');
+ m.style.position = 'fixed'; m.style.right = '8px'; m.style.top = '48px'; m.style.left = 'auto';
+ m.classList.toggle('open');
+ }});
+ items.push({ label: 'Export as Markdown', action: () => exportConversation('markdown') });
+ items.push({ label: 'Export as JSON', action: () => exportConversation('json') });
+
+ if (_configState) {
+ const modelLabel = _configState.model || '(none)';
+ items.push({ label: 'Model: ' + modelLabel, action: () => {
+ openDrawer(document.getElementById('settings-panel'));
+ }});
+ }
+
+ // Items also hidden at xs (<480)
+ if (window.innerWidth < 480) {
+ items.unshift({ label: '+ New Session', action: () => document.getElementById('new-session-btn').click() });
+ items.unshift({ label: 'Sessions', action: () => {
+ openDrawer(document.getElementById('session-drawer'));
+ sendToBridge({ type: 'REQUEST_SESSIONS' });
+ }});
+ }
+
+ for (const item of items) {
+ const el = document.createElement('div');
+ el.className = 'dropdown-item';
+ el.textContent = item.label;
+ el.addEventListener('click', (e) => {
+ e.stopPropagation();
+ menu.classList.remove('open');
+ item.action();
+ });
+ menu.appendChild(el);
+ }
+}
+
+export function wireToolbarEvents() {
+ document.getElementById('layout-btn').addEventListener('click', (e) => {
+ e.stopPropagation();
+ const menu = document.getElementById('layout-menu');
+ menu.classList.toggle('open');
+ });
+
+ document.getElementById('add-panel-btn').addEventListener('click', (e) => {
+ e.stopPropagation();
+ rebuildAddPanelMenu();
+ const menu = document.getElementById('add-panel-menu');
+ const btn = document.getElementById('add-panel-btn');
+ const rect = btn.getBoundingClientRect();
+ menu.style.left = rect.left + 'px';
+ menu.style.top = (rect.bottom + 4) + 'px';
+ menu.style.position = 'fixed';
+ menu.classList.toggle('open');
+ });
+
+ document.getElementById('settings-btn').addEventListener('click', (e) => {
+ e.stopPropagation();
+ const panel = document.getElementById('settings-panel');
+ if (panel.classList.contains('open')) { closeDrawer(panel); } else { openDrawer(panel); }
+ });
+
+ document.getElementById('save-layout-btn').addEventListener('click', () => {
+ try {
+ localStorage.setItem('dash-layout', JSON.stringify(layoutConfig));
+ showToast('success', 'Layout saved');
+ } catch (e) {
+ showToast('error', 'Could not save layout');
+ }
+ });
+
+ document.getElementById('clear-history-btn').addEventListener('click', () => {
+ if (!confirm('Clear all chat history and start a new session?')) return;
+ sendToBridge({ type: 'CLEAR_HISTORY' });
+ showToast('info', 'Chat history cleared');
+ });
+
+ document.getElementById('new-session-btn').addEventListener('click', () => {
+ if (!confirm('Save current session and start a new one?')) return;
+ sendToBridge({ type: 'NEW_SESSION' });
+ showToast('success', 'New session started');
+ // Close session drawer if open
+ document.getElementById('session-drawer').classList.remove('open');
+ });
+
+ document.getElementById('sessions-btn').addEventListener('click', (e) => {
+ e.stopPropagation();
+ const drawer = document.getElementById('session-drawer');
+ if (drawer.classList.contains('open')) {
+ closeDrawer(drawer);
+ } else {
+ openDrawer(drawer);
+ sendToBridge({ type: 'REQUEST_SESSIONS' });
+ }
+ });
+
+ document.getElementById('refresh-sessions-btn').addEventListener('click', (e) => {
+ e.stopPropagation();
+ sendToBridge({ type: 'REQUEST_SESSIONS' });
+ });
+
+ // Export button + menu
+ document.getElementById('export-btn').addEventListener('click', (e) => {
+ e.stopPropagation();
+ document.getElementById('export-menu').classList.toggle('open');
+ });
+
+ document.getElementById('export-menu').addEventListener('click', (e) => {
+ const item = e.target.closest('[data-format]');
+ if (!item) return;
+ e.stopPropagation();
+ document.getElementById('export-menu').classList.remove('open');
+ exportConversation(item.dataset.format);
+ });
+
+ // Close dropdowns/menus on outside click (but NOT settings panel when clicking inside it)
+ document.addEventListener('click', (e) => {
+ document.getElementById('layout-menu').classList.remove('open');
+ document.getElementById('add-panel-menu').classList.remove('open');
+ document.getElementById('export-menu').classList.remove('open');
+ document.getElementById('overflow-menu').classList.remove('open');
+ document.querySelectorAll('.panel-view-menu.open').forEach(m => m.classList.remove('open'));
+ // Only close settings panel if click was outside both the panel and the settings button
+ const settingsPanel = document.getElementById('settings-panel');
+ const settingsBtn = document.getElementById('settings-btn');
+ if (!settingsPanel.contains(e.target) && e.target !== settingsBtn) {
+ closeDrawer(settingsPanel);
+ }
+ // Only close session drawer if click was outside both the drawer and the sessions button
+ const sessionDrawer = document.getElementById('session-drawer');
+ const sessionsBtn = document.getElementById('sessions-btn');
+ if (!sessionDrawer.contains(e.target) && e.target !== sessionsBtn) {
+ closeDrawer(sessionDrawer);
+ }
+ });
+
+ // Overflow menu button
+ document.getElementById('overflow-btn').addEventListener('click', (e) => {
+ e.stopPropagation();
+ buildOverflowMenu();
+ const menu = document.getElementById('overflow-menu');
+ const rect = e.target.getBoundingClientRect();
+ menu.style.top = (rect.bottom + 4) + 'px';
+ menu.style.right = '8px';
+ menu.style.left = 'auto';
+ menu.classList.toggle('open');
+ });
+
+ // Drawer backdrop
+ document.getElementById('drawer-backdrop').addEventListener('click', closeAllDrawers);
+}
diff --git a/src/mcp_cli/dashboard/static/js/utils.js b/src/mcp_cli/dashboard/static/js/utils.js
new file mode 100644
index 00000000..c2698d65
--- /dev/null
+++ b/src/mcp_cli/dashboard/static/js/utils.js
@@ -0,0 +1,77 @@
+// ================================================================
+// js/utils.js โ HTML escape, toast, drag utility, drawer helpers
+// ================================================================
+'use strict';
+
+export function esc(s) {
+ return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');
+}
+
+export function showToast(level, message, duration) {
+ const container = document.getElementById('toast-container');
+ const toast = document.createElement('div');
+ toast.className = `toast ${level}`;
+ toast.textContent = message;
+ container.appendChild(toast);
+ setTimeout(() => toast.remove(), duration || 5000);
+}
+
+export function makeDraggable(el, callbacks) {
+ // Mouse
+ el.addEventListener('mousedown', (e) => {
+ e.preventDefault();
+ const state = callbacks.onStart(e.clientX, e.clientY);
+ if (!state) return;
+ function onMove(e) { callbacks.onMove(state, e.clientX, e.clientY); }
+ function onUp() {
+ callbacks.onEnd(state);
+ document.removeEventListener('mousemove', onMove);
+ document.removeEventListener('mouseup', onUp);
+ }
+ document.addEventListener('mousemove', onMove);
+ document.addEventListener('mouseup', onUp);
+ });
+ // Touch
+ el.addEventListener('touchstart', (e) => {
+ e.preventDefault();
+ const touch = e.touches[0];
+ const state = callbacks.onStart(touch.clientX, touch.clientY);
+ if (!state) return;
+ function onMove(e) {
+ const t = e.touches[0];
+ callbacks.onMove(state, t.clientX, t.clientY);
+ }
+ function onEnd() {
+ callbacks.onEnd(state);
+ document.removeEventListener('touchmove', onMove);
+ document.removeEventListener('touchend', onEnd);
+ }
+ document.addEventListener('touchmove', onMove, { passive: false });
+ document.addEventListener('touchend', onEnd);
+ }, { passive: false });
+}
+
+export function openDrawer(el) {
+ el.classList.add('open');
+ if (window.innerWidth < 768) {
+ document.getElementById('drawer-backdrop').classList.add('visible');
+ }
+}
+
+export function closeDrawer(el) {
+ el.classList.remove('open');
+ document.getElementById('drawer-backdrop').classList.remove('visible');
+}
+
+// Late-binding for closeSidebar to avoid circular dependency with sidebar.js
+let _closeSidebarFn = null;
+export function setCloseSidebarFn(fn) {
+ _closeSidebarFn = fn;
+}
+
+export function closeAllDrawers() {
+ document.getElementById('settings-panel').classList.remove('open');
+ document.getElementById('session-drawer').classList.remove('open');
+ if (_closeSidebarFn) _closeSidebarFn();
+ document.getElementById('drawer-backdrop').classList.remove('visible');
+}
diff --git a/src/mcp_cli/dashboard/static/js/views.js b/src/mcp_cli/dashboard/static/js/views.js
new file mode 100644
index 00000000..874200e1
--- /dev/null
+++ b/src/mcp_cli/dashboard/static/js/views.js
@@ -0,0 +1,390 @@
+// ================================================================
+// js/views.js โ View pool, iframe management, message routing
+// ================================================================
+'use strict';
+
+import {
+ viewRegistry, viewPool, panels, popoutWindows,
+ PROTOCOL, VERSION, READY_TIMEOUT_MS,
+ focusedAgentId, themes, activeTheme,
+ _cachedToolRegistry, _cachedPlanUpdate, _configState,
+ setViewRegistry, isSidebarView, _sidebarOpen,
+} from './state.js';
+import { showToast } from './utils.js';
+import { sendToBridge } from './websocket.js';
+import { themeToCSS } from './theme.js';
+
+// โโ Late-binding imports to avoid circular deps with layout.js/sidebar.js โโ
+let _syncViewPositions = null;
+let _showPanelError = null;
+let _notifyResize = null;
+let _findPopoutViewIdByWindow = null;
+let _handlePopoutReady = null;
+let _postToPopout = null;
+let _notifySidebarUpdate = null;
+
+export function setViewDeps(deps) {
+ _syncViewPositions = deps.syncViewPositions;
+ _showPanelError = deps.showPanelError;
+ _notifyResize = deps.notifyResize;
+ _findPopoutViewIdByWindow = deps.findPopoutViewIdByWindow;
+ _handlePopoutReady = deps.handlePopoutReady;
+ _postToPopout = deps.postToPopout;
+ _notifySidebarUpdate = deps.notifySidebarUpdate;
+}
+
+// โโ View registry โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+export function mergeViewRegistry(dynamicViews) {
+ // Keep builtins, add/update dynamic views
+ const builtinIds = new Set(viewRegistry.filter(v => v.source === 'builtin').map(v => v.id));
+ const merged = viewRegistry.filter(v => v.source === 'builtin');
+ for (const v of dynamicViews) {
+ if (!builtinIds.has(v.id)) merged.push(v);
+ }
+ setViewRegistry(merged);
+}
+
+export function srcForView(viewId) {
+ const vInfo = viewRegistry.find(v => v.id === viewId);
+ if (vInfo && vInfo.url) return vInfo.url;
+ if (viewId === 'builtin:agent-terminal') return '/views/agent-terminal.html';
+ if (viewId === 'builtin:activity-stream') return '/views/activity-stream.html';
+ if (viewId === 'builtin:tool-browser') return '/views/tool-browser.html';
+ if (viewId === 'builtin:plan-viewer') return '/views/plan-viewer.html';
+ return '';
+}
+
+export function iconForView(viewId) {
+ if (!viewId) return 'โก';
+ if (viewId === 'builtin:agent-terminal') return 'โจ';
+ if (viewId === 'builtin:activity-stream') return 'โ';
+ if (viewId === 'builtin:tool-browser') return '๐ง';
+ if (viewId === 'builtin:plan-viewer') return '๐';
+ if (viewId === 'builtin:agent-overview') return '๐ฅ';
+ const v = viewRegistry.find(v => v.id === viewId);
+ return v?.icon || 'โป';
+}
+
+export function labelForView(viewId) {
+ if (!viewId) return 'Empty';
+ if (viewId === 'builtin:agent-terminal') return 'Agent Terminal';
+ if (viewId === 'builtin:activity-stream') return 'Activity Stream';
+ if (viewId === 'builtin:tool-browser') return 'Tool Browser';
+ if (viewId === 'builtin:plan-viewer') return 'Plan Viewer';
+ if (viewId === 'builtin:agent-overview') return 'Agent Overview';
+ const v = viewRegistry.find(v => v.id === viewId);
+ return v ? v.name : viewId;
+}
+
+// โโ View Pool โ iframes persist across layout changes โโโโโโโโโโโโโ
+export function getOrCreateView(viewId) {
+ if (viewPool.has(viewId)) return viewPool.get(viewId);
+ const view = { iframe: null, ready: false, accepts: [], _readyTimeout: null };
+ const iframe = document.createElement('iframe');
+ // App iframes load cross-origin host pages โ need permissive sandbox
+ if (viewId.startsWith('app:')) {
+ iframe.sandbox = 'allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox';
+ } else {
+ iframe.sandbox = 'allow-scripts allow-same-origin allow-forms';
+ }
+ iframe.allow = '';
+ // App views: append ?embedded=1 to hide the host page header (dashboard panel
+ // already provides its own header). srcForView returns the raw URL so pop-out
+ // still shows the standalone host header.
+ const viewUrl = srcForView(viewId);
+ iframe.src = viewId.startsWith('app:') && viewUrl
+ ? viewUrl + (viewUrl.includes('?') ? '&' : '?') + 'embedded=1'
+ : viewUrl;
+ iframe.dataset.viewId = viewId;
+ view.iframe = iframe;
+ viewPool.set(viewId, view);
+ // Append to view-overlay โ iframes NEVER move between parents (reparenting
+ // destroys browsing context in all modern browsers). They stay here
+ // permanently and are positioned over panel body slots via syncViewPositions().
+ document.getElementById('view-overlay').appendChild(iframe);
+ return view;
+}
+
+export function attachViewToSlot(viewId) {
+ if (!viewId) return;
+ const view = getOrCreateView(viewId);
+ // No reparenting โ iframe stays in #view-overlay.
+ // syncViewPositions() will position it over the panel body.
+ requestAnimationFrame(() => { if (_syncViewPositions) _syncViewPositions(); });
+ // App views use JSON-RPC, not mcp-dashboard READY โ skip timeout
+ if (viewId.startsWith('app:')) { view.ready = true; return; }
+ // Start ready timeout only for first load
+ if (!view.ready && !view._readyTimeout) {
+ view._readyTimeout = setTimeout(() => {
+ if (!view.ready) {
+ const p = findPanelHostingView(viewId);
+ const body = p?.el.querySelector('.panel-body');
+ if (body && _showPanelError) _showPanelError(body, 'View did not respond in time.');
+ }
+ }, READY_TIMEOUT_MS);
+ }
+}
+
+export function findViewIdByWindow(win) {
+ for (const [viewId, view] of viewPool) {
+ if (view.iframe && view.iframe.contentWindow === win) return viewId;
+ }
+ return null;
+}
+
+export function findPanelHostingView(viewId) {
+ for (const panel of Object.values(panels)) {
+ if (panel.viewId === viewId) return panel;
+ }
+ return null;
+}
+
+export function sendInitToView(viewId, panel) {
+ const view = viewPool.get(viewId);
+ if (!view || !view.iframe) return;
+ const themeObj = themes[activeTheme] || themes['dark'] || {};
+ const bodyEl = panel.el.querySelector('.panel-body');
+ const dims = bodyEl
+ ? { width: bodyEl.clientWidth, height: bodyEl.clientHeight }
+ : { width: 800, height: 450 };
+ postToIframe(view.iframe, 'INIT', {
+ view_id: viewId,
+ panel_id: panel.panelId,
+ agent_id: focusedAgentId,
+ theme: themeToCSS(themeObj),
+ dimensions: dims,
+ });
+}
+
+export function sendInitToSidebarView(viewId) {
+ const view = viewPool.get(viewId);
+ if (!view || !view.iframe) return;
+ const themeObj = themes[activeTheme] || themes['dark'] || {};
+ const sectionBody = document.querySelector(`.sidebar-section-body[data-view-id="${viewId}"]`);
+ const dims = sectionBody
+ ? { width: sectionBody.clientWidth, height: sectionBody.clientHeight }
+ : { width: 360, height: 300 };
+ postToIframe(view.iframe, 'INIT', {
+ view_id: viewId,
+ panel_id: 'sidebar',
+ agent_id: focusedAgentId,
+ theme: themeToCSS(themeObj),
+ dimensions: dims,
+ });
+}
+
+export function updatePanelHeader(panel) {
+ const iconEl = panel.el.querySelector('.panel-icon');
+ const nameEl = panel.el.querySelector('.panel-view-toggle');
+ if (iconEl) iconEl.textContent = iconForView(panel.viewId);
+ if (nameEl) nameEl.textContent = labelForView(panel.viewId) + ' โพ';
+}
+
+export function switchPanelView(panelId, newViewId) {
+ const panel = panels[panelId];
+ if (!panel || panel.viewId === newViewId) return;
+ // Just update the mapping โ no iframe DOM manipulation needed.
+ // The old view's iframe hides automatically via syncViewPositions()
+ // (it will no longer be hosted by any panel).
+ panel.viewId = newViewId;
+ getOrCreateView(newViewId); // ensure iframe exists
+ updatePanelHeader(panel);
+ requestAnimationFrame(() => { if (_syncViewPositions) _syncViewPositions(); });
+ // If already ready, send INIT with current dimensions
+ const view = viewPool.get(newViewId);
+ if (view?.ready) sendInitToView(newViewId, panel);
+}
+
+// โโ Message routing โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+export function routeToViews(type, payload) {
+ const serverName = payload.server_name;
+ const metaView = payload.meta_ui?.view;
+ const sent = new Set();
+ for (const panel of Object.values(panels)) {
+ const view = viewPool.get(panel.viewId);
+ if (!view?.ready || !view.iframe) continue;
+ const vid = panel.viewId;
+ if (vid === 'builtin:activity-stream') { postToIframe(view.iframe, type, payload); sent.add(vid); continue; }
+ if (metaView && vid === metaView) { postToIframe(view.iframe, type, payload); sent.add(vid); continue; }
+ const vInfo = viewRegistry.find(v => v.id === vid);
+ if (vInfo && vInfo.source === serverName) { postToIframe(view.iframe, type, payload); sent.add(vid); }
+ }
+ // Also route to sidebar views not in panels
+ for (const [viewId, view] of viewPool) {
+ if (sent.has(viewId) || !view?.ready || !view.iframe || !isSidebarView(viewId)) continue;
+ if (viewId === 'builtin:activity-stream' ||
+ (metaView && viewId === metaView) ||
+ viewRegistry.find(v => v.id === viewId && v.source === serverName)) {
+ postToIframe(view.iframe, type, payload);
+ }
+ }
+ for (const [viewId, entry] of popoutWindows) {
+ if (entry.win.closed) continue;
+ if (viewId === 'builtin:activity-stream' ||
+ (metaView && viewId === metaView) ||
+ viewRegistry.find(v => v.id === viewId && v.source === serverName)) {
+ if (_postToPopout) _postToPopout(entry.win, type, payload);
+ }
+ }
+}
+
+export function broadcastToViews(type, payload) {
+ for (const [viewId, view] of viewPool) {
+ if (view?.ready && view.iframe) postToIframe(view.iframe, type, payload);
+ }
+ for (const [, entry] of popoutWindows) {
+ if (!entry.win.closed && _postToPopout) _postToPopout(entry.win, type, payload);
+ }
+}
+
+export function sendToActivityStream(type, payload) {
+ // Activity stream lives in the sidebar โ send directly to its viewPool iframe
+ const view = viewPool.get('builtin:activity-stream');
+ if (view?.ready && view.iframe) postToIframe(view.iframe, type, payload);
+ // Notify sidebar toggle on mobile when sidebar is closed
+ if (_notifySidebarUpdate) _notifySidebarUpdate();
+ // Also send to popout window if open
+ for (const [viewId, entry] of popoutWindows) {
+ if (viewId === 'builtin:activity-stream' && !entry.win.closed) {
+ if (_postToPopout) _postToPopout(entry.win, type, payload);
+ }
+ }
+}
+
+export function broadcastToViewType(viewType, type, payload) {
+ for (const [vid, view] of viewPool) {
+ if (!view?.ready || !view.iframe) continue;
+ const vInfo = viewRegistry.find(v => v.id === vid);
+ if (vInfo && vInfo.type === viewType) {
+ postToIframe(view.iframe, type, payload);
+ } else if (!vInfo && vid === 'builtin:agent-terminal' && viewType === 'conversation') {
+ // Fallback before viewRegistry is populated
+ postToIframe(view.iframe, type, payload);
+ }
+ }
+ for (const [viewId, entry] of popoutWindows) {
+ if (entry.win.closed) continue;
+ const vInfo = viewRegistry.find(v => v.id === viewId);
+ const matches = (vInfo && vInfo.type === viewType) ||
+ (!vInfo && viewId === 'builtin:agent-terminal' && viewType === 'conversation');
+ if (matches && _postToPopout) _postToPopout(entry.win, type, payload);
+ }
+}
+
+export function postToIframe(iframe, type, payload) {
+ try {
+ iframe.contentWindow.postMessage({ protocol: PROTOCOL, version: VERSION, type, payload }, '*');
+ } catch (e) { /* ignore โ iframe may not be ready */ }
+}
+
+export function populateViewMenu(menu, panelId) {
+ menu.innerHTML = '';
+ const currentViewId = panels[panelId]?.viewId;
+ for (const v of viewRegistry) {
+ const item = document.createElement('div');
+ item.className = 'dropdown-item' + (v.id === currentViewId ? ' active' : '');
+ item.textContent = (v.icon ? v.icon + ' ' : '') + v.name;
+ item.addEventListener('click', (e) => {
+ e.stopPropagation();
+ menu.classList.remove('open');
+ if (v.id !== currentViewId) switchPanelView(panelId, v.id);
+ });
+ menu.appendChild(item);
+ }
+ if (!menu.children.length) {
+ const item = document.createElement('div');
+ item.className = 'dropdown-item';
+ item.style.color = 'var(--dash-fg-muted)';
+ item.textContent = 'No views available';
+ menu.appendChild(item);
+ }
+}
+
+// โโ View โ Shell postMessage handler โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+export function handleViewReady(viewId, payload) {
+ const view = viewPool.get(viewId);
+ if (!view) return;
+ const wasReady = view.ready;
+ view.ready = true;
+ view.accepts = payload.accepts || [];
+ view.agent_scope = payload.agent_scope || null; // "focused" | "all" | specific agent_id
+ if (view._readyTimeout) { clearTimeout(view._readyTimeout); view._readyTimeout = null; }
+ const panel = findPanelHostingView(viewId);
+ if (panel) {
+ updatePanelHeader(panel);
+ if (!wasReady) {
+ sendInitToView(viewId, panel);
+ replayCachedState(viewId, view);
+ } else {
+ if (_notifyResize) _notifyResize(panel.panelId);
+ }
+ } else if (isSidebarView(viewId)) {
+ // Sidebar view (no panel) โ send INIT directly
+ if (!wasReady) {
+ sendInitToSidebarView(viewId);
+ replayCachedState(viewId, view);
+ }
+ }
+}
+
+export function replayCachedState(viewId, view) {
+ const vInfo = viewRegistry.find(v => v.id === viewId);
+ const viewType = vInfo?.type;
+
+ // Replay TOOL_REGISTRY to tools-type views
+ if (viewType === 'tools' && _cachedToolRegistry) {
+ postToIframe(view.iframe, 'TOOL_REGISTRY', _cachedToolRegistry);
+ }
+ // Replay PLAN_UPDATE to plan-type views and activity stream
+ if (_cachedPlanUpdate && (viewType === 'plan' || viewId === 'builtin:activity-stream')) {
+ postToIframe(view.iframe, 'PLAN_UPDATE', _cachedPlanUpdate);
+ }
+ // Replay CONFIG_STATE to all views
+ if (_configState) {
+ postToIframe(view.iframe, 'CONFIG_STATE', _configState);
+ }
+ // Replay CONVERSATION_HISTORY to conversation-type views
+ if (viewType === 'conversation') {
+ // Request fresh history from bridge
+ sendToBridge({ type: 'REQUEST_CONFIG' });
+ }
+}
+
+// โโ Window message listener for iframe communication โโโโโโโโโโโโโโ
+export function setupViewMessageListener() {
+ window.addEventListener('message', (evt) => {
+ const msg = evt.data;
+ if (!msg || msg.protocol !== PROTOCOL) return;
+ const viewId = findViewIdByWindow(evt.source);
+ const panel = viewId ? findPanelHostingView(viewId) : null;
+
+ switch (msg.type) {
+ case 'READY': {
+ if (viewId) {
+ handleViewReady(viewId, msg.payload);
+ } else {
+ const popId = _findPopoutViewIdByWindow ? _findPopoutViewIdByWindow(evt.source) : null;
+ if (popId && _handlePopoutReady) _handlePopoutReady(popId, msg.payload, evt.source);
+ }
+ break;
+ }
+ case 'USER_ACTION':
+ sendToBridge({ type: 'USER_ACTION', view_id: viewId, ...msg.payload });
+ break;
+ case 'REQUEST_TOOL':
+ sendToBridge({ type: 'REQUEST_TOOL', view_id: viewId, ...msg.payload });
+ break;
+ case 'USER_MESSAGE':
+ sendToBridge({ type: 'USER_MESSAGE', content: msg.payload.content, files: msg.payload.files || undefined });
+ break;
+ case 'USER_COMMAND':
+ sendToBridge({ type: 'USER_COMMAND', command: msg.payload.command });
+ break;
+ case 'NOTIFY':
+ showToast(msg.payload.level || 'info', msg.payload.message, msg.payload.duration_ms);
+ break;
+ default:
+ break;
+ }
+ });
+}
diff --git a/src/mcp_cli/dashboard/static/js/websocket.js b/src/mcp_cli/dashboard/static/js/websocket.js
new file mode 100644
index 00000000..8120a13c
--- /dev/null
+++ b/src/mcp_cli/dashboard/static/js/websocket.js
@@ -0,0 +1,142 @@
+// ================================================================
+// js/websocket.js โ WebSocket connection + multi-agent helpers
+// ================================================================
+'use strict';
+
+import {
+ ws, connected, WS_URL, _wsBackoff, _WS_BACKOFF_MAX, _wsReconnectTimer,
+ agentList, focusedAgentId, AGENT_COLORS, agentColorMap,
+ setWs, setConnected, setWsBackoff, setWsReconnectTimer,
+ setAgentList, setFocusedAgentId,
+} from './state.js';
+import { showToast } from './utils.js';
+
+// โโ Late-binding message handler (set by init.js) โโโโโโโโโโโโโโโโโ
+let _messageHandler = null;
+
+export function setMessageHandler(fn) {
+ _messageHandler = fn;
+}
+
+// โโ WebSocket (exponential backoff reconnect) โโโโโโโโโโโโโโโโโโโโโ
+export function connectWS() {
+ if (_wsReconnectTimer) { clearTimeout(_wsReconnectTimer); setWsReconnectTimer(null); }
+ // Close existing connection before creating a new one
+ if (ws) { try { ws.onclose = null; ws.close(); } catch(e) {} setWs(null); }
+ const socket = new WebSocket(WS_URL);
+ setWs(socket);
+
+ socket.onopen = () => {
+ setConnected(true);
+ setWsBackoff(1000); // reset on success
+ document.getElementById('conn-dot').classList.add('connected');
+ showToast('success', 'Connected to dashboard server', 2000);
+ // Request agent list (router will send AGENT_LIST)
+ sendToBridge({ type: 'REQUEST_AGENT_LIST' });
+ // Request current config (model, servers, system prompt)
+ sendToBridge({ type: 'REQUEST_CONFIG' });
+ // Request tool registry
+ sendToBridge({ type: 'REQUEST_TOOLS' });
+ };
+
+ socket.onclose = () => {
+ const wasConnected = connected;
+ setConnected(false);
+ document.getElementById('conn-dot').classList.remove('connected');
+ if (wasConnected) showToast('warning', `Disconnected โ reconnecting in ${Math.round(_wsBackoff/1000)}sโฆ`, _wsBackoff);
+ setWsReconnectTimer(setTimeout(() => {
+ setWsBackoff(Math.min(_wsBackoff * 2, _WS_BACKOFF_MAX));
+ connectWS();
+ }, _wsBackoff));
+ };
+
+ socket.onerror = () => {
+ socket.close();
+ };
+
+ socket.onmessage = (evt) => {
+ let msg;
+ try { msg = JSON.parse(evt.data); } catch { return; }
+ if (_messageHandler) _messageHandler(msg);
+ };
+}
+
+export function sendToBridge(msg) {
+ if (ws && ws.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify(msg));
+ }
+}
+
+// โโ Multi-agent helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+export function agentColor(agentId) {
+ if (!agentColorMap.has(agentId)) {
+ agentColorMap.set(agentId, AGENT_COLORS[agentColorMap.size % AGENT_COLORS.length]);
+ }
+ return agentColorMap.get(agentId);
+}
+
+export function renderAgentTabs() {
+ const bar = document.getElementById('agent-tabs');
+ bar.innerHTML = '';
+ if (agentList.length <= 1) { bar.classList.remove('visible'); return; }
+ bar.classList.add('visible');
+ for (const agent of agentList) {
+ const btn = document.createElement('button');
+ btn.className = 'agent-tab' + (agent.agent_id === focusedAgentId ? ' focused' : '');
+ const dot = document.createElement('span');
+ dot.className = 'agent-tab-indicator ' + (agent.status || 'active');
+ btn.appendChild(dot);
+ const label = document.createElement('span');
+ label.textContent = agent.name || agent.agent_id;
+ btn.appendChild(label);
+ btn.addEventListener('click', () => focusAgent(agent.agent_id));
+ bar.appendChild(btn);
+ }
+}
+
+export function focusAgent(agentId) {
+ if (agentId === focusedAgentId) return;
+ setFocusedAgentId(agentId);
+ renderAgentTabs();
+ sendToBridge({ type: 'FOCUS_AGENT', agent_id: agentId });
+ // Update subscription to include focused agent + global
+ sendToBridge({ type: 'SUBSCRIBE', agents: [agentId], global: true });
+}
+
+export function handleAgentList(payload) {
+ setAgentList(payload.agents || []);
+ if (!focusedAgentId && agentList.length > 0) {
+ setFocusedAgentId(agentList[0].agent_id);
+ }
+ renderAgentTabs();
+}
+
+export function handleAgentRegistered(payload) {
+ const existing = agentList.find(a => a.agent_id === payload.agent_id);
+ if (!existing) agentList.push(payload);
+ if (!focusedAgentId && agentList.length > 0) {
+ setFocusedAgentId(agentList[0].agent_id);
+ }
+ renderAgentTabs();
+}
+
+export function handleAgentUnregistered(payload) {
+ setAgentList(agentList.filter(a => a.agent_id !== payload.agent_id));
+ if (focusedAgentId === payload.agent_id) {
+ setFocusedAgentId(agentList.length > 0 ? agentList[0].agent_id : null);
+ }
+ renderAgentTabs();
+}
+
+export function handleAgentStatus(payload) {
+ const agent = agentList.find(a => a.agent_id === payload.agent_id);
+ if (agent) agent.status = payload.status;
+ renderAgentTabs();
+}
+
+export function isMultiAgent() { return agentList.length > 1; }
+export function isFocusedAgent(msg) {
+ if (!isMultiAgent()) return true;
+ const aid = (msg.payload && msg.payload.agent_id) || msg.agent_id;
+ return !aid || aid === focusedAgentId;
+}
diff --git a/src/mcp_cli/dashboard/static/shell.html b/src/mcp_cli/dashboard/static/shell.html
index be278448..60588a0d 100644
--- a/src/mcp_cli/dashboard/static/shell.html
+++ b/src/mcp_cli/dashboard/static/shell.html
@@ -4,532 +4,14 @@
mcp-cli dashboard
-
+
+
+
+
+
+
+
+
@@ -571,6 +53,9 @@
โ
+
+ ...
+
@@ -620,9 +105,19 @@
-
-
-
+
+
@@ -639,1567 +134,15 @@
Tool Confirmation Required
-
-
-
-
+