From 56e8e7324496e29c533c6a19c934347c7b962c21 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 27 Apr 2026 19:30:05 -0700 Subject: [PATCH] fix(dashboard): resizable terminal + agent detail modals, no resize-drag dismiss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Terminal modal and agent detail view are now resizable via the native CSS grip with sizes persisted per-modal in localStorage. Mobile keeps fullscreen layout (resize disabled, !important overrides any persisted desktop dimensions). Both modals previously dismissed when a drag-resize started inside the modal but released over the overlay — the synthesised click event targets the common ancestor (overlay), tripping the e.target === e.currentTarget dismiss check. Switched to mousedown→mouseup tracking so dismiss only fires when both events land on the overlay. Terminal additionally observes its own pixel box via ResizeObserver and refits xterm on every grip drag — `resize: both` doesn't emit window resize. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../app/components/AgentDetailView.css | 24 +++++++-- .../app/components/AgentDetailView.tsx | 17 +++++- .../app/components/TerminalModal.css | 26 ++++++--- .../app/components/TerminalModal.tsx | 54 +++++++++++++++++-- .../__tests__/TerminalModal.test.tsx | 19 ++++++- 5 files changed, 122 insertions(+), 18 deletions(-) diff --git a/packages/dashboard/app/components/AgentDetailView.css b/packages/dashboard/app/components/AgentDetailView.css index 04575f185..43e2fbedb 100644 --- a/packages/dashboard/app/components/AgentDetailView.css +++ b/packages/dashboard/app/components/AgentDetailView.css @@ -16,14 +16,25 @@ border: 1px solid var(--border); border-radius: var(--radius-lg); width: 100%; - max-width: 900px; - max-height: 85vh; + min-width: 480px; + max-width: calc(100vw - 40px); + min-height: 320px; + max-height: calc(100dvh - 40px); display: flex; flex-direction: column; overflow: hidden; + resize: both; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); } +/* Initial size — applied only when no persisted size has been restored. */ +.agent-detail-modal:not([style*="width"]) { + width: min(900px, calc(100vw - 40px)); +} +.agent-detail-modal:not([style*="height"]) { + height: min(85vh, calc(100dvh - 40px)); +} + .agent-detail-loading { display: flex; flex-direction: column; @@ -978,14 +989,17 @@ } .agent-detail-modal { - width: 100vw; - height: 100vh; - height: 100dvh; + width: 100vw !important; + height: 100vh !important; + height: 100dvh !important; max-width: 100%; max-height: 100vh; max-height: 100dvh; border-radius: 0; border: none; + /* Disable user resize on mobile — modal is fullscreen. The !important on + width/height above also overrides any persisted desktop dimensions. */ + resize: none; } .agent-detail-header { diff --git a/packages/dashboard/app/components/AgentDetailView.tsx b/packages/dashboard/app/components/AgentDetailView.tsx index c902f47f0..ee93a2d33 100644 --- a/packages/dashboard/app/components/AgentDetailView.tsx +++ b/packages/dashboard/app/components/AgentDetailView.tsx @@ -22,6 +22,7 @@ import { subscribeSse } from "../sse-bus"; import { DEFAULT_HEARTBEAT_INTERVAL_MS, formatHeartbeatInterval, resolveHeartbeatIntervalMs } from "../utils/heartbeatIntervals"; import { CustomModelDropdown } from "./CustomModelDropdown"; import { useConfirm } from "../hooks/useConfirm"; +import { useModalResizePersist } from "../hooks/useModalResizePersist"; /** * Simple className utility - joins class names conditionally @@ -126,6 +127,9 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild const [isStreaming, setIsStreaming] = useState(false); const [isTransitioning, setIsTransitioning] = useState(false); const logContainerRef = useRef(null); + const agentDetailModalRef = useRef(null); + const overlayMouseDownRef = useRef(false); + useModalResizePersist(agentDetailModalRef, true, "fusion:agent-detail-modal-size"); const onCloseRef = useRef(onClose); const addToastRef = useRef(addToast); const agentRef = useRef(null); @@ -370,8 +374,17 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild if (isLoading) { return ( -
e.target === e.currentTarget && onClose()} role="dialog" aria-modal="true"> -
+
{ if (e.target === e.currentTarget) overlayMouseDownRef.current = true; }} + onMouseUp={(e) => { + if (overlayMouseDownRef.current && e.target === e.currentTarget) onClose(); + overlayMouseDownRef.current = false; + }} + role="dialog" + aria-modal="true" + > +
Loading agent... diff --git a/packages/dashboard/app/components/TerminalModal.css b/packages/dashboard/app/components/TerminalModal.css index 2a7c97a0b..3b33b0919 100644 --- a/packages/dashboard/app/components/TerminalModal.css +++ b/packages/dashboard/app/components/TerminalModal.css @@ -7,13 +7,24 @@ } .modal.terminal-modal { - width: min(1800px, calc(100vw - (var(--space-xl) * 2))); + /* Initial dimensions are applied only when no persisted size has been + restored — see :not([style*=...]) selectors below. */ + min-width: 480px; + min-height: 320px; max-width: calc(100vw - (var(--space-xl) * 2)); - min-height: 80vh; - max-height: 85vh; + max-height: calc(100dvh - 40px); background: var(--card); display: flex; flex-direction: column; + overflow: hidden; + resize: both; +} + +.modal.terminal-modal:not([style*="width"]) { + width: min(1800px, calc(100vw - (var(--space-xl) * 2))); +} +.modal.terminal-modal:not([style*="height"]) { + height: min(85vh, calc(100dvh - 40px)); } .terminal-header { @@ -828,16 +839,19 @@ /* === Terminal Modal Mobile Responsive === */ @media (max-width: 768px) { .modal.terminal-modal { - width: 100%; + width: 100% !important; max-width: 100%; min-height: 100vh; min-height: 100dvh; - height: 100vh; - height: 100dvh; + height: 100vh !important; + height: 100dvh !important; max-height: 100vh; max-height: 100dvh; border-radius: 0; border: none; + /* Disable user resize on mobile — fullscreen layout. !important above + overrides any persisted desktop dimensions. */ + resize: none; } /* Stack tabs and actions on separate rows */ diff --git a/packages/dashboard/app/components/TerminalModal.tsx b/packages/dashboard/app/components/TerminalModal.tsx index 7e24b260d..219dce3a9 100644 --- a/packages/dashboard/app/components/TerminalModal.tsx +++ b/packages/dashboard/app/components/TerminalModal.tsx @@ -12,6 +12,7 @@ import { } from "lucide-react"; import { useTerminal } from "../hooks/useTerminal"; import { useTerminalSessions } from "../hooks/useTerminalSessions"; +import { useModalResizePersist } from "../hooks/useModalResizePersist"; import "@xterm/xterm/css/xterm.css"; import type { Terminal as XTerm, ITerminalAddon } from "@xterm/xterm"; @@ -238,6 +239,8 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te const terminalRef = useRef(null); const modalRef = useRef(null); + const overlayMouseDownRef = useRef(false); + useModalResizePersist(modalRef, isOpen, "fusion:terminal-modal-size"); const xtermRef = useRef(null); const fitAddonRef = useRef(null); const hasInitialCommandRun = useRef(false); @@ -375,6 +378,35 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te }; }, [fitAndResizeForSession, isOpen]); + // Refit xterm whenever the user drags the modal's CSS resize grip. + // The window/visualViewport listeners only fire on viewport changes; native + // `resize: both` does NOT emit window resize, so we observe the modal node + // directly and ask xterm to refit to the new pixel box. + useEffect(() => { + if (!isOpen) return; + const node = modalRef.current; + if (!node || typeof ResizeObserver === "undefined") return; + + let pendingFrame: number | null = null; + const observer = new ResizeObserver(() => { + if (pendingFrame !== null) cancelAnimationFrame(pendingFrame); + pendingFrame = requestAnimationFrame(() => { + pendingFrame = null; + const sessionId = + typeof xtermInitializedRef.current === "string" + ? xtermInitializedRef.current + : undefined; + fitAndResizeForSession(sessionId); + }); + }); + observer.observe(node); + + return () => { + observer.disconnect(); + if (pendingFrame !== null) cancelAnimationFrame(pendingFrame); + }; + }, [fitAndResizeForSession, isOpen]); + // Use the session management hook const { tabs, @@ -940,10 +972,23 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te return unsub; }, [onSessionInvalid, replaceActiveTabSession]); - // Handle overlay click to close - const handleOverlayClick = useCallback( + // Overlay dismiss — track mousedown source so a click that starts on the + // modal but releases on the overlay (e.g. when dragging the resize grip + // beyond the modal's edge) does NOT dismiss. Native CSS `resize: both` + // would otherwise let a resize-drag end on the overlay and synthesise a + // click event whose target is the overlay. + const handleOverlayMouseDown = useCallback( (e: React.MouseEvent) => { - if (e.target === e.currentTarget) onClose(); + if (e.target === e.currentTarget) overlayMouseDownRef.current = true; + }, + [] + ); + const handleOverlayMouseUp = useCallback( + (e: React.MouseEvent) => { + if (overlayMouseDownRef.current && e.target === e.currentTarget) { + onClose(); + } + overlayMouseDownRef.current = false; }, [onClose] ); @@ -1054,7 +1099,8 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te return (
{ await waitFor(() => { const overlay = screen.getByTestId("terminal-modal-overlay"); - fireEvent.click(overlay); + // Overlay dismiss is wired via mousedown→mouseup so a resize-drag that + // ends on the overlay (after starting inside the modal) doesn't close. + // A real click on the overlay fires both events on the overlay. + fireEvent.mouseDown(overlay); + fireEvent.mouseUp(overlay); }); expect(mockOnClose).toHaveBeenCalled(); }); + it("does NOT close when mousedown is on the modal but mouseup is on the overlay (resize drag)", async () => { + render(); + + await waitFor(() => { + const overlay = screen.getByTestId("terminal-modal-overlay"); + const modal = screen.getByTestId("terminal-modal"); + fireEvent.mouseDown(modal); + fireEvent.mouseUp(overlay); + }); + + expect(mockOnClose).not.toHaveBeenCalled(); + }); + it("shows reconnect button when disconnected", async () => { mockUseTerminal.mockReturnValue( createMockTerminalState({