Skip to content

Commit 12ac616

Browse files
committed
feat: add live preview banner and resizeLivePreview MCP tool
Show a frosted-glass banner on the live preview toolbar when AI inspects via execJsInLivePreview, with mode save/restore and reference counting. Add resizeLivePreview MCP tool that accepts a pixel width, clamps via WorkspaceManager, and reports back actual width with a clamped notice when the editor can't accommodate the requested size.
1 parent 61eba66 commit 12ac616

6 files changed

Lines changed: 237 additions & 2 deletions

File tree

src-node/claude-code-agent.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,8 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale)
236236
"mcp__phoenix-editor__getEditorState",
237237
"mcp__phoenix-editor__takeScreenshot",
238238
"mcp__phoenix-editor__execJsInLivePreview",
239-
"mcp__phoenix-editor__controlEditor"
239+
"mcp__phoenix-editor__controlEditor",
240+
"mcp__phoenix-editor__resizeLivePreview"
240241
],
241242
mcpServers: { "phoenix-editor": editorMcpServer },
242243
permissionMode: "acceptEdits",

src-node/mcp-editor-tools.js

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,9 +170,39 @@ function createEditorMcpServer(sdkModule, nodeConnector) {
170170
}
171171
);
172172

173+
const resizeLivePreviewTool = sdkModule.tool(
174+
"resizeLivePreview",
175+
"Resize the live preview panel to a specific width for responsive testing. " +
176+
"Provide a width in pixels based on the target device (e.g. 390 for a phone, 768 for a tablet, 1440 for desktop).",
177+
{
178+
width: z.number().describe("Target width in pixels")
179+
},
180+
async function (args) {
181+
try {
182+
const result = await nodeConnector.execPeer("resizeLivePreview", {
183+
width: args.width
184+
});
185+
if (result.error) {
186+
return {
187+
content: [{ type: "text", text: "Error: " + result.error }],
188+
isError: true
189+
};
190+
}
191+
return {
192+
content: [{ type: "text", text: JSON.stringify(result) }]
193+
};
194+
} catch (err) {
195+
return {
196+
content: [{ type: "text", text: "Error resizing live preview: " + err.message }],
197+
isError: true
198+
};
199+
}
200+
}
201+
);
202+
173203
return sdkModule.createSdkMcpServer({
174204
name: "phoenix-editor",
175-
tools: [getEditorStateTool, takeScreenshotTool, execJsInLivePreviewTool, controlEditorTool]
205+
tools: [getEditorStateTool, takeScreenshotTool, execJsInLivePreviewTool, controlEditorTool, resizeLivePreviewTool]
176206
});
177207
}
178208

