From 22cf29d477b14a4d9e805fe23e01e14cd36e132b Mon Sep 17 00:00:00 2001 From: Frank Song Date: Wed, 29 Apr 2026 16:45:26 +0800 Subject: [PATCH] Restore terminal resize and collapse controls --- CHANGELOG.md | 8 ++ static/index.html | 13 +++ static/style.css | 23 ++++- static/terminal.js | 46 +++++++-- tests/test_embedded_workspace_terminal.py | 117 +++++++++++++++++++++- 5 files changed, 193 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6fad61a..458c4bf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +### Fixed +- **Embedded terminal resize and collapse controls restored** — restores the + collapse/expand dock markup and controlled height CSS variable lost during the + v0.50.237 batch integration, and reinstates regression coverage for terminal + resizing and collapsed-state behavior. (`static/index.html`, + `static/style.css`, `static/terminal.js`, + `tests/test_embedded_workspace_terminal.py`) + ## [v0.50.237] — 2026-04-29 ### Added diff --git a/static/index.html b/static/index.html index ea6b9879..d557724b 100644 --- a/static/index.html +++ b/static/index.html @@ -325,6 +325,7 @@ + @@ -332,6 +333,18 @@
+ diff --git a/static/style.css b/static/style.css index cb037168..8e459c18 100644 --- a/static/style.css +++ b/static/style.css @@ -793,10 +793,23 @@ .send-btn:disabled{opacity:.35;cursor:not-allowed;transform:none;box-shadow:none;} .send-btn.visible{animation:send-pop-in .18s cubic-bezier(.34,1.56,.64,1) forwards;} .composer-terminal-panel{position:absolute;left:0;right:0;bottom:-24px;width:min(calc(100% - 64px),720px);margin:0 auto;box-sizing:border-box;overflow:hidden;pointer-events:none;z-index:1;} - .composer-terminal-panel.is-open{pointer-events:auto;} + .composer-terminal-panel.is-open,.composer-terminal-panel.is-collapsed{pointer-events:auto;} .composer-terminal-panel[hidden]{display:none!important;} - .composer-terminal-inner{height:260px;min-height:180px;display:flex;flex-direction:column;overflow:hidden;resize:vertical;border:1px solid var(--border2);border-radius:14px;background:var(--surface);box-shadow:0 12px 32px rgba(0,0,0,.22);padding-bottom:38px;transform:translateY(100%);opacity:0;transition:transform .4s cubic-bezier(.32,.72,.16,1),opacity .25s ease;} + .composer-terminal-inner{height:var(--composer-terminal-height,260px);min-height:180px;max-height:min(520px,50vh);display:flex;flex-direction:column;overflow:hidden;border:1px solid var(--border2);border-radius:14px;background:var(--surface);box-shadow:0 12px 32px rgba(0,0,0,.22);padding-bottom:38px;transform:translateY(100%);opacity:0;transition:transform .4s cubic-bezier(.32,.72,.16,1),opacity .25s ease;} .composer-terminal-panel.is-open .composer-terminal-inner{transform:translateY(0);opacity:1;} + .composer-terminal-panel.is-expanding-from-dock .composer-terminal-inner{transition:opacity .18s ease;} + .composer-terminal-panel.is-collapsed{bottom:-2px;width:min(calc(100% - 112px),560px);overflow:visible;z-index:4;} + .composer-terminal-panel.is-collapsed .composer-terminal-inner{position:absolute;opacity:0;pointer-events:none;transform:translateY(100%);} + .composer-terminal-dock{min-height:42px;display:flex;align-items:center;justify-content:space-between;gap:12px;border:1px solid var(--border);border-radius:13px;background:color-mix(in srgb,var(--surface) 86%,transparent);box-shadow:0 8px 22px rgba(0,0,0,.16);padding:7px 9px 7px 12px;backdrop-filter:blur(10px);transform:translateY(100%);opacity:0;transition:transform .32s cubic-bezier(.32,.72,.16,1),opacity .2s ease;} + .composer-terminal-panel.is-collapsed .composer-terminal-dock{transform:translateY(0);opacity:.94;} + .composer-terminal-dock[hidden]{display:none!important;} + .composer-terminal-dock-title{min-width:0;display:flex;align-items:center;gap:6px;color:var(--muted);font-size:12px;font-weight:700;letter-spacing:.02em;text-transform:uppercase;} + .composer-terminal-dock-dot{width:7px;height:7px;border-radius:999px;background:var(--success);box-shadow:0 0 0 3px color-mix(in srgb,var(--success) 16%,transparent);flex:0 0 auto;} + #terminalDockWorkspaceLabel{min-width:0;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted);text-transform:none;letter-spacing:0;font-weight:600;} + .composer-terminal-resize-handle{height:12px;display:flex;align-items:center;justify-content:center;flex:0 0 auto;cursor:ns-resize;touch-action:none;background:linear-gradient(to bottom,rgba(255,255,255,.04),transparent);} + .composer-terminal-resize-handle::before{content:"";width:52px;height:4px;border-radius:999px;background:var(--border2);opacity:.72;transition:opacity .15s,background .15s;} + .composer-terminal-resize-handle:hover::before,.composer-terminal-resize-handle:focus-visible::before{opacity:1;background:var(--muted);} + .composer-terminal-inner.is-resizing{transition:none;user-select:none;} .composer-terminal-header{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 10px;border-bottom:1px solid var(--border);background:rgba(255,255,255,.025);} .composer-terminal-title{min-width:0;display:flex;align-items:center;gap:6px;color:var(--text);font-size:12px;font-weight:700;letter-spacing:.02em;text-transform:uppercase;} .composer-terminal-dot{color:var(--muted);font-weight:400;} @@ -972,7 +985,11 @@ .ctx-indicator{width:32px;height:32px;} .ctx-tooltip{right:-4px;min-width:190px;max-width:220px;} .composer-terminal-panel{width:calc(100% - 20px);} - .composer-terminal-inner{height:190px;min-height:140px;border-radius:12px;padding-bottom:28px;} + .composer-terminal-panel.is-collapsed{bottom:-1px;width:calc(100% - 28px);} + .composer-terminal-inner{height:var(--composer-terminal-height,190px);min-height:140px;max-height:min(300px,44vh);border-radius:12px;padding-bottom:28px;} + .composer-terminal-dock{min-height:40px;padding:6px 7px 6px 10px;border-radius:12px;gap:8px;} + .composer-terminal-dock-title{font-size:11px;} + .composer-terminal-resize-handle{height:10px;cursor:default;} .composer-terminal-header{padding:7px 8px;} .composer-terminal-actions{gap:2px;overflow-x:auto;} .composer-terminal-action{padding:5px 7px;font-size:10px;white-space:nowrap;} diff --git a/static/terminal.js b/static/terminal.js index 30b7abb0..f2ae5b07 100644 --- a/static/terminal.js +++ b/static/terminal.js @@ -28,6 +28,7 @@ function _terminalEls(){ return { panel:$('composerTerminalPanel'), inner:$('composerTerminalPanel')&&$('composerTerminalPanel').querySelector('.composer-terminal-inner'), + dock:$('composerTerminalDock'), viewport:$('terminalViewport'), surface:$('terminalSurface'), toggle:$('btnTerminalToggle'), @@ -201,7 +202,7 @@ function _applyTerminalHeight(height){ handle.setAttribute('aria-valuemax',String(bounds.max)); handle.setAttribute('aria-valuenow',String(next)); } - if(TERMINAL_UI.open){ + if(TERMINAL_UI.open&&!TERMINAL_UI.collapsed){ _fitTerminal(); _syncTerminalTranscriptSpace(true); } @@ -280,7 +281,8 @@ function _terminalIsMessagesNearBottom(el){ return el.scrollHeight-el.scrollTop-el.clientHeight<150; } -function _syncTerminalTranscriptSpace(open){ +function _syncTerminalTranscriptSpace(open,opts){ + opts=opts||{}; const messages=_terminalMessagesEl(); if(!messages)return; const wasNearBottom=_terminalIsMessagesNearBottom(messages); @@ -289,17 +291,29 @@ function _syncTerminalTranscriptSpace(open){ messages.classList.remove('terminal-collapsed'); messages.classList.remove('terminal-expanding-from-dock'); messages.style.removeProperty('--terminal-card-height'); + messages.style.removeProperty('--terminal-dock-height'); if(wasNearBottom&&typeof scrollToBottom==='function')requestAnimationFrame(scrollToBottom); return; } - messages.classList.add('terminal-open'); + if(open==='collapsed'){ + messages.classList.remove('terminal-open'); + messages.classList.add('terminal-collapsed'); + }else{ + messages.classList.add('terminal-open'); + messages.classList.remove('terminal-collapsed'); + } const measure=()=>{ if(!TERMINAL_UI.open)return; - const {panel,inner}= _terminalEls(); - const h=(inner||panel)&&((inner||panel).getBoundingClientRect().height); - if(h>0)messages.style.setProperty('--terminal-card-height',Math.ceil(h+24)+'px'); + const {panel,inner,dock}= _terminalEls(); + const target=open==='collapsed'?(dock||panel):(inner||panel); + const h=target&&target.getBoundingClientRect().height; + if(h>0){ + if(open==='collapsed')messages.style.setProperty('--terminal-dock-height',Math.ceil(h+24)+'px'); + else messages.style.setProperty('--terminal-card-height',Math.ceil(h+24)+'px'); + } if(wasNearBottom&&typeof scrollToBottom==='function')scrollToBottom(); }; + if(opts.immediate)measure(); requestAnimationFrame(measure); setTimeout(measure,420); } @@ -317,9 +331,11 @@ function _fitTerminal(){ function _setTerminalChromeState(state){ const {panel,inner,dock,workspace,dockWorkspace}= _terminalEls(); + const composerWrap=$('composerWrap'); if(!panel)return; const collapsed=state==='collapsed'; const expanded=state==='expanded'; + if(composerWrap)composerWrap.classList.toggle('terminal-dock-visible',collapsed); panel.hidden=!(collapsed||expanded); panel.classList.toggle('is-open',expanded); panel.classList.toggle('is-collapsed',collapsed); @@ -412,10 +428,15 @@ async function _startComposerTerminal(restart=false){ async function toggleComposerTerminal(force){ const next=typeof force==='boolean'?force:!TERMINAL_UI.open; if(next){ - const {panel,inner,workspace}= _terminalEls(); + if(TERMINAL_UI.open){ + if(TERMINAL_UI.collapsed)expandComposerTerminal(); + else focusComposerTerminalInput(); + return; + } + const {panel,inner}= _terminalEls(); + const messages=_terminalMessagesEl(); if(!panel)return; clearTimeout(TERMINAL_UI.closeTimer); - panel.hidden=false; _initTerminalResizeHandle(); _resetTerminalHeightForViewport(); if(messages)messages.classList.add('terminal-expanding-from-dock'); @@ -467,7 +488,6 @@ function expandComposerTerminal(){ if(messages)void messages.offsetHeight; _setTerminalChromeState('expanded'); _resetTerminalHeightForViewport(); - _syncTerminalTranscriptSpace(true); requestAnimationFrame(()=>{ _fitTerminal(); focusComposerTerminalInput(); @@ -502,7 +522,7 @@ async function closeComposerTerminal(sessionId,opts){ } const {panel}= _terminalEls(); if(panel){ - panel.classList.remove('is-open'); + panel.classList.remove('is-open','is-collapsed','is-expanding-from-dock'); _syncTerminalTranscriptSpace(false); clearTimeout(TERMINAL_UI.closeTimer); TERMINAL_UI.closeTimer=setTimeout(()=>{ @@ -515,6 +535,8 @@ async function closeComposerTerminal(sessionId,opts){ } TERMINAL_UI.open=false; TERMINAL_UI.collapsed=false; + const composerWrap=$('composerWrap'); + if(composerWrap)composerWrap.classList.remove('terminal-dock-visible'); TERMINAL_UI.sessionId=null; TERMINAL_UI.workspace=null; syncTerminalButton(); @@ -595,6 +617,10 @@ window.addEventListener('beforeunload',()=>{ window.addEventListener('resize',()=>{ if(!TERMINAL_UI.open)return; + if(TERMINAL_UI.collapsed){ + _syncTerminalTranscriptSpace('collapsed'); + return; + } _resetTerminalHeightForViewport(); }); diff --git a/tests/test_embedded_workspace_terminal.py b/tests/test_embedded_workspace_terminal.py index 830de17e..32d8c4ea 100644 --- a/tests/test_embedded_workspace_terminal.py +++ b/tests/test_embedded_workspace_terminal.py @@ -33,14 +33,129 @@ def test_terminal_surface_uses_composer_flyout_card_pattern(): flyout = html.split('
', 1)[1].split('
', 1)[0] assert 'id="composerTerminalPanel"' in flyout assert 'class="composer-terminal-inner"' in flyout + assert 'id="composerTerminalDock"' in flyout + assert 'id="terminalResizeHandle"' in flyout assert 'id="composerTerminalPanel"' not in html.split('
', 1)[1] assert ".composer-terminal-panel{position:absolute" in style_css assert "bottom:-24px" in style_css assert "width:min(calc(100% - 64px),720px)" in style_css - assert ".composer-terminal-inner{height:260px" in style_css + assert ".composer-wrap.terminal-dock-visible .composer-flyout{z-index:4" in style_css + assert ".composer-terminal-panel.is-collapsed{bottom:-2px;width:min(calc(100% - 112px),560px);overflow:visible;z-index:4" in style_css + assert ".composer-terminal-panel.is-expanding-from-dock .composer-terminal-inner{transition:opacity .18s ease" in style_css + assert ".messages.terminal-expanding-from-dock{transition:none!important" in style_css + assert ".composer-terminal-dock{min-height:42px" in style_css + assert ".composer-terminal-inner{height:var(--composer-terminal-height,260px)" in style_css assert "transform:translateY(100%)" in style_css +def test_terminal_uses_controlled_desktop_resize_handle(): + html = _read("static/index.html") + style_css = _read("static/style.css") + terminal_js = _read("static/terminal.js") + + assert 'class="composer-terminal-resize-handle"' in html + assert 'role="separator"' in html + assert 'aria-orientation="horizontal"' in html + terminal_inner_rule = style_css.split(".composer-terminal-inner{", 1)[1].split("}", 1)[0] + assert "resize:" not in terminal_inner_rule + assert "cursor:ns-resize" in style_css + assert "const TERMINAL_HEIGHT_DEFAULT=260" in terminal_js + assert "const TERMINAL_HEIGHT_MIN=180" in terminal_js + assert "const TERMINAL_HEIGHT_MAX=520" in terminal_js + assert "max:Math.max(min,Math.min(hardMax,maxByViewport))" in terminal_js + + +def test_terminal_resize_path_refits_backend_and_transcript_space(): + terminal_js = _read("static/terminal.js") + + assert "function _applyTerminalHeight" in terminal_js + apply_block = terminal_js.split("function _applyTerminalHeight", 1)[1].split("function _resetTerminalHeightForViewport", 1)[0] + assert "_fitTerminal();" in apply_block + assert "_syncTerminalTranscriptSpace(true);" in apply_block + assert "function _moveTerminalHeightResize" in terminal_js + assert "_applyTerminalHeight(TERMINAL_UI.resizeStartHeight+(TERMINAL_UI.resizeStartY-ev.clientY))" in terminal_js + assert "handle.addEventListener('pointerdown',_startTerminalHeightResize)" in terminal_js + assert "handle.addEventListener('pointermove',_moveTerminalHeightResize)" in terminal_js + assert "clearTimeout(TERMINAL_UI.resizeTimer)" in terminal_js + assert "api('/api/terminal/resize'" in terminal_js + + +def test_terminal_open_reserves_transcript_space(): + style_css = _read("static/style.css") + terminal_js = _read("static/terminal.js") + + assert ".messages.terminal-open{padding-bottom:var(--terminal-card-height" in style_css + assert ".messages.terminal-collapsed{padding-bottom:var(--terminal-dock-height" in style_css + assert "scroll-padding-bottom:var(--terminal-card-height" in style_css + assert "classList.add('terminal-open')" in terminal_js + assert "classList.add('terminal-collapsed')" in terminal_js + assert "classList.remove('terminal-open')" in terminal_js + assert "classList.remove('terminal-collapsed')" in terminal_js + assert "messages.style.setProperty('--terminal-card-height'" in terminal_js + assert "messages.style.setProperty('--terminal-dock-height'" in terminal_js + assert "messages.style.removeProperty('--terminal-card-height')" in terminal_js + assert "messages.style.removeProperty('--terminal-dock-height')" in terminal_js + assert "function _terminalIsMessagesNearBottom" in terminal_js + assert "scrollToBottom" in terminal_js + + +def test_terminal_initial_open_settles_transcript_space_before_reveal(): + terminal_js = _read("static/terminal.js") + + open_block = terminal_js.split("async function toggleComposerTerminal", 1)[1].split("function collapseComposerTerminal", 1)[0] + assert "messages.classList.add('terminal-expanding-from-dock')" in open_block + assert "_syncTerminalTranscriptSpace(true,{immediate:true});" in open_block + assert "void messages.offsetHeight;" in open_block + assert "panel.classList.add('is-open')" in open_block + assert "messages.classList.remove('terminal-expanding-from-dock')" in open_block + assert open_block.index("_syncTerminalTranscriptSpace(true,{immediate:true});") < open_block.index("panel.classList.add('is-open')") + assert open_block.index("void messages.offsetHeight;") < open_block.index("panel.classList.add('is-open')") + + +def test_terminal_collapsed_state_preserves_pty_and_output_surface(): + html = _read("static/index.html") + terminal_js = _read("static/terminal.js") + + assert 'id="btnTerminalCollapse"' in html + assert 'onclick="collapseComposerTerminal()"' in html + assert 'id="btnTerminalExpand"' in html + assert 'onclick="expandComposerTerminal()"' in html + assert 'id="btnTerminalDockClose"' in html + assert 'onclick="closeComposerTerminal()"' in html + assert "collapsed:false" in terminal_js + collapse_block = terminal_js.split("function collapseComposerTerminal", 1)[1].split("function expandComposerTerminal", 1)[0] + assert "api('/api/terminal/close'" not in collapse_block + assert "_disposeXterm" not in collapse_block + assert "_setTerminalChromeState('collapsed')" in collapse_block + assert "composerWrap.classList.toggle('terminal-dock-visible',collapsed)" in terminal_js + expand_block = terminal_js.split("function expandComposerTerminal", 1)[1].split("function _disposeXterm", 1)[0] + assert "_setTerminalChromeState('expanded')" in expand_block + assert "panel.classList.add('is-expanding-from-dock')" in expand_block + assert "panel.classList.remove('is-expanding-from-dock')" in expand_block + assert "messages.classList.add('terminal-expanding-from-dock')" in expand_block + assert "messages.classList.remove('terminal-expanding-from-dock')" in expand_block + assert "_syncTerminalTranscriptSpace(true,{immediate:true});" in expand_block + assert "void messages.offsetHeight;" in expand_block + assert expand_block.index("_syncTerminalTranscriptSpace(true,{immediate:true});") < expand_block.index("_setTerminalChromeState('expanded')") + assert expand_block.index("void messages.offsetHeight;") < expand_block.index("_setTerminalChromeState('expanded')") + assert "_resetTerminalHeightForViewport();" in expand_block + assert "focusComposerTerminalInput();" in expand_block + close_block = terminal_js.split("async function closeComposerTerminal", 1)[1].split("async function restartComposerTerminal", 1)[0] + assert "api('/api/terminal/close'" in close_block + assert "_disposeXterm();" in close_block + + +def test_terminal_slash_command_expands_existing_collapsed_terminal(): + commands_js = _read("static/commands.js") + terminal_js = _read("static/terminal.js") + + assert "await toggleComposerTerminal(true)" in commands_js + toggle_block = terminal_js.split("async function toggleComposerTerminal", 1)[1].split("function collapseComposerTerminal", 1)[0] + assert "if(TERMINAL_UI.open)" in toggle_block + assert "if(TERMINAL_UI.collapsed)expandComposerTerminal();" in toggle_block + assert "else focusComposerTerminalInput();" in toggle_block + + def test_terminal_v1_does_not_expose_send_to_chat_action(): html = _read("static/index.html") terminal_js = _read("static/terminal.js")