diff --git a/src-node/claude-code-agent.js b/src-node/claude-code-agent.js index ee724402a2..c76f7ece71 100644 --- a/src-node/claude-code-agent.js +++ b/src-node/claude-code-agent.js @@ -147,11 +147,25 @@ exports.sendPrompt = async function (params) { // Prepend selection context to the prompt if available let enrichedPrompt = prompt; - if (selectionContext && selectionContext.selectedText) { - enrichedPrompt = - "The user has selected the following text in " + selectionContext.filePath + - " (lines " + selectionContext.startLine + "-" + selectionContext.endLine + "):\n" + - "```\n" + selectionContext.selectedText + "\n```\n\n" + prompt; + if (selectionContext) { + if (selectionContext.selectedText) { + enrichedPrompt = + "The user has selected the following text in " + selectionContext.filePath + + " (lines " + selectionContext.startLine + "-" + selectionContext.endLine + "):\n" + + "```\n" + selectionContext.selectedText + "\n```\n\n" + prompt; + } else { + let previewSnippet = ""; + if (selectionContext.selectionPreview) { + previewSnippet = "\nPreview of selection:\n```\n" + + selectionContext.selectionPreview + "\n```\n"; + } + enrichedPrompt = + "The user has selected lines " + selectionContext.startLine + "-" + + selectionContext.endLine + " in " + selectionContext.filePath + + ". Use the Read tool with offset=" + (selectionContext.startLine - 1) + + " and limit=" + (selectionContext.endLine - selectionContext.startLine + 1) + + " to read the selected content if needed." + previewSnippet + "\n" + prompt; + } } // Run the query asynchronously — don't await here so we return requestId immediately @@ -222,7 +236,9 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale) "mcp__phoenix-editor__getEditorState", "mcp__phoenix-editor__takeScreenshot", "mcp__phoenix-editor__execJsInLivePreview", - "mcp__phoenix-editor__controlEditor" + "mcp__phoenix-editor__controlEditor", + "mcp__phoenix-editor__resizeLivePreview", + "mcp__phoenix-editor__wait" ], mcpServers: { "phoenix-editor": editorMcpServer }, permissionMode: "acceptEdits", diff --git a/src-node/mcp-editor-tools.js b/src-node/mcp-editor-tools.js index 767243041d..66d64ec284 100644 --- a/src-node/mcp-editor-tools.js +++ b/src-node/mcp-editor-tools.js @@ -170,9 +170,57 @@ function createEditorMcpServer(sdkModule, nodeConnector) { } ); + const resizeLivePreviewTool = sdkModule.tool( + "resizeLivePreview", + "Resize the live preview panel to a specific width for responsive testing. " + + "Provide a width in pixels based on the target device (e.g. 390 for a phone, 768 for a tablet, 1440 for desktop).", + { + width: z.number().describe("Target width in pixels") + }, + async function (args) { + try { + const result = await nodeConnector.execPeer("resizeLivePreview", { + width: args.width + }); + if (result.error) { + return { + content: [{ type: "text", text: "Error: " + result.error }], + isError: true + }; + } + return { + content: [{ type: "text", text: JSON.stringify(result) }] + }; + } catch (err) { + return { + content: [{ type: "text", text: "Error resizing live preview: " + err.message }], + isError: true + }; + } + } + ); + + const waitTool = sdkModule.tool( + "wait", + "Wait for a specified number of seconds before continuing. " + + "Useful for waiting after DOM changes, animations, live preview updates, or resize operations " + + "before taking a screenshot or inspecting state. Maximum 60 seconds.", + { + seconds: z.number().min(0.1).max(60).describe("Number of seconds to wait (0.1–60)") + }, + async function (args) { + const ms = Math.round(args.seconds * 1000); + await new Promise(function (resolve) { setTimeout(resolve, ms); }); + return { + content: [{ type: "text", text: "Waited " + args.seconds + " seconds." }] + }; + } + ); + return sdkModule.createSdkMcpServer({ name: "phoenix-editor", - tools: [getEditorStateTool, takeScreenshotTool, execJsInLivePreviewTool, controlEditorTool] + tools: [getEditorStateTool, takeScreenshotTool, execJsInLivePreviewTool, controlEditorTool, + resizeLivePreviewTool, waitTool] }); } diff --git a/src/core-ai/AIChatPanel.js b/src/core-ai/AIChatPanel.js index 396371f250..a786b82cd8 100644 --- a/src/core-ai/AIChatPanel.js +++ b/src/core-ai/AIChatPanel.js @@ -506,15 +506,31 @@ define(function (require, exports, module) { // Gather selection context if available and not dismissed let selectionContext = null; if (_lastSelectionInfo && !_selectionDismissed && _lastSelectionInfo.selectedText) { + const MAX_INLINE_SELECTION = 500; + const MAX_PREVIEW_LINES = 3; + const MAX_PREVIEW_LINE_LEN = 80; let selectedText = _lastSelectionInfo.selectedText; - if (selectedText.length > 10000) { - selectedText = selectedText.slice(0, 10000); + let selectionPreview = null; + if (selectedText.length > MAX_INLINE_SELECTION) { + const lines = selectedText.split("\n"); + const headLines = lines.slice(0, MAX_PREVIEW_LINES).map(function (l) { + return l.length > MAX_PREVIEW_LINE_LEN ? l.slice(0, MAX_PREVIEW_LINE_LEN) + "..." : l; + }); + const tailLines = lines.length > MAX_PREVIEW_LINES * 2 + ? lines.slice(-MAX_PREVIEW_LINES).map(function (l) { + return l.length > MAX_PREVIEW_LINE_LEN ? l.slice(0, MAX_PREVIEW_LINE_LEN) + "..." : l; + }) + : []; + selectionPreview = headLines.join("\n") + + (tailLines.length ? "\n...\n" + tailLines.join("\n") : ""); + selectedText = null; } selectionContext = { filePath: _lastSelectionInfo.filePath, startLine: _lastSelectionInfo.startLine, endLine: _lastSelectionInfo.endLine, - selectedText: selectedText + selectedText: selectedText, + selectionPreview: selectionPreview }; } @@ -623,6 +639,8 @@ define(function (require, exports, module) { "mcp__phoenix-editor__takeScreenshot": { icon: "fa-solid fa-camera", color: "#c084fc", label: Strings.AI_CHAT_TOOL_SCREENSHOT }, "mcp__phoenix-editor__execJsInLivePreview": { icon: "fa-solid fa-eye", color: "#66bb6a", label: Strings.AI_CHAT_TOOL_LIVE_PREVIEW_JS }, "mcp__phoenix-editor__controlEditor": { icon: "fa-solid fa-code", color: "#6bc76b", label: Strings.AI_CHAT_TOOL_CONTROL_EDITOR }, + "mcp__phoenix-editor__resizeLivePreview": { icon: "fa-solid fa-arrows-left-right", color: "#66bb6a", label: Strings.AI_CHAT_TOOL_RESIZE_PREVIEW }, + "mcp__phoenix-editor__wait": { icon: "fa-solid fa-hourglass-half", color: "#adb9bd", label: Strings.AI_CHAT_TOOL_WAIT }, TodoWrite: { icon: "fa-solid fa-list-check", color: "#66bb6a", label: Strings.AI_CHAT_TOOL_TASKS } }; @@ -1174,6 +1192,7 @@ define(function (require, exports, module) { try { $target.html(marked.parse(_segmentText, { breaks: true, gfm: true })); _enhanceColorCodes($target); + _addCopyButtons($target); } catch (e) { $target.text(_segmentText); } @@ -1240,6 +1259,35 @@ define(function (require, exports, module) { }); } + /** + * Inject a copy-to-clipboard button into each
 block inside the given container.
+     * Idempotent: skips 
 elements that already have a .ai-copy-btn.
+     */
+    function _addCopyButtons($container) {
+        $container.find("pre").each(function () {
+            const $pre = $(this);
+            if ($pre.find(".ai-copy-btn").length) {
+                return;
+            }
+            const $btn = $('');
+            $btn.on("click", function (e) {
+                e.stopPropagation();
+                const $code = $pre.find("code");
+                const text = ($code.length ? $code[0] : $pre[0]).textContent;
+                Phoenix.app.copyToClipboard(text);
+                const $icon = $btn.find("i");
+                $icon.removeClass("fa-copy").addClass("fa-check");
+                $btn.attr("title", Strings.AI_CHAT_COPIED_CODE);
+                setTimeout(function () {
+                    $icon.removeClass("fa-check").addClass("fa-copy");
+                    $btn.attr("title", Strings.AI_CHAT_COPY_CODE);
+                }, 1500);
+            });
+            $pre.append($btn);
+        });
+    }
+
     function _appendToolIndicator(toolName, toolId) {
         // Remove thinking indicator on first content
         if (!_hasReceivedContent) {
@@ -1329,6 +1377,20 @@ define(function (require, exports, module) {
             $tool.find(".ai-tool-header").on("click", function () {
                 $tool.toggleClass("ai-tool-expanded");
             }).css("cursor", "pointer");
+        } else if (toolName === "mcp__phoenix-editor__wait" && toolInput && toolInput.seconds) {
+            // Countdown timer: update label every second
+            const totalSec = Math.ceil(toolInput.seconds);
+            let remaining = totalSec;
+            const $label = $tool.find(".ai-tool-label");
+            const countdownId = setInterval(function () {
+                remaining--;
+                if (remaining <= 0) {
+                    clearInterval(countdownId);
+                    $label.text(StringUtils.format(Strings.AI_CHAT_TOOL_WAITED, totalSec));
+                } else {
+                    $label.text(StringUtils.format(Strings.AI_CHAT_TOOL_WAITING, remaining));
+                }
+            }, 1000);
         } else if (toolName === "mcp__phoenix-editor__takeScreenshot") {
             const $detail = $('
'); $tool.append($detail); @@ -1451,6 +1513,16 @@ define(function (require, exports, module) { summary: Strings.AI_CHAT_TOOL_LIVE_PREVIEW_JS, lines: input.code ? input.code.split("\n").slice(0, 20) : [] }; + case "mcp__phoenix-editor__resizeLivePreview": + return { + summary: Strings.AI_CHAT_TOOL_RESIZE_PREVIEW, + lines: input.width ? [input.width + "px"] : [] + }; + case "mcp__phoenix-editor__wait": + return { + summary: StringUtils.format(Strings.AI_CHAT_TOOL_WAITING, input.seconds || "?"), + lines: [] + }; case "TodoWrite": { const todos = input.todos || []; const completed = todos.filter(function (t) { return t.status === "completed"; }).length; @@ -1606,6 +1678,9 @@ define(function (require, exports, module) { // Finalize: remove ai-stream-target class so future messages get their own container $messages.find(".ai-stream-target").removeClass("ai-stream-target"); + // Ensure copy buttons are present on all code blocks + _addCopyButtons($messages); + // Mark all active tool indicators as done _finishActiveTools(); } diff --git a/src/core-ai/aiPhoenixConnectors.js b/src/core-ai/aiPhoenixConnectors.js index 0038673236..75c1c64db4 100644 --- a/src/core-ai/aiPhoenixConnectors.js +++ b/src/core-ai/aiPhoenixConnectors.js @@ -36,9 +36,11 @@ define(function (require, exports, module) { FileSystem = require("filesystem/FileSystem"), LiveDevProtocol = require("LiveDevelopment/MultiBrowserImpl/protocol/LiveDevProtocol"), LiveDevMain = require("LiveDevelopment/main"), + LivePreviewConstants = require("LiveDevelopment/LivePreviewConstants"), WorkspaceManager = require("view/WorkspaceManager"), SnapshotStore = require("core-ai/AISnapshotStore"), EventDispatcher = require("utils/EventDispatcher"), + StringUtils = require("utils/StringUtils"), Strings = require("strings"); // filePath → previous content before edit, for undo/snapshot support @@ -47,6 +49,146 @@ define(function (require, exports, module) { // Last screenshot base64 data, for displaying in tool indicators let _lastScreenshotBase64 = null; + // Banner / live preview mode state + let _activeExecJsCount = 0; + let _savedLivePreviewMode = null; + let _bannerDismissed = false; + let _bannerEl = null; + let _bannerStyleInjected = false; + let _bannerAutoHideTimer = null; + + /** + * Inject banner CSS once into the document head. + */ + function _injectBannerStyles() { + if (_bannerStyleInjected) { + return; + } + _bannerStyleInjected = true; + const style = document.createElement("style"); + style.textContent = + "@keyframes ai-banner-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }" + + ".ai-lp-banner {" + + " position: absolute; top: 0; left: 0; right: 0; bottom: 0;" + + " display: flex; align-items: center; justify-content: center; gap: 8px;" + + " background: rgba(24,24,28,0.52);" + + " backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);" + + " z-index: 10; border-radius: 3px;" + + " font-size: 12px; color: #e0e0e0; pointer-events: auto;" + + " transition: opacity 0.3s ease;" + + "}" + + ".ai-lp-banner .ai-lp-banner-icon {" + + " color: #66bb6a; animation: ai-banner-pulse 1.5s ease-in-out infinite;" + + "}" + + ".ai-lp-banner .ai-lp-banner-close {" + + " position: absolute; right: 6px; top: 50%; transform: translateY(-50%);" + + " background: none; border: none; color: #aaa; cursor: pointer;" + + " font-size: 14px; padding: 2px 5px; line-height: 1;" + + "}" + + ".ai-lp-banner .ai-lp-banner-close:hover { color: #fff; }"; + document.head.appendChild(style); + } + + /** + * Show a banner overlay on the live preview toolbar. + * @param {string} text - Banner message text + */ + function _showBanner(text) { + if (_bannerDismissed) { + return; + } + _injectBannerStyles(); + const toolbar = document.getElementById("live-preview-plugin-toolbar"); + if (!toolbar) { + return; + } + // Ensure toolbar can host absolutely positioned children + if (getComputedStyle(toolbar).position === "static") { + toolbar.style.position = "relative"; + } + if (_bannerEl && _bannerEl.parentNode) { + // Update text on existing banner + const textSpan = _bannerEl.querySelector(".ai-lp-banner-text"); + if (textSpan) { + textSpan.textContent = text; + } + _bannerEl.style.opacity = "1"; + return; + } + const banner = document.createElement("div"); + banner.className = "ai-lp-banner"; + banner.innerHTML = + '' + + '' + text.replace(/' + + ''; + banner.querySelector(".ai-lp-banner-close").addEventListener("click", function () { + _bannerDismissed = true; + _hideBanner(); + }); + toolbar.appendChild(banner); + _bannerEl = banner; + } + + /** + * Hide and remove the banner overlay with a fade-out transition. + */ + function _hideBanner() { + if (!_bannerEl) { + return; + } + _bannerEl.style.opacity = "0"; + const el = _bannerEl; + setTimeout(function () { + if (el.parentNode) { + el.parentNode.removeChild(el); + } + }, 300); + _bannerEl = null; + } + + /** + * Called when an execJsInLivePreview call starts. Increments the active + * count, saves mode and shows banner on first call. + */ + function _onExecJsStart() { + _activeExecJsCount++; + if (_activeExecJsCount === 1) { + // Cancel any pending auto-hide from a previous exec batch + if (_bannerAutoHideTimer) { + clearTimeout(_bannerAutoHideTimer); + _bannerAutoHideTimer = null; + } + _savedLivePreviewMode = LiveDevMain.getCurrentMode(); + if (_savedLivePreviewMode !== LivePreviewConstants.LIVE_PREVIEW_MODE) { + LiveDevMain.setMode(LivePreviewConstants.LIVE_PREVIEW_MODE); + } + _bannerDismissed = false; + _showBanner(Strings.AI_LIVE_PREVIEW_BANNER_TEXT); + } + } + + /** + * Called when an execJsInLivePreview call finishes. Decrements the count + * and restores mode / hides banner when all calls are done. + */ + function _onExecJsDone() { + _activeExecJsCount = Math.max(0, _activeExecJsCount - 1); + if (_activeExecJsCount === 0) { + if (_savedLivePreviewMode && _savedLivePreviewMode !== LivePreviewConstants.LIVE_PREVIEW_MODE) { + LiveDevMain.setMode(_savedLivePreviewMode); + } + _savedLivePreviewMode = null; + // Keep the banner visible briefly so the user can read it + if (_bannerAutoHideTimer) { + clearTimeout(_bannerAutoHideTimer); + } + _bannerAutoHideTimer = setTimeout(function () { + _hideBanner(); + _bannerAutoHideTimer = null; + }, 5000); + } + } + // --- Editor state --- /** @@ -354,13 +496,16 @@ define(function (require, exports, module) { */ function execJsInLivePreview(params) { const deferred = new $.Deferred(); + _onExecJsStart(); function _evaluate() { LiveDevProtocol.evaluate(params.code) .done(function (evalResult) { + _onExecJsDone(); deferred.resolve({ result: JSON.stringify(evalResult) }); }) .fail(function (err) { + _onExecJsDone(); deferred.resolve({ error: (err && err.message) || String(err) || "evaluate() failed" }); }); } @@ -397,6 +542,7 @@ define(function (require, exports, module) { const timeoutTimer = setTimeout(function () { if (settled) { return; } cleanup(); + _onExecJsDone(); deferred.resolve({ error: "Timed out waiting for live preview connection (30s)" }); }, TIMEOUT); @@ -495,6 +641,62 @@ define(function (require, exports, module) { return deferred.promise(); } + // --- Live preview resize --- + + /** + * Resize the live preview panel to a specific width in pixels. + * @param {Object} params - { width: number } + * @return {$.Promise} resolves with { actualWidth } or { error } + */ + function resizeLivePreview(params) { + const deferred = new $.Deferred(); + + if (!params.width) { + deferred.resolve({ error: "Provide 'width' as a number in pixels" }); + return deferred.promise(); + } + + const targetWidth = params.width; + const label = targetWidth + "px"; + + // Ensure live preview panel is open + const panel = WorkspaceManager.getPanelForID("live-preview-panel"); + if (!panel || !panel.isVisible()) { + CommandManager.execute("file.liveFilePreview"); + } + + // Give the panel a moment to open, then resize + setTimeout(function () { + WorkspaceManager.setPluginPanelWidth(targetWidth); + + // Read back actual width from the toolbar + const toolbar = document.getElementById("live-preview-plugin-toolbar"); + const actualWidth = toolbar ? toolbar.offsetWidth : targetWidth; + + // Show brief banner + _bannerDismissed = false; + _showBanner(StringUtils.format(Strings.AI_LIVE_PREVIEW_BANNER_RESIZE, label)); + if (_bannerAutoHideTimer) { + clearTimeout(_bannerAutoHideTimer); + } + _bannerAutoHideTimer = setTimeout(function () { + _hideBanner(); + _bannerAutoHideTimer = null; + }, 5000); + + const result = { actualWidth: actualWidth }; + if (actualWidth !== targetWidth) { + result.clamped = true; + result.note = "Requested " + targetWidth + "px but the editor window can only " + + "accommodate " + actualWidth + "px. The user needs to increase the editor " + + "window size to allow a wider preview."; + } + deferred.resolve(result); + }, 100); + + return deferred.promise(); + } + exports.getEditorState = getEditorState; exports.takeScreenshot = takeScreenshot; exports.getFileContent = getFileContent; @@ -504,6 +706,7 @@ define(function (require, exports, module) { exports.getLastScreenshot = getLastScreenshot; exports.execJsInLivePreview = execJsInLivePreview; exports.controlEditor = controlEditor; + exports.resizeLivePreview = resizeLivePreview; EventDispatcher.makeEventDispatcher(exports); }); diff --git a/src/core-ai/main.js b/src/core-ai/main.js index 48bab8a91a..794aae4f8a 100644 --- a/src/core-ai/main.js +++ b/src/core-ai/main.js @@ -58,6 +58,10 @@ define(function (require, exports, module) { return PhoenixConnectors.controlEditor(params); }; + exports.resizeLivePreview = async function (params) { + return PhoenixConnectors.resizeLivePreview(params); + }; + AppInit.appReady(function () { SidebarTabs.addTab("ai", "AI", "fa-solid fa-wand-magic-sparkles", { priority: 200 }); diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index f6b9db8f8d..1dabc4f942 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -1826,6 +1826,9 @@ define({ "AI_CHAT_TOOL_SCREENSHOT_LIVE_PREVIEW": "live preview", "AI_CHAT_TOOL_SCREENSHOT_FULL_PAGE": "full page", "AI_CHAT_TOOL_LIVE_PREVIEW_JS": "Inspecting preview", + "AI_CHAT_TOOL_RESIZE_PREVIEW": "Resize preview", + "AI_LIVE_PREVIEW_BANNER_TEXT": "AI is inspecting the live preview", + "AI_LIVE_PREVIEW_BANNER_RESIZE": "AI resized preview to {0}", "AI_CHAT_TOOL_CONTROL_EDITOR": "Editor", "AI_CHAT_TOOL_TASKS": "Tasks", "AI_CHAT_TOOL_TASKS_SUMMARY": "{0} of {1} tasks done", @@ -1859,6 +1862,11 @@ define({ "AI_CHAT_CONTEXT_SELECTION": "Selection L{0}-L{1}", "AI_CHAT_CONTEXT_CURSOR": "Line {0}", "AI_CHAT_CONTEXT_LIVE_PREVIEW": "Live Preview", + "AI_CHAT_TOOL_WAIT": "Wait", + "AI_CHAT_TOOL_WAITING": "Waiting {0}s", + "AI_CHAT_TOOL_WAITED": "Done waiting {0}s", + "AI_CHAT_COPY_CODE": "Copy", + "AI_CHAT_COPIED_CODE": "Copied!", // demo start - Phoenix Code Playground - Interactive Onboarding "DEMO_SECTION1_TITLE": "Edit in Live Preview", diff --git a/src/styles/Extn-AIChatPanel.less b/src/styles/Extn-AIChatPanel.less index d086b11a44..c9ed5e3bb0 100644 --- a/src/styles/Extn-AIChatPanel.less +++ b/src/styles/Extn-AIChatPanel.less @@ -163,6 +163,7 @@ border-radius: 4px; overflow-x: auto; margin: 6px 0; + position: relative; code { background: none; @@ -172,6 +173,31 @@ line-height: 1.5; color: @project-panel-text-1; } + + .ai-copy-btn { + position: absolute; + top: 4px; + right: 4px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 4px; + color: rgba(255, 255, 255, 0.5); + cursor: pointer; + padding: 2px 6px; + font-size: 12px; + line-height: 1; + opacity: 0; + transition: opacity 0.15s ease, background 0.15s ease, color 0.15s ease; + + &:hover { + background: rgba(255, 255, 255, 0.15); + color: rgba(255, 255, 255, 0.85); + } + } + + &:hover .ai-copy-btn { + opacity: 1; + } } ul, ol { @@ -216,6 +242,7 @@ max-width: 50%; white-space: nowrap; overflow-wrap: normal; + cursor: text; } th { diff --git a/src/view/WorkspaceManager.js b/src/view/WorkspaceManager.js index c56bbb4d95..c11bc2e0a5 100644 --- a/src/view/WorkspaceManager.js +++ b/src/view/WorkspaceManager.js @@ -160,7 +160,8 @@ define(function (require, exports, module) { } }); - $mainToolbar.data("maxsize", window.innerWidth*.75); + var sidebarWidth = $("#sidebar").outerWidth() || 0; + $mainToolbar.data("maxsize", Math.min(window.innerWidth * 0.75, window.innerWidth - sidebarWidth - 100)); } @@ -442,7 +443,8 @@ define(function (require, exports, module) { // Respect min/max constraints var minSize = currentlyShownPanel.minWidth || 0; var minToolbarWidth = minSize + pluginIconsBarWidth; - var maxToolbarWidth = window.innerWidth * 0.75; + var sidebarWidth = $("#sidebar").outerWidth() || 0; + var maxToolbarWidth = Math.min(window.innerWidth * 0.75, window.innerWidth - sidebarWidth - 100); newToolbarWidth = Math.max(newToolbarWidth, minToolbarWidth); newToolbarWidth = Math.min(newToolbarWidth, maxToolbarWidth); diff --git a/test/spec/MainViewManager-integ-test.js b/test/spec/MainViewManager-integ-test.js index 1d9d96e36a..16e61040a7 100644 --- a/test/spec/MainViewManager-integ-test.js +++ b/test/spec/MainViewManager-integ-test.js @@ -1238,6 +1238,39 @@ define(function (require, exports, module) { expect(panelContentWidth).toEqual(targetWidth); }); }); + + it("should preserve at least 100px for the editor area", function () { + pluginPanel.show(); + + // Request an absurdly large width that would squeeze the editor to near-zero + WorkspaceManager.setPluginPanelWidth(testWindow.innerWidth); + + const $mainToolbar = _$("#main-toolbar"); + const sidebarWidth = _$("#sidebar").outerWidth() || 0; + const toolbarWidth = $mainToolbar.width(); + const editorWidth = testWindow.innerWidth - sidebarWidth - toolbarWidth; + + // Editor area must retain at least 100px + expect(editorWidth).toBeGreaterThanOrEqual(100); + }); + + it("should set drag-based maxsize to preserve at least 100px for the editor", function () { + pluginPanel.show(); + + // Trigger a layout recompute so updateResizeLimits runs + WorkspaceManager.recomputeLayout(true); + + const $mainToolbar = _$("#main-toolbar"); + const sidebarWidth = _$("#sidebar").outerWidth() || 0; + const maxSize = $mainToolbar.data("maxsize"); + + // maxSize must leave at least 100px for the editor + const editorWidthAtMax = testWindow.innerWidth - sidebarWidth - maxSize; + expect(editorWidthAtMax).toBeGreaterThanOrEqual(100); + + // maxSize should also not exceed 75% of window width + expect(maxSize).toBeLessThanOrEqual(testWindow.innerWidth * 0.75); + }); }); }); });