src/core-ai/AIChatPanel.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -639,6 +639,7 @@ define(function (require, exports, module) {
639639
"mcp__phoenix-editor__takeScreenshot": { icon: "fa-solid fa-camera", color: "#c084fc", label: Strings.AI_CHAT_TOOL_SCREENSHOT },
640640
"mcp__phoenix-editor__execJsInLivePreview": { icon: "fa-solid fa-eye", color: "#66bb6a", label: Strings.AI_CHAT_TOOL_LIVE_PREVIEW_JS },
641641
"mcp__phoenix-editor__controlEditor": { icon: "fa-solid fa-code", color: "#6bc76b", label: Strings.AI_CHAT_TOOL_CONTROL_EDITOR },
642+
"mcp__phoenix-editor__resizeLivePreview": { icon: "fa-solid fa-arrows-left-right", color: "#66bb6a", label: Strings.AI_CHAT_TOOL_RESIZE_PREVIEW },
642643
TodoWrite: { icon: "fa-solid fa-list-check", color: "#66bb6a", label: Strings.AI_CHAT_TOOL_TASKS }
643644
};
644645

@@ -1467,6 +1468,11 @@ define(function (require, exports, module) {
14671468
summary: Strings.AI_CHAT_TOOL_LIVE_PREVIEW_JS,
14681469
lines: input.code ? input.code.split("\n").slice(0, 20) : []
14691470
};
1471+
case "mcp__phoenix-editor__resizeLivePreview":
1472+
return {
1473+
summary: Strings.AI_CHAT_TOOL_RESIZE_PREVIEW,
1474+
lines: input.width ? [input.width + "px"] : []
1475+
};
14701476
case "TodoWrite": {
14711477
const todos = input.todos || [];
14721478
const completed = todos.filter(function (t) { return t.status === "completed"; }).length;

src/core-ai/aiPhoenixConnectors.js

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,11 @@ define(function (require, exports, module) {
3636
FileSystem = require("filesystem/FileSystem"),
3737
LiveDevProtocol = require("LiveDevelopment/MultiBrowserImpl/protocol/LiveDevProtocol"),
3838
LiveDevMain = require("LiveDevelopment/main"),
39+
LivePreviewConstants = require("LiveDevelopment/LivePreviewConstants"),
3940
WorkspaceManager = require("view/WorkspaceManager"),
4041
SnapshotStore = require("core-ai/AISnapshotStore"),
4142
EventDispatcher = require("utils/EventDispatcher"),
43+
StringUtils = require("utils/StringUtils"),
4244
Strings = require("strings");
4345

4446
// filePath → previous content before edit, for undo/snapshot support
@@ -47,6 +49,134 @@ define(function (require, exports, module) {
4749
// Last screenshot base64 data, for displaying in tool indicators
4850
let _lastScreenshotBase64 = null;
4951

52+
// Banner / live preview mode state
53+
let _activeExecJsCount = 0;
54+
let _savedLivePreviewMode = null;
55+
let _bannerDismissed = false;
56+
let _bannerEl = null;
57+
let _bannerStyleInjected = false;
58+
let _bannerAutoHideTimer = null;
59+
60+
/**
61+
* Inject banner CSS once into the document head.
62+
*/
63+
function _injectBannerStyles() {
64+
if (_bannerStyleInjected) {
65+
return;
66+
}
67+
_bannerStyleInjected = true;
68+
const style = document.createElement("style");
69+
style.textContent =
70+
"@keyframes ai-banner-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }" +
71+
".ai-lp-banner {" +
72+
" position: absolute; top: 0; left: 0; right: 0; bottom: 0;" +
73+
" display: flex; align-items: center; justify-content: center; gap: 8px;" +
74+
" background: rgba(24,24,28,0.52);" +
75+
" backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);" +
76+
" z-index: 10; border-radius: 3px;" +
77+
" font-size: 12px; color: #e0e0e0; pointer-events: auto;" +
78+
" transition: opacity 0.3s ease;" +
79+
"}" +
80+
".ai-lp-banner .ai-lp-banner-icon {" +
81+
" color: #66bb6a; animation: ai-banner-pulse 1.5s ease-in-out infinite;" +
82+
"}" +
83+
".ai-lp-banner .ai-lp-banner-close {" +
84+
" position: absolute; right: 6px; top: 50%; transform: translateY(-50%);" +
85+
" background: none; border: none; color: #aaa; cursor: pointer;" +
86+
" font-size: 14px; padding: 2px 5px; line-height: 1;" +
87+
"}" +
88+
".ai-lp-banner .ai-lp-banner-close:hover { color: #fff; }";
89+
document.head.appendChild(style);
90+
}
91+
92+
/**
93+
* Show a banner overlay on the live preview toolbar.
94+
* @param {string} text - Banner message text
95+
*/
96+
function _showBanner(text) {
97+
if (_bannerDismissed) {
98+
return;
99+
}
100+
_injectBannerStyles();
101+
const toolbar = document.getElementById("live-preview-plugin-toolbar");
102+
if (!toolbar) {
103+
return;
104+
}
105+
// Ensure toolbar can host absolutely positioned children
106+
if (getComputedStyle(toolbar).position === "static") {
107+
toolbar.style.position = "relative";
108+
}
109+
if (_bannerEl && _bannerEl.parentNode) {
110+
// Update text on existing banner
111+
const textSpan = _bannerEl.querySelector(".ai-lp-banner-text");
112+
if (textSpan) {
113+
textSpan.textContent = text;
114+
}
115+
_bannerEl.style.opacity = "1";
116+
return;
117+
}
118+
const banner = document.createElement("div");
119+
banner.className = "ai-lp-banner";
120+
banner.innerHTML =
121+
'<i class="fa-solid fa-eye ai-lp-banner-icon"></i>' +
122+
'<span class="ai-lp-banner-text">' + text.replace(/</g, "&lt;") + '</span>' +
123+
'<button class="ai-lp-banner-close" title="Dismiss">&times;</button>';
124+
banner.querySelector(".ai-lp-banner-close").addEventListener("click", function () {
125+
_bannerDismissed = true;
126+
_hideBanner();
127+
});
128+
toolbar.appendChild(banner);
129+
_bannerEl = banner;
130+
}
131+
132+
/**
133+
* Hide and remove the banner overlay with a fade-out transition.
134+
*/
135+
function _hideBanner() {
136+
if (!_bannerEl) {
137+
return;
138+
}
139+
_bannerEl.style.opacity = "0";
140+
const el = _bannerEl;
141+
setTimeout(function () {
142+
if (el.parentNode) {
143+
el.parentNode.removeChild(el);
144+
}
145+
}, 300);
146+
_bannerEl = null;
147+
}
148+
149+
/**
150+
* Called when an execJsInLivePreview call starts. Increments the active
151+
* count, saves mode and shows banner on first call.
152+
*/
153+
function _onExecJsStart() {
154+
_activeExecJsCount++;
155+
if (_activeExecJsCount === 1) {
156+
_savedLivePreviewMode = LiveDevMain.getCurrentMode();
157+
if (_savedLivePreviewMode !== LivePreviewConstants.LIVE_PREVIEW_MODE) {
158+
LiveDevMain.setMode(LivePreviewConstants.LIVE_PREVIEW_MODE);
159+
}
160+
_bannerDismissed = false;
161+
_showBanner(Strings.AI_LIVE_PREVIEW_BANNER_TEXT);
162+
}
163+
}
164+
165+
/**
166+
* Called when an execJsInLivePreview call finishes. Decrements the count
167+
* and restores mode / hides banner when all calls are done.
168+
*/
169+
function _onExecJsDone() {
170+
_activeExecJsCount = Math.max(0, _activeExecJsCount - 1);
171+
if (_activeExecJsCount === 0) {
172+
if (_savedLivePreviewMode && _savedLivePreviewMode !== LivePreviewConstants.LIVE_PREVIEW_MODE) {
173+
LiveDevMain.setMode(_savedLivePreviewMode);
174+
}
175+
_savedLivePreviewMode = null;
176+
_hideBanner();
177+
}
178+
}
179+
50180
// --- Editor state ---
51181

52182
/**
@@ -354,13 +484,16 @@ define(function (require, exports, module) {
354484
*/
355485
function execJsInLivePreview(params) {
356486
const deferred = new $.Deferred();
487+
_onExecJsStart();
357488

358489
function _evaluate() {
359490
LiveDevProtocol.evaluate(params.code)
360491
.done(function (evalResult) {
492+
_onExecJsDone();
361493
deferred.resolve({ result: JSON.stringify(evalResult) });
362494
})
363495
.fail(function (err) {
496+
_onExecJsDone();
364497
deferred.resolve({ error: (err && err.message) || String(err) || "evaluate() failed" });
365498
});
366499
}
@@ -397,6 +530,7 @@ define(function (require, exports, module) {
397530
const timeoutTimer = setTimeout(function () {
398531
if (settled) { return; }
399532
cleanup();
533+
_onExecJsDone();
400534
deferred.resolve({ error: "Timed out waiting for live preview connection (30s)" });
401535
}, TIMEOUT);
402536

@@ -495,6 +629,62 @@ define(function (require, exports, module) {
495629
return deferred.promise();
496630
}
497631

632+
// --- Live preview resize ---
633+
634+
/**
635+
* Resize the live preview panel to a specific width in pixels.
636+
* @param {Object} params - { width: number }
637+
* @return {$.Promise} resolves with { actualWidth } or { error }
638+
*/
639+
function resizeLivePreview(params) {
640+
const deferred = new $.Deferred();
641+
642+
if (!params.width) {
643+
deferred.resolve({ error: "Provide 'width' as a number in pixels" });
644+
return deferred.promise();
645+
}
646+
647+
const targetWidth = params.width;
648+
const label = targetWidth + "px";
649+
650+
// Ensure live preview panel is open
651+
const panel = WorkspaceManager.getPanelForID("live-preview-panel");
652+
if (!panel || !panel.isVisible()) {
653+
CommandManager.execute("file.liveFilePreview");
654+
}
655+
656+
// Give the panel a moment to open, then resize
657+
setTimeout(function () {
658+
WorkspaceManager.setPluginPanelWidth(targetWidth);
659+
660+
// Read back actual width from the toolbar
661+
const toolbar = document.getElementById("live-preview-plugin-toolbar");
662+
const actualWidth = toolbar ? toolbar.offsetWidth : targetWidth;
663+
664+
// Show brief banner
665+
_bannerDismissed = false;
666+
_showBanner(StringUtils.format(Strings.AI_LIVE_PREVIEW_BANNER_RESIZE, label));
667+
if (_bannerAutoHideTimer) {
668+
clearTimeout(_bannerAutoHideTimer);
669+
}
670+
_bannerAutoHideTimer = setTimeout(function () {
671+
_hideBanner();
672+
_bannerAutoHideTimer = null;
673+
}, 3000);
674+
675+
const result = { actualWidth: actualWidth };
676+
if (actualWidth !== targetWidth) {
677+
result.clamped = true;
678+
result.note = "Requested " + targetWidth + "px but the editor window can only " +
679+
"accommodate " + actualWidth + "px. The user needs to increase the editor " +
680+
"window size to allow a wider preview.";
681+
}
682+
deferred.resolve(result);
683+
}, 100);
684+
685+
return deferred.promise();
686+
}
687+
498688
exports.getEditorState = getEditorState;
499689
exports.takeScreenshot = takeScreenshot;
500690
exports.getFileContent = getFileContent;
@@ -504,6 +694,7 @@ define(function (require, exports, module) {
504694
exports.getLastScreenshot = getLastScreenshot;
505695
exports.execJsInLivePreview = execJsInLivePreview;
506696
exports.controlEditor = controlEditor;
697+
exports.resizeLivePreview = resizeLivePreview;
507698

508699
EventDispatcher.makeEventDispatcher(exports);
509700
});

src/core-ai/main.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ define(function (require, exports, module) {
5858
return PhoenixConnectors.controlEditor(params);
5959
};
6060

61+
exports.resizeLivePreview = async function (params) {
62+
return PhoenixConnectors.resizeLivePreview(params);
63+
};
64+
6165
AppInit.appReady(function () {
6266
SidebarTabs.addTab("ai", "AI", "fa-solid fa-wand-magic-sparkles", { priority: 200 });
6367

src/nls/root/strings.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1826,6 +1826,9 @@ define({
18261826
"AI_CHAT_TOOL_SCREENSHOT_LIVE_PREVIEW": "live preview",
18271827
"AI_CHAT_TOOL_SCREENSHOT_FULL_PAGE": "full page",
18281828
"AI_CHAT_TOOL_LIVE_PREVIEW_JS": "Inspecting preview",
1829+
"AI_CHAT_TOOL_RESIZE_PREVIEW": "Resize preview",
1830+
"AI_LIVE_PREVIEW_BANNER_TEXT": "AI is inspecting the live preview",
1831+
"AI_LIVE_PREVIEW_BANNER_RESIZE": "AI resized preview to {0}",
18291832
"AI_CHAT_TOOL_CONTROL_EDITOR": "Editor",
18301833
"AI_CHAT_TOOL_TASKS": "Tasks",
18311834
"AI_CHAT_TOOL_TASKS_SUMMARY": "{0} of {1} tasks done",

0 commit comments

Comments
 (0)