From 2a28967133ae749ef9a52c2599ffb240860af40a Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 04:50:43 -0700 Subject: [PATCH 01/11] fix(dashboard): smooth terminal drag/resize; task-detail tweaks + popup fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Terminal move/resize: pointer-capture + captured-element listeners + rAF-batched updates + touch-action:none on the move grip; stop per-move localStorage writes. Matches the dock pop-out smoothness. - Task detail: Summarize-as-title inline at the title's bottom-right; priority + speed controls shorter and equal height (30px); trimmed the gray id-header vertical padding. - Fix footer Actions/Move dropdowns that vanished — they opened downward off the panel bottom (clipped by overflow); now open upward (above the trigger). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../app/components/TaskDetailModal.css | 46 +++++- .../app/components/TaskDetailModal.tsx | 4 + .../app/components/TerminalModal.css | 5 + .../app/components/TerminalModal.tsx | 156 +++++++++++++----- .../__tests__/TerminalModal.test.tsx | 23 ++- 5 files changed, 173 insertions(+), 61 deletions(-) diff --git a/packages/dashboard/app/components/TaskDetailModal.css b/packages/dashboard/app/components/TaskDetailModal.css index 5cfa9e3ae3..02e5d8e2da 100644 --- a/packages/dashboard/app/components/TaskDetailModal.css +++ b/packages/dashboard/app/components/TaskDetailModal.css @@ -17,6 +17,14 @@ resize: both; } +/* +FNXC:TaskDetail 2026-06-22-20:00: +The gray top header band (task id + column badge) was over-padded. Trim its vertical padding for a more compact band, scoped to the task-detail header so the shared global .modal-header (used by other modals) is unaffected. Keep horizontal padding from --modal-padding; only the block padding shrinks. +*/ +.task-detail-content > .modal-header { + padding-block: var(--space-sm); +} + .detail-title-row { display: flex; align-items: center; @@ -121,16 +129,22 @@ overflow: hidden; } +/* +FNXC:TaskDetail 2026-06-22-20:00: +Summarize-as-title is an in-field affordance, not a separate full-width row: it sits inline with the title, pinned to the far right and bottom of the title area. Use a nowrap flex row where the title flexes to fill and the button is pushed right (margin-left:auto) and bottom-aligned (align-self:flex-end). The button shrinks to its content so it never steals title space. +*/ .detail-heading-row { display: flex; - align-items: baseline; - flex-wrap: wrap; + align-items: flex-end; + flex-wrap: nowrap; gap: var(--space-sm); margin-bottom: var(--space-md); } .detail-heading-row .detail-title { margin-bottom: 0; + flex: 1 1 auto; + min-width: 0; } .detail-summarize-title-btn { @@ -143,7 +157,11 @@ font-size: 0.8125rem; padding: 0; cursor: pointer; - text-align: left; + text-align: right; + margin-left: auto; + align-self: flex-end; + flex: 0 0 auto; + white-space: nowrap; } .detail-summarize-title-btn:hover:not(:disabled) { @@ -257,8 +275,12 @@ The task-detail modal metadata must keep priority, execution mode, provenance, P } @media (max-width: 768px) { + /* + FNXC:TaskDetail 2026-06-22-20:00: + Keep summarize-as-title pinned bottom-right inline with the title on mobile too (no wrap to a separate row), matching the desktop in-field affordance. + */ .detail-heading-row { - align-items: flex-start; + align-items: flex-end; } .detail-summarize-title-btn { @@ -291,7 +313,11 @@ The task-detail modal metadata must keep priority, execution mode, provenance, P } .detail-meta-inline-controls { - --detail-priority-control-min-height: calc(var(--space-2xl) + var(--space-xs)); + /* + FNXC:TaskDetail 2026-06-22-20:00: + Priority chip and speed (execution-mode) toggle share one min-height token so they render at identical, equal height. Reduced from the old calc(space-2xl + space-xs) (~too tall) to a compact 30px that stays legible and tappable. Both controls also get trimmed vertical padding to match. + */ + --detail-priority-control-min-height: 30px; display: flex; align-items: stretch; @@ -302,6 +328,7 @@ The task-detail modal metadata must keep priority, execution mode, provenance, P .detail-priority-chip { gap: var(--space-xs); min-height: var(--detail-priority-control-min-height); + padding-block: var(--space-xs); box-sizing: border-box; } @@ -342,6 +369,7 @@ The task-detail modal metadata must keep priority, execution mode, provenance, P align-items: center; gap: var(--space-xs); min-height: var(--detail-priority-control-min-height); + padding-block: var(--space-xs); box-sizing: border-box; } @@ -1115,10 +1143,14 @@ FNXC:TaskDetail 2026-06-22-18:40: background: var(--card-hover); } +/* +FNXC:TaskDetail 2026-06-22-20:15: +The footer Actions/Move dropdown buttons sit at the BOTTOM of the embedded panel, so the menus must open UPWARD (above the button). The earlier embedded rule opened them downward (top:100%), which dropped the menu off the panel bottom where the body's overflow clipped it — the popups appeared to vanish. Anchor to bottom:100% so they always open above the trigger and stay on-screen. +*/ .task-detail-content--embedded .detail-actions-menu, .task-detail-content--embedded .detail-move-menu { - top: calc(100% + var(--space-xs)); - bottom: auto; + bottom: calc(100% + var(--space-xs)); + top: auto; } .detail-refine-title { diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index 757b94dd54..0f7cc9cc8b 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -2877,6 +2877,10 @@ export function TaskDetailContent({ ) : ( <> <> + {/* + FNXC:TaskDetail 2026-06-22-20:00: + Summarize-as-title renders inline with the title inside .detail-heading-row and is positioned (CSS) to the far bottom-right as an in-field affordance, not a separate full-width row. Markup order is preserved; only layout changed. + */}

(null); /* FNXC:Terminal 2026-06-22-09:00: - Docked-resize, floating-drag, and floating-resize each attach document pointer listeners (and docked schedules a rAF) for the duration of a drag. If the modal closes or the component unmounts mid-drag, those listeners + the pending frame would leak. Track the active drag teardown here and run it from the close/unmount effect. + Docked-resize, floating-drag, and floating-resize each attach pointer listeners and schedule a rAF for the duration of a drag. If the modal closes or the component unmounts mid-drag, those listeners + the pending frame would leak. Track the active drag teardown here and run it from the close/unmount effect. + + FNXC:Terminal 2026-06-22-19:50: + All three families now capture the pointer and attach listeners to the CAPTURED handle element (not `document`), so the teardown also releasePointerCapture()s; the close/unmount effect still drives it through this single ref. */ const dragTeardownRef = useRef<(() => void) | null>(null); /** Tracks the previous projectId to detect project switches and invalidate xterm. */ @@ -489,22 +492,26 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG FNXC:Terminal 2026-06-21-22:45: The pop-out terminal mode uses project-scoped `fusion:terminal-modal-size-${projectId}` and `fusion:terminal-float-pos-${projectId}` keys so floating windows restore independently per project while avoiding the old bottom-right native resize grip conflict. */ + /* + FNXC:Terminal 2026-06-22-19:50: + Docked top-edge resize, smooth on touch + desktop (same technique as the right-dock pop-out RightDockExpandModal). On pointerdown we setPointerCapture on the handle and attach pointermove/up/cancel to the CAPTURED element (`captureTarget` = event.currentTarget), NOT `document` — capture redirects the full pointer stream for this pointerId to that element so element-scoped listeners receive every move even when the finger drifts off the handle, and they pair cleanly with the handle's `touch-action: none` (CSS) without a non-passive document listener. Moves are filtered by pointerId and coalesced into one rAF, so we set height at most once per frame and never thrash layout on a flood of touch-move events. localStorage is written only on pointerup (existing behavior). Teardown (pointerup/cancel + unmount via dragTeardownRef) cancels the pending rAF, releases pointer capture, and detaches listeners. + */ const handleDockedResizePointerDown = useCallback((event: ReactPointerEvent) => { if (!isDockedMode) return; event.preventDefault(); - event.currentTarget.setPointerCapture(event.pointerId); + const captureTarget = event.currentTarget; + const pointerId = event.pointerId; + captureTarget.setPointerCapture?.(pointerId); const startY = event.clientY; const startHeight = dockedHeight; const previousUserSelect = document.body.style.userSelect; document.body.style.userSelect = "none"; - /* - FNXC:Terminal 2026-06-22-01:30: - Smooth docked resize: batch height state to one update per animation frame during the drag and write localStorage only once on pointer-up, instead of a synchronous clamp + localStorage write on every pointermove (which janked the drag). - */ + let latestHeight = startHeight; let frame = 0; const handlePointerMove = (moveEvent: PointerEvent) => { + if (moveEvent.pointerId !== pointerId) return; latestHeight = clampTerminalDockedHeight(startHeight + (startY - moveEvent.clientY)); if (frame) return; frame = requestAnimationFrame(() => { @@ -512,64 +519,100 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG setDockedHeight(latestHeight); }); }; - const handlePointerUp = () => { + const detachListeners = () => { + captureTarget.releasePointerCapture?.(pointerId); + captureTarget.removeEventListener("pointermove", handlePointerMove); + captureTarget.removeEventListener("pointerup", handlePointerUp); + captureTarget.removeEventListener("pointercancel", handlePointerUp); + }; + function handlePointerUp() { if (frame) cancelAnimationFrame(frame); setDockedHeight(writeTerminalDockedHeight(latestHeight, projectId)); document.body.style.userSelect = previousUserSelect; - document.removeEventListener("pointermove", handlePointerMove); - document.removeEventListener("pointerup", handlePointerUp); - document.removeEventListener("pointercancel", handlePointerUp); + detachListeners(); dragTeardownRef.current = null; - }; + } - // FNXC:Terminal 2026-06-22-09:00: Unmount/close-mid-drag teardown cancels the pending rAF and removes the document listeners without persisting a partial drag. + // FNXC:Terminal 2026-06-22-19:50: Unmount/close-mid-drag teardown cancels the pending rAF, releases pointer capture, and detaches the captured-element listeners without persisting a partial drag. dragTeardownRef.current = () => { if (frame) cancelAnimationFrame(frame); document.body.style.userSelect = previousUserSelect; - document.removeEventListener("pointermove", handlePointerMove); - document.removeEventListener("pointerup", handlePointerUp); - document.removeEventListener("pointercancel", handlePointerUp); + detachListeners(); dragTeardownRef.current = null; }; - document.addEventListener("pointermove", handlePointerMove); - document.addEventListener("pointerup", handlePointerUp); - document.addEventListener("pointercancel", handlePointerUp); + captureTarget.addEventListener("pointermove", handlePointerMove); + captureTarget.addEventListener("pointerup", handlePointerUp); + captureTarget.addEventListener("pointercancel", handlePointerUp); }, [dockedHeight, isDockedMode, projectId]); + /* + FNXC:Terminal 2026-06-22-19:50: + Floating-window move (drag the header grip), smooth on touch + desktop. Pointer capture + captured-element (`captureTarget`) listeners filtered by pointerId, identical to the right-dock pop-out drag. Raw pointer coords are stored in `latest` and applied via one rAF per frame, so a flood of touch-move events coalesces into a single state set and never thrashes layout. State-only updates during the drag; localStorage is persisted once on pointerup (the old per-move persistFloatingPosition wrote localStorage on every move, which janked touch drags). Teardown cancels the rAF, releases capture, and detaches listeners on pointerup/cancel and on unmount. + */ const handleFloatingDragPointerDown = useCallback((event: ReactPointerEvent) => { if (!isFloatingMode || (event.target as HTMLElement).closest("button")) return; event.preventDefault(); - event.currentTarget.setPointerCapture(event.pointerId); + const captureTarget = event.currentTarget; + const pointerId = event.pointerId; + captureTarget.setPointerCapture?.(pointerId); const startX = event.clientX; const startY = event.clientY; const startPosition = floatingPosition; + const currentSize = floatingSize; const previousUserSelect = document.body.style.userSelect; document.body.style.userSelect = "none"; + let latest = startPosition; + let frame = 0; + const handlePointerMove = (moveEvent: PointerEvent) => { - persistFloatingPosition({ x: startPosition.x + moveEvent.clientX - startX, y: startPosition.y + moveEvent.clientY - startY }); + if (moveEvent.pointerId !== pointerId) return; + latest = { x: startPosition.x + moveEvent.clientX - startX, y: startPosition.y + moveEvent.clientY - startY }; + if (frame) return; + frame = requestAnimationFrame(() => { + frame = 0; + setFloatingPosition(clampTerminalFloatPosition(latest, currentSize)); + }); }; - const handlePointerUp = () => { + const detachListeners = () => { + captureTarget.releasePointerCapture?.(pointerId); + captureTarget.removeEventListener("pointermove", handlePointerMove); + captureTarget.removeEventListener("pointerup", handlePointerUp); + captureTarget.removeEventListener("pointercancel", handlePointerUp); + }; + function handlePointerUp() { + if (frame) cancelAnimationFrame(frame); + persistFloatingPosition(latest, currentSize); document.body.style.userSelect = previousUserSelect; - document.removeEventListener("pointermove", handlePointerMove); - document.removeEventListener("pointerup", handlePointerUp); - document.removeEventListener("pointercancel", handlePointerUp); + detachListeners(); + dragTeardownRef.current = null; + } + + // FNXC:Terminal 2026-06-22-19:50: Unmount/close-mid-drag teardown cancels the rAF, releases capture, and detaches the captured-element listeners without persisting a partial move. + dragTeardownRef.current = () => { + if (frame) cancelAnimationFrame(frame); + document.body.style.userSelect = previousUserSelect; + detachListeners(); dragTeardownRef.current = null; }; - // FNXC:Terminal 2026-06-22-09:00: Unmount/close-mid-drag teardown removes the document listeners so a floating-drag never leaks them. - dragTeardownRef.current = handlePointerUp; - document.addEventListener("pointermove", handlePointerMove); - document.addEventListener("pointerup", handlePointerUp); - document.addEventListener("pointercancel", handlePointerUp); - }, [floatingPosition, isFloatingMode, persistFloatingPosition]); + captureTarget.addEventListener("pointermove", handlePointerMove); + captureTarget.addEventListener("pointerup", handlePointerUp); + captureTarget.addEventListener("pointercancel", handlePointerUp); + }, [floatingPosition, floatingSize, isFloatingMode, persistFloatingPosition]); + /* + FNXC:Terminal 2026-06-22-19:50: + Floating-window edge/corner resize, smooth on touch + desktop. Pointer capture + captured-element listeners filtered by pointerId, rAF-batched size/position updates (west/north handles also shift the origin so the opposite edge stays pinned), persisted once on pointerup — same discipline as the right-dock pop-out resize. The old per-move persistFloatingSize/persistFloatingPosition wrote localStorage on every move; now we set state per frame and persist only on release. Teardown cancels the rAF, releases capture, and detaches listeners on pointerup/cancel and on unmount. + */ const handleFloatingResizePointerDown = useCallback((event: ReactPointerEvent, direction: TerminalResizeDirection) => { if (!isFloatingMode) return; event.preventDefault(); event.stopPropagation(); - event.currentTarget.setPointerCapture(event.pointerId); + const captureTarget = event.currentTarget; + const pointerId = event.pointerId; + captureTarget.setPointerCapture?.(pointerId); const startX = event.clientX; const startY = event.clientY; const startSize = floatingSize; @@ -577,34 +620,57 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG const previousUserSelect = document.body.style.userSelect; document.body.style.userSelect = "none"; + let latestSize = startSize; + let latestPosition = startPosition; + let frame = 0; + const handlePointerMove = (moveEvent: PointerEvent) => { + if (moveEvent.pointerId !== pointerId) return; const dx = moveEvent.clientX - startX; const dy = moveEvent.clientY - startY; - const rawSize = { + const nextSize = clampTerminalFloatSize({ width: startSize.width + (direction.includes("e") ? dx : direction.includes("w") ? -dx : 0), height: startSize.height + (direction.includes("s") ? dy : direction.includes("n") ? -dy : 0), - }; - const nextSize = clampTerminalFloatSize(rawSize); + }); const nextPosition = { x: startPosition.x + (direction.includes("w") ? startSize.width - nextSize.width : 0), y: startPosition.y + (direction.includes("n") ? startSize.height - nextSize.height : 0), }; - persistFloatingSize(nextSize); - persistFloatingPosition(nextPosition, nextSize); + latestSize = nextSize; + latestPosition = nextPosition; + if (frame) return; + frame = requestAnimationFrame(() => { + frame = 0; + setFloatingSize(latestSize); + setFloatingPosition(clampTerminalFloatPosition(latestPosition, latestSize)); + }); }; - const handlePointerUp = () => { + const detachListeners = () => { + captureTarget.releasePointerCapture?.(pointerId); + captureTarget.removeEventListener("pointermove", handlePointerMove); + captureTarget.removeEventListener("pointerup", handlePointerUp); + captureTarget.removeEventListener("pointercancel", handlePointerUp); + }; + function handlePointerUp() { + if (frame) cancelAnimationFrame(frame); + persistFloatingSize(latestSize); + persistFloatingPosition(latestPosition, latestSize); document.body.style.userSelect = previousUserSelect; - document.removeEventListener("pointermove", handlePointerMove); - document.removeEventListener("pointerup", handlePointerUp); - document.removeEventListener("pointercancel", handlePointerUp); + detachListeners(); + dragTeardownRef.current = null; + } + + // FNXC:Terminal 2026-06-22-19:50: Unmount/close-mid-drag teardown cancels the rAF, releases capture, and detaches the captured-element listeners without persisting a partial resize. + dragTeardownRef.current = () => { + if (frame) cancelAnimationFrame(frame); + document.body.style.userSelect = previousUserSelect; + detachListeners(); dragTeardownRef.current = null; }; - // FNXC:Terminal 2026-06-22-09:00: Unmount/close-mid-drag teardown removes the document listeners so a floating-resize never leaks them. - dragTeardownRef.current = handlePointerUp; - document.addEventListener("pointermove", handlePointerMove); - document.addEventListener("pointerup", handlePointerUp); - document.addEventListener("pointercancel", handlePointerUp); + captureTarget.addEventListener("pointermove", handlePointerMove); + captureTarget.addEventListener("pointerup", handlePointerUp); + captureTarget.addEventListener("pointercancel", handlePointerUp); }, [floatingPosition, floatingSize, isFloatingMode, persistFloatingPosition, persistFloatingSize]); /** diff --git a/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx b/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx index 8a9fc08137..bd38e5c4a7 100644 --- a/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx @@ -261,12 +261,14 @@ describe("TerminalModal", () => { expect(modal).not.toHaveClass("terminal-modal--floating"); const fitCallBaseline = mockFitAddonFit.mock.calls.length; - const handle = screen.getByTestId("terminal-docked-resize-handle") as HTMLElement & { setPointerCapture: (pointerId: number) => void }; + // FNXC:Terminal 2026-06-22-19:50: The resize handlers now capture the pointer and listen on the CAPTURED handle element (not document), so move/up are fired on the handle with the matching pointerId; stub setPointerCapture/releasePointerCapture (jsdom no-ops). + const handle = screen.getByTestId("terminal-docked-resize-handle") as HTMLElement & { setPointerCapture: (pointerId: number) => void; releasePointerCapture: (pointerId: number) => void }; handle.setPointerCapture = vi.fn(); + handle.releasePointerCapture = vi.fn(); fireEvent.pointerDown(handle, { pointerId: 1, clientY: 500 }); - fireEvent.pointerMove(document, { clientY: 420 }); - fireEvent.pointerUp(document, { pointerId: 1 }); + fireEvent.pointerMove(handle, { pointerId: 1, clientY: 420 }); + fireEvent.pointerUp(handle, { pointerId: 1 }); await waitFor(() => { expect(window.localStorage.getItem(`fusion:terminal-docked-height-${projectId}`)).toBe("440"); @@ -312,23 +314,26 @@ describe("TerminalModal", () => { expect(screen.getByTestId("terminal-floating-resize-se")).toBeInTheDocument(); const fitCallBaseline = mockFitAddonFit.mock.calls.length; - const resizeHandle = screen.getByTestId("terminal-floating-resize-se") as HTMLElement & { setPointerCapture: (pointerId: number) => void }; + // FNXC:Terminal 2026-06-22-19:50: Floating resize/drag now capture the pointer and listen on the CAPTURED element (not document); fire move/up on that element with the matching pointerId and stub set/releasePointerCapture. + const resizeHandle = screen.getByTestId("terminal-floating-resize-se") as HTMLElement & { setPointerCapture: (pointerId: number) => void; releasePointerCapture: (pointerId: number) => void }; resizeHandle.setPointerCapture = vi.fn(); + resizeHandle.releasePointerCapture = vi.fn(); fireEvent.pointerDown(resizeHandle, { pointerId: 1, clientX: 100, clientY: 100 }); - fireEvent.pointerMove(document, { clientX: 140, clientY: 130 }); - fireEvent.pointerUp(document, { pointerId: 1 }); + fireEvent.pointerMove(resizeHandle, { pointerId: 1, clientX: 140, clientY: 130 }); + fireEvent.pointerUp(resizeHandle, { pointerId: 1 }); await waitFor(() => { expect(window.localStorage.getItem(`fusion:terminal-modal-size-${projectId}`)).toBe(JSON.stringify({ width: 992, height: 590 })); expect(mockFitAddonFit.mock.calls.length).toBeGreaterThan(fitCallBaseline); }); - const header = modal.querySelector(".terminal-header") as HTMLElement & { setPointerCapture: (pointerId: number) => void }; + const header = modal.querySelector(".terminal-header") as HTMLElement & { setPointerCapture: (pointerId: number) => void; releasePointerCapture: (pointerId: number) => void }; header.setPointerCapture = vi.fn(); + header.releasePointerCapture = vi.fn(); fireEvent.pointerDown(header, { pointerId: 2, clientX: 100, clientY: 100 }); - fireEvent.pointerMove(document, { clientX: 125, clientY: 135 }); - fireEvent.pointerUp(document, { pointerId: 2 }); + fireEvent.pointerMove(header, { pointerId: 2, clientX: 125, clientY: 135 }); + fireEvent.pointerUp(header, { pointerId: 2 }); await waitFor(() => { expect(window.localStorage.getItem(`fusion:terminal-float-pos-${projectId}`)).toBeTruthy(); From 62a96e371202af9bac4fcfc15ec6a00092f94802 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 05:10:12 -0700 Subject: [PATCH 02/11] feat(dashboard): floating windows (pop-out task detail, floating New Task), consolidate AI engine row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FloatingWindow: reusable non-blocking, draggable, smoothly-resizable window with focus-to-front z-index so multiple coexist (file browser + terminal + several task details open and movable at once). - Task detail gains a Pop out (Maximize2) button in List + Board; App tracks multiple open floating task-detail windows (dedupe by id). - New Task dialog is now a floating, draggable, resizable, non-blocking window; all quick-add controls visible without expanding (TaskForm forceMoreOptionsOpen). - Dashboard Overview: removed the duplicate AI Engine row — View Board / View Agents moved into the first instance (the AI engine card, under Stop AI Engine). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/dashboard/app/App.tsx | 55 +++ .../app/components/FloatingWindow.css | 148 ++++++++ .../app/components/FloatingWindow.tsx | 317 ++++++++++++++++++ .../dashboard/app/components/ListView.tsx | 7 + .../dashboard/app/components/NewTaskModal.css | 93 +++++ .../dashboard/app/components/NewTaskModal.tsx | 268 ++++++++++++++- .../app/components/TaskDetailModal.tsx | 24 +- .../dashboard/app/components/TaskForm.tsx | 45 ++- .../__tests__/FloatingWindow.test.tsx | 94 ++++++ .../__tests__/NewTaskModal.test.tsx | 168 ++++++---- .../command-center/CommandCenter.tsx | 44 +-- .../command-center/CommandCenterControls.tsx | 23 +- 12 files changed, 1148 insertions(+), 138 deletions(-) create mode 100644 packages/dashboard/app/components/FloatingWindow.css create mode 100644 packages/dashboard/app/components/FloatingWindow.tsx create mode 100644 packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 109e3e5d46..632645f2ec 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -13,7 +13,9 @@ import { Header, useViewportMode } from "./components/Header"; import { Board } from "./components/Board"; import { TaskCard } from "./components/TaskCard"; import { ListView } from "./components/ListView"; +import { Maximize2 } from "lucide-react"; import { TaskDetailContent } from "./components/TaskDetailModal"; +import { FloatingWindow } from "./components/FloatingWindow"; import { ProjectOverview } from "./components/ProjectOverview"; import { MissionManager } from "./components/MissionManager"; import { MailboxView } from "./components/MailboxView"; @@ -553,6 +555,18 @@ function AppInner() { */ const [mainPanelDetailTask, setMainPanelDetailTask] = useState(null); + /* + FNXC:FloatingWindow 2026-06-22-20:45: + Open popped-out task-detail windows. Each entry is a task snapshot rendered inside its own movable, resizable, non-blocking FloatingWindow. Several can be open at once and coexist with the right-dock pop-out and terminal (all click-through overlays). Snapshots survive a tasks revalidation; rendering prefers the live row by id and falls back to the snapshot. Pop-out dedupes by task id — re-popping an already-open task is a no-op (its window stays; focus-to-front in FloatingWindow handles re-raising on click). + */ + const [poppedOutTasks, setPoppedOutTasks] = useState>([]); + const popOutTaskDetail = useCallback((task: Task | TaskDetail) => { + setPoppedOutTasks((current) => (current.some((entry) => entry.id === task.id) ? current : [...current, task])); + }, []); + const closePoppedOutTask = useCallback((taskId: string) => { + setPoppedOutTasks((current) => current.filter((entry) => entry.id !== taskId)); + }, []); + const previousTaskViewRef = useRef(taskView); useEffect(() => { @@ -2053,6 +2067,7 @@ function AppInner() { Board-card detail (full main panel) renders its "Back to board" affordance inside TaskDetailContent's gray header (far right, across from the task id) instead of a separate back-row above the content. The prop only renders the header back button when both embedded and onBackToBoard are present, so ListView split-pane and modal usages stay unaffected. */ onBackToBoard={closeTaskDetailMainPanel} + onPopOut={popOutTaskDetail} onOpenDetail={(value) => setMainPanelDetailTask(value)} onMoveTask={moveTask} onDeleteTask={deleteTask} @@ -2148,6 +2163,7 @@ function AppInner() { onResetTask={resetTask} onDuplicateTask={duplicateTask} onOpenDetail={(task, options) => openDetailTask(task, undefined, options)} + onPopOut={popOutTaskDetail} addToast={addToast} globalPaused={globalPaused} onNewTask={openNewTaskWithNav} @@ -2495,6 +2511,45 @@ function AppInner() { onToggleModelFavorite={handleToggleModelFavorite} /> )} + {/* + FNXC:FloatingWindow 2026-06-22-20:45: + One movable, resizable, non-blocking FloatingWindow per popped-out task. Each hosts the same embedded TaskDetailContent List/Board use, wired to the same App task handlers. Live row preferred by id; falls back to the snapshot. Terminal/destructive actions and the window close button both remove the entry. Multiple entries → multiple coexisting windows; FloatingWindow's per-window z-counter handles focus-to-front so the clicked one comes on top. + */} + {poppedOutTasks.map((snapshot) => { + const liveTask = tasks.find((candidate) => candidate.id === snapshot.id) ?? snapshot; + const close = () => closePoppedOutTask(snapshot.id); + return ( + + + ); + })} * { + flex: 1; + min-width: 0; + min-height: 0; + min-block-size: 0; +} + +/* +FNXC:FloatingWindow 2026-06-22-20:45: +Edge + corner resize handles. touch-action:none keeps the drag from being hijacked by scroll/gestures so resizing stays smooth. +*/ +.floating-window__resize-handle { + position: absolute; + z-index: 2; + touch-action: none; +} + +.floating-window__resize-handle--n, +.floating-window__resize-handle--s { + left: var(--space-sm); + right: var(--space-sm); + height: var(--space-sm); + cursor: ns-resize; +} + +.floating-window__resize-handle--n { top: 0; } +.floating-window__resize-handle--s { bottom: 0; } + +.floating-window__resize-handle--e, +.floating-window__resize-handle--w { + top: var(--space-sm); + bottom: var(--space-sm); + width: var(--space-sm); + cursor: ew-resize; +} + +.floating-window__resize-handle--e { right: 0; } +.floating-window__resize-handle--w { left: 0; } + +.floating-window__resize-handle--ne, +.floating-window__resize-handle--nw, +.floating-window__resize-handle--se, +.floating-window__resize-handle--sw { + width: var(--space-lg); + height: var(--space-lg); +} + +.floating-window__resize-handle--ne { top: 0; right: 0; cursor: nesw-resize; } +.floating-window__resize-handle--nw { top: 0; left: 0; cursor: nwse-resize; } +.floating-window__resize-handle--se { bottom: 0; right: 0; cursor: nwse-resize; } +.floating-window__resize-handle--sw { bottom: 0; left: 0; cursor: nesw-resize; } diff --git a/packages/dashboard/app/components/FloatingWindow.tsx b/packages/dashboard/app/components/FloatingWindow.tsx new file mode 100644 index 0000000000..67a531859e --- /dev/null +++ b/packages/dashboard/app/components/FloatingWindow.tsx @@ -0,0 +1,317 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type CSSProperties, + type PointerEvent as ReactPointerEvent, + type ReactNode, +} from "react"; +import { X } from "lucide-react"; +import "./FloatingWindow.css"; + +/* +FNXC:FloatingWindow 2026-06-22-20:45: +FloatingWindow is the REUSABLE non-blocking floating window. It generalizes the proven RightDockExpandModal technique (transparent `pointer-events:none` overlay, a `position:fixed; pointer-events:auto` panel dragged by its header via setPointerCapture + captured-element listeners + pointerId filtering + rAF-batched position, edge/corner resize handles, `touch-action:none` handles, and a single dragTeardownRef detached on pointerup/cancel AND unmount). It hosts ARBITRARY children so several windows (file browser, terminal, multiple task details) can coexist without blocking the page or each other. + +MULTI-WINDOW STACKING: a module-level z-index counter (`topZ`) hands each window a fresh z on mount and on every panel pointerdown/focus, so the most recently interacted-with window floats to the front. All overlays are click-through; only the panels capture pointer events, so every open FloatingWindow is independently movable and none blocks the page behind it. +*/ + +export interface FloatingWindowSize { + width: number; + height: number; +} + +export interface FloatingWindowPosition { + x: number; + y: number; +} + +export interface FloatingWindowProps { + title: ReactNode; + onClose: () => void; + children: ReactNode; + /** Stable identity for this window; used to derive a deterministic cascade offset for the default position. */ + windowKey: string; + defaultSize?: FloatingWindowSize; + defaultPosition?: FloatingWindowPosition; + minSize?: FloatingWindowSize; +} + +const DEFAULT_WIDTH = 720; +const DEFAULT_HEIGHT = 560; +const DEFAULT_MIN_WIDTH = 360; +const DEFAULT_MIN_HEIGHT = 280; +const VIEWPORT_PADDING = 16; + +/* +FNXC:FloatingWindow 2026-06-22-20:45: +Base z-index band sits at 4000+, above ordinary page content and interoperable with the existing terminal/right-dock pop-out band. `nextZ()` bumps the shared counter so a freshly mounted or freshly clicked window comes to the front. The counter is module-level and intentionally monotonic — it only ever climbs, which is fine for a session-length dashboard. +*/ +let topZ = 4000; +function nextZ(): number { + return ++topZ; +} + +type ResizeDirection = "n" | "s" | "e" | "w" | "ne" | "nw" | "se" | "sw"; +const RESIZE_DIRECTIONS: ResizeDirection[] = ["n", "s", "e", "w", "ne", "nw", "se", "sw"]; + +/** Hash a windowKey into a small bounded cascade index so stacked default windows do not perfectly overlap. */ +function cascadeIndexFor(windowKey: string): number { + let hash = 0; + for (let i = 0; i < windowKey.length; i += 1) { + hash = (hash * 31 + windowKey.charCodeAt(i)) | 0; + } + return Math.abs(hash) % 6; +} + +function clampSize(size: FloatingWindowSize, minSize: FloatingWindowSize): FloatingWindowSize { + if (typeof window === "undefined") return size; + return { + width: Math.min(Math.max(size.width, minSize.width), Math.max(minSize.width, window.innerWidth - VIEWPORT_PADDING * 2)), + height: Math.min(Math.max(size.height, minSize.height), Math.max(minSize.height, window.innerHeight - VIEWPORT_PADDING * 2)), + }; +} + +function clampPosition(position: FloatingWindowPosition, size: FloatingWindowSize): FloatingWindowPosition { + if (typeof window === "undefined") return position; + return { + x: Math.min(Math.max(position.x, VIEWPORT_PADDING), Math.max(VIEWPORT_PADDING, window.innerWidth - size.width - VIEWPORT_PADDING)), + y: Math.min(Math.max(position.y, VIEWPORT_PADDING), Math.max(VIEWPORT_PADDING, window.innerHeight - size.height - VIEWPORT_PADDING)), + }; +} + +/* +FNXC:FloatingWindow 2026-06-22-20:45: +Default position cascades by windowKey so opening several windows in a row visibly offsets each one from a roughly-centered origin instead of stacking them pixel-perfect on top of one another. +*/ +function defaultPositionFor(windowKey: string, size: FloatingWindowSize): FloatingWindowPosition { + if (typeof window === "undefined") return { x: VIEWPORT_PADDING, y: VIEWPORT_PADDING }; + const cascade = cascadeIndexFor(windowKey) * 28; + return clampPosition( + { x: (window.innerWidth - size.width) / 2 + cascade, y: (window.innerHeight - size.height) / 2 + cascade }, + size + ); +} + +export function FloatingWindow({ + title, + onClose, + children, + windowKey, + defaultSize, + defaultPosition, + minSize, +}: FloatingWindowProps) { + const resolvedMinSize: FloatingWindowSize = minSize ?? { width: DEFAULT_MIN_WIDTH, height: DEFAULT_MIN_HEIGHT }; + + const [size, setSize] = useState(() => + clampSize(defaultSize ?? { width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT }, resolvedMinSize) + ); + const [position, setPosition] = useState(() => { + const initialSize = clampSize(defaultSize ?? { width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT }, resolvedMinSize); + return defaultPosition ? clampPosition(defaultPosition, initialSize) : defaultPositionFor(windowKey, initialSize); + }); + // FNXC:FloatingWindow 2026-06-22-20:45: Each window owns its z-index; mounting claims the front of the stack. + const [zIndex, setZIndex] = useState(() => nextZ()); + + /* + FNXC:FloatingWindow 2026-06-22-20:45: + A single active-drag/resize teardown (copied from the RightDockExpandModal pattern). pointerup/pointercancel run it, and the unmount effect runs it too, so an in-progress gesture interrupted by close/unmount never leaks captured-element pointer listeners or a pending rAF. + */ + const dragTeardownRef = useRef<(() => void) | null>(null); + + // FNXC:FloatingWindow 2026-06-22-20:45: Focus-to-front. Pointerdown/focus anywhere on the panel raises this window above the rest. + const bringToFront = useCallback(() => { + setZIndex((current) => { + // Only claim a new z if we are not already on top, to avoid needless counter churn on every move. + if (current > topZ) return current; + return nextZ(); + }); + }, []); + + const handleDragPointerDown = useCallback( + (event: ReactPointerEvent) => { + if ((event.target as HTMLElement).closest("button")) return; + event.preventDefault(); + bringToFront(); + const captureTarget = event.currentTarget; + const pointerId = event.pointerId; + captureTarget.setPointerCapture?.(pointerId); + const startX = event.clientX; + const startY = event.clientY; + const startPosition = position; + const currentSize = size; + const previousUserSelect = document.body.style.userSelect; + document.body.style.userSelect = "none"; + + let latest = startPosition; + let frame = 0; + + const handlePointerMove = (moveEvent: PointerEvent) => { + if (moveEvent.pointerId !== pointerId) return; + latest = { x: startPosition.x + moveEvent.clientX - startX, y: startPosition.y + moveEvent.clientY - startY }; + if (frame) return; + frame = requestAnimationFrame(() => { + frame = 0; + setPosition(clampPosition(latest, currentSize)); + }); + }; + const detachListeners = () => { + captureTarget.releasePointerCapture?.(pointerId); + captureTarget.removeEventListener("pointermove", handlePointerMove); + captureTarget.removeEventListener("pointerup", handlePointerUp); + captureTarget.removeEventListener("pointercancel", handlePointerUp); + }; + function handlePointerUp() { + if (frame) cancelAnimationFrame(frame); + setPosition(clampPosition(latest, currentSize)); + document.body.style.userSelect = previousUserSelect; + detachListeners(); + dragTeardownRef.current = null; + } + + dragTeardownRef.current = () => { + if (frame) cancelAnimationFrame(frame); + document.body.style.userSelect = previousUserSelect; + detachListeners(); + dragTeardownRef.current = null; + }; + + captureTarget.addEventListener("pointermove", handlePointerMove); + captureTarget.addEventListener("pointerup", handlePointerUp); + captureTarget.addEventListener("pointercancel", handlePointerUp); + }, + [bringToFront, position, size] + ); + + const handleResizePointerDown = useCallback( + (event: ReactPointerEvent, direction: ResizeDirection) => { + event.preventDefault(); + event.stopPropagation(); + bringToFront(); + const captureTarget = event.currentTarget; + const pointerId = event.pointerId; + captureTarget.setPointerCapture?.(pointerId); + const startX = event.clientX; + const startY = event.clientY; + const startSize = size; + const startPosition = position; + const previousUserSelect = document.body.style.userSelect; + document.body.style.userSelect = "none"; + + let latestSize = startSize; + let latestPosition = startPosition; + let frame = 0; + + const handlePointerMove = (moveEvent: PointerEvent) => { + if (moveEvent.pointerId !== pointerId) return; + const dx = moveEvent.clientX - startX; + const dy = moveEvent.clientY - startY; + const nextSize = clampSize( + { + width: startSize.width + (direction.includes("e") ? dx : direction.includes("w") ? -dx : 0), + height: startSize.height + (direction.includes("s") ? dy : direction.includes("n") ? -dy : 0), + }, + resolvedMinSize + ); + const nextPosition = { + x: startPosition.x + (direction.includes("w") ? startSize.width - nextSize.width : 0), + y: startPosition.y + (direction.includes("n") ? startSize.height - nextSize.height : 0), + }; + latestSize = nextSize; + latestPosition = nextPosition; + if (frame) return; + frame = requestAnimationFrame(() => { + frame = 0; + setSize(latestSize); + setPosition(clampPosition(latestPosition, latestSize)); + }); + }; + const detachListeners = () => { + captureTarget.releasePointerCapture?.(pointerId); + captureTarget.removeEventListener("pointermove", handlePointerMove); + captureTarget.removeEventListener("pointerup", handlePointerUp); + captureTarget.removeEventListener("pointercancel", handlePointerUp); + }; + function handlePointerUp() { + if (frame) cancelAnimationFrame(frame); + setSize(latestSize); + setPosition(clampPosition(latestPosition, latestSize)); + document.body.style.userSelect = previousUserSelect; + detachListeners(); + dragTeardownRef.current = null; + } + + dragTeardownRef.current = () => { + if (frame) cancelAnimationFrame(frame); + document.body.style.userSelect = previousUserSelect; + detachListeners(); + dragTeardownRef.current = null; + }; + + captureTarget.addEventListener("pointermove", handlePointerMove); + captureTarget.addEventListener("pointerup", handlePointerUp); + captureTarget.addEventListener("pointercancel", handlePointerUp); + }, + [bringToFront, position, resolvedMinSize, size] + ); + + // FNXC:FloatingWindow 2026-06-22-20:45: Run any active drag/resize teardown on unmount so captured-element listeners + a pending rAF never outlive the window. + useEffect(() => () => dragTeardownRef.current?.(), []); + + const panelStyle = { + left: `${position.x}px`, + top: `${position.y}px`, + width: `${size.width}px`, + height: `${size.height}px`, + zIndex, + } as CSSProperties; + + return ( +
+
+ {RESIZE_DIRECTIONS.map((direction) => ( +
handleResizePointerDown(event, direction)} + /> + ))} +
+
{title}
+ +
+
+ {children} +
+
+
+ ); +} diff --git a/packages/dashboard/app/components/ListView.tsx b/packages/dashboard/app/components/ListView.tsx index f05b9c1388..ec2fe4b0d2 100644 --- a/packages/dashboard/app/components/ListView.tsx +++ b/packages/dashboard/app/components/ListView.tsx @@ -208,6 +208,11 @@ interface ListViewProps { onResetTask?: (id: string) => Promise; onDuplicateTask?: (id: string) => Promise; onOpenDetail: (task: Task | TaskDetail, options?: { origin?: "list-mobile" }) => void; + /* + FNXC:FloatingWindow 2026-06-22-20:45: + onPopOut pops the split-pane task detail into a movable, resizable, non-blocking FloatingWindow managed at App level. Wired to the Maximize2 "Pop out" button in TaskDetailContent's header. + */ + onPopOut?: (task: Task | TaskDetail) => void; addToast: (message: string, type?: ToastType) => void; globalPaused?: boolean; onNewTask?: () => void; @@ -291,6 +296,7 @@ export function ListView({ onMergeTask, onResetTask, onDuplicateTask, + onPopOut, onOpenDetail, addToast, globalPaused, @@ -2473,6 +2479,7 @@ export function ListView({ onRetryTask={onRetryTask} onResetTask={onResetTask} onDuplicateTask={onDuplicateTask} + onPopOut={onPopOut ? () => onPopOut(selectedTaskSnapshot) : undefined} onTaskUpdated={(updatedTask) => { setSelectedTaskSnapshot((previous) => { if (!previous || previous.id !== updatedTask.id) return previous; diff --git a/packages/dashboard/app/components/NewTaskModal.css b/packages/dashboard/app/components/NewTaskModal.css index ebf9898e30..5d8a0a496e 100644 --- a/packages/dashboard/app/components/NewTaskModal.css +++ b/packages/dashboard/app/components/NewTaskModal.css @@ -3,6 +3,99 @@ min-height: min(520px, 80vh); } +/* +FNXC:NewTask 2026-06-22-20:30: +The New Task dialog is a FLOATING, DRAGGABLE, RESIZABLE, NON-BLOCKING window (mirrors the right-dock pop-out). The overlay MUST out-specify the base `.modal-overlay` (which dims + blurs the page). Both base and override are single-class, so a two-class selector (`.modal-overlay.new-task-modal-overlay`) guarantees the transparent, non-blurring, click-through backdrop regardless of stylesheet order. `pointer-events: none` lets behind-clicks pass through to the app; the floating panel re-enables `pointer-events: auto`. No overlay click-to-dismiss — the header X / Cancel / Escape are the only dismissals. +*/ +.modal-overlay.new-task-modal-overlay { + align-items: stretch; + justify-content: flex-start; + padding: 0; + background: transparent; + backdrop-filter: none; + pointer-events: none; +} + +/* +FNXC:NewTask 2026-06-22-20:30: +Floating panel positioned by state-driven inline left/top/width/height. min/max keep content usable and the panel on-screen; `resize: none` because the corner/edge handles own resizing (the native grip conflicts with the pointer handlers). `pointer-events: auto` re-enables interaction on the panel only. Desktop only — mobile keeps the full-screen keyboard-aware sheet. +*/ +.new-task-modal--floating { + position: fixed; + display: flex; + flex-direction: column; + min-width: calc(var(--space-2xl) * 8.75); + min-height: calc(var(--space-2xl) * 7.5); + max-width: calc(100vw - (var(--space-lg) * 2)); + max-height: calc(100dvh - (var(--space-lg) * 2)); + resize: none; + pointer-events: auto; + box-shadow: var(--shadow-xl); +} + +.new-task-modal--floating .modal-body { + max-height: none; +} + +/* +FNXC:NewTask 2026-06-22-20:30: +Header is the drag handle. `touch-action: none` (matching the resize handles) hands the whole gesture to our pointer handlers so a finger drag stays smooth and never scrolls the page behind it. `cursor: grab/grabbing` is desktop-only signal. +*/ +.new-task-modal__header--draggable { + cursor: grab; + user-select: none; + touch-action: none; +} + +.new-task-modal__header--draggable:active { + cursor: grabbing; +} + +/* +FNXC:NewTask 2026-06-22-20:30: +Edge + corner resize handles. touch-action:none keeps the drag from being hijacked by scroll/gestures so resizing stays smooth. +*/ +.new-task-resize-handle { + position: absolute; + z-index: 2; + touch-action: none; +} + +.new-task-resize-handle--n, +.new-task-resize-handle--s { + left: var(--space-sm); + right: var(--space-sm); + height: var(--space-sm); + cursor: ns-resize; +} + +.new-task-resize-handle--n { top: 0; } +.new-task-resize-handle--s { bottom: 0; } + +.new-task-resize-handle--e, +.new-task-resize-handle--w { + top: var(--space-sm); + bottom: var(--space-sm); + width: var(--space-sm); + cursor: ew-resize; +} + +.new-task-resize-handle--e { right: 0; } +.new-task-resize-handle--w { left: 0; } + +.new-task-resize-handle--ne, +.new-task-resize-handle--nw, +.new-task-resize-handle--se, +.new-task-resize-handle--sw { + width: var(--space-lg); + height: var(--space-lg); +} + +.new-task-resize-handle--ne { top: 0; right: 0; cursor: nesw-resize; } +.new-task-resize-handle--nw { top: 0; left: 0; cursor: nwse-resize; } +.new-task-resize-handle--se { bottom: 0; right: 0; cursor: nwse-resize; } +.new-task-resize-handle--sw { bottom: 0; left: 0; cursor: nesw-resize; } + .new-task-modal .modal-body { padding: var(--space-xl); overflow-y: auto; diff --git a/packages/dashboard/app/components/NewTaskModal.tsx b/packages/dashboard/app/components/NewTaskModal.tsx index 7d0e2441cc..0c39156399 100644 --- a/packages/dashboard/app/components/NewTaskModal.tsx +++ b/packages/dashboard/app/components/NewTaskModal.tsx @@ -1,5 +1,5 @@ import "./NewTaskModal.css"; -import { useState, useCallback, useEffect, useRef } from "react"; +import { useState, useCallback, useEffect, useRef, type CSSProperties, type PointerEvent as ReactPointerEvent } from "react"; import { useTranslation } from "react-i18next"; import { DEFAULT_TASK_PRIORITY, type Task, type TaskCreateInput, type TaskPriority } from "@fusion/core"; import { getErrorMessage } from "@fusion/core"; @@ -30,6 +30,97 @@ interface NewTaskModalProps { onSubtaskBreakdown?: (description: string, workflowId?: string | null) => void; } +/* +FNXC:NewTask 2026-06-22-20:30: +The New Task dialog is a FLOATING, DRAGGABLE, RESIZABLE, NON-BLOCKING window matching the right-dock pop-out (RightDockExpandModal). The overlay is transparent and `pointer-events: none` so the app behind stays usable and behind-clicks pass through — there is therefore NO overlay click-to-dismiss; the header close (X) and Cancel button are the only dismissals (plus Escape). The panel is `position: fixed; pointer-events: auto`, dragged by its header and resized from corner/edge handles, with rAF-batched position/size state and a single teardown ref invoked on pointerup/pointercancel AND on unmount so no document/element listeners or pending rAF leak. Size/position persist to localStorage. On mobile we keep the full-screen sheet behavior (no floating) so the keyboard-aware layout still works. +*/ +const NEW_TASK_MODAL_SIZE_STORAGE_KEY = "fusion:new-task-modal-size"; +const NEW_TASK_MODAL_POSITION_STORAGE_KEY = "fusion:new-task-modal-position"; + +const NEW_TASK_DEFAULT_WIDTH = 720; +const NEW_TASK_DEFAULT_HEIGHT = 640; +const NEW_TASK_MIN_WIDTH = 420; +const NEW_TASK_MIN_HEIGHT = 360; +const NEW_TASK_VIEWPORT_PADDING = 16; + +interface FloatSize { + width: number; + height: number; +} + +interface FloatPosition { + x: number; + y: number; +} + +function clampFloatSize(size: FloatSize): FloatSize { + if (typeof window === "undefined") return size; + return { + width: Math.min(Math.max(size.width, NEW_TASK_MIN_WIDTH), Math.max(NEW_TASK_MIN_WIDTH, window.innerWidth - NEW_TASK_VIEWPORT_PADDING * 2)), + height: Math.min(Math.max(size.height, NEW_TASK_MIN_HEIGHT), Math.max(NEW_TASK_MIN_HEIGHT, window.innerHeight - NEW_TASK_VIEWPORT_PADDING * 2)), + }; +} + +function clampFloatPosition(position: FloatPosition, size: FloatSize): FloatPosition { + if (typeof window === "undefined") return position; + return { + x: Math.min(Math.max(position.x, NEW_TASK_VIEWPORT_PADDING), Math.max(NEW_TASK_VIEWPORT_PADDING, window.innerWidth - size.width - NEW_TASK_VIEWPORT_PADDING)), + y: Math.min(Math.max(position.y, NEW_TASK_VIEWPORT_PADDING), Math.max(NEW_TASK_VIEWPORT_PADDING, window.innerHeight - size.height - NEW_TASK_VIEWPORT_PADDING)), + }; +} + +function readFloatSize(): FloatSize { + if (typeof window === "undefined") return { width: NEW_TASK_DEFAULT_WIDTH, height: NEW_TASK_DEFAULT_HEIGHT }; + try { + const raw = window.localStorage.getItem(NEW_TASK_MODAL_SIZE_STORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw) as Partial; + if (typeof parsed.width === "number" && typeof parsed.height === "number") { + return clampFloatSize({ width: parsed.width, height: parsed.height }); + } + } + } catch { + // ignore corrupted persisted size + } + return clampFloatSize({ width: NEW_TASK_DEFAULT_WIDTH, height: NEW_TASK_DEFAULT_HEIGHT }); +} + +function writeFloatSize(size: FloatSize): FloatSize { + const clamped = clampFloatSize(size); + if (typeof window !== "undefined") { + window.localStorage.setItem(NEW_TASK_MODAL_SIZE_STORAGE_KEY, JSON.stringify(clamped)); + } + return clamped; +} + +function readFloatPosition(size: FloatSize): FloatPosition { + if (typeof window === "undefined") return { x: NEW_TASK_VIEWPORT_PADDING, y: NEW_TASK_VIEWPORT_PADDING }; + try { + const raw = window.localStorage.getItem(NEW_TASK_MODAL_POSITION_STORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw) as Partial; + if (typeof parsed.x === "number" && typeof parsed.y === "number") { + return clampFloatPosition({ x: parsed.x, y: parsed.y }, size); + } + } + } catch { + // ignore corrupted persisted position + } + // Default: roughly centered. + return clampFloatPosition({ x: (window.innerWidth - size.width) / 2, y: (window.innerHeight - size.height) / 2 }, size); +} + +function writeFloatPosition(position: FloatPosition, size: FloatSize): FloatPosition { + const clamped = clampFloatPosition(position, size); + if (typeof window !== "undefined") { + window.localStorage.setItem(NEW_TASK_MODAL_POSITION_STORAGE_KEY, JSON.stringify(clamped)); + } + return clamped; +} + +type FloatResizeDirection = "n" | "s" | "e" | "w" | "ne" | "nw" | "se" | "sw"; +const NEW_TASK_RESIZE_DIRECTIONS: FloatResizeDirection[] = ["n", "s", "e", "w", "ne", "nw", "se", "sw"]; + export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, addToast, initialDescription = "", onPlanningMode, onSubtaskBreakdown }: NewTaskModalProps) { const { t } = useTranslation("app"); const { confirm } = useConfirm(); @@ -47,6 +138,145 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, : {}; const [description, setDescription] = useState(""); const wasOpenRef = useRef(false); + + /* + FNXC:NewTask 2026-06-22-20:30: + Floating window position/size state (desktop only). Mobile keeps the full-screen sheet, so we only apply the floating panel style and drag/resize handlers when not mobile. A single active-drag teardown (drag OR resize) lives in dragTeardownRef; pointerup/pointercancel AND the unmount effect run it so an interrupted drag never leaks element pointer listeners or a pending rAF. + */ + const isFloating = viewportMode !== "mobile"; + const [size, setSizeState] = useState(() => readFloatSize()); + const [position, setPositionState] = useState(() => readFloatPosition(readFloatSize())); + const dragTeardownRef = useRef<(() => void) | null>(null); + + const persistSize = useCallback((next: FloatSize) => { + setSizeState(writeFloatSize(next)); + }, []); + + const persistPosition = useCallback((next: FloatPosition, withSize: FloatSize) => { + setPositionState(writeFloatPosition(next, withSize)); + }, []); + + // FNXC:NewTask 2026-06-22-20:30: Header drag. setPointerCapture redirects the pointer stream to the captured header element, so element-scoped pointermove/up listeners receive the full drag even off the header; moves are rAF-batched; the panel is clamped on-screen. Close button clicks are excluded so dragging never swallows close. + const handleFloatingDragPointerDown = useCallback((event: ReactPointerEvent) => { + if ((event.target as HTMLElement).closest("button")) return; + event.preventDefault(); + const captureTarget = event.currentTarget; + const pointerId = event.pointerId; + captureTarget.setPointerCapture?.(pointerId); + const startX = event.clientX; + const startY = event.clientY; + const startPosition = position; + const currentSize = size; + const previousUserSelect = document.body.style.userSelect; + document.body.style.userSelect = "none"; + + let latest = startPosition; + let frame = 0; + + const handlePointerMove = (moveEvent: PointerEvent) => { + if (moveEvent.pointerId !== pointerId) return; + latest = { x: startPosition.x + moveEvent.clientX - startX, y: startPosition.y + moveEvent.clientY - startY }; + if (frame) return; + frame = requestAnimationFrame(() => { + frame = 0; + setPositionState(clampFloatPosition(latest, currentSize)); + }); + }; + const detachListeners = () => { + captureTarget.releasePointerCapture?.(pointerId); + captureTarget.removeEventListener("pointermove", handlePointerMove); + captureTarget.removeEventListener("pointerup", handlePointerUp); + captureTarget.removeEventListener("pointercancel", handlePointerUp); + }; + function handlePointerUp() { + if (frame) cancelAnimationFrame(frame); + persistPosition(latest, currentSize); + document.body.style.userSelect = previousUserSelect; + detachListeners(); + dragTeardownRef.current = null; + } + + dragTeardownRef.current = () => { + if (frame) cancelAnimationFrame(frame); + document.body.style.userSelect = previousUserSelect; + detachListeners(); + dragTeardownRef.current = null; + }; + + captureTarget.addEventListener("pointermove", handlePointerMove); + captureTarget.addEventListener("pointerup", handlePointerUp); + captureTarget.addEventListener("pointercancel", handlePointerUp); + }, [persistPosition, position, size]); + + // FNXC:NewTask 2026-06-22-20:30: Corner/edge resize, rAF-batched. West/north handles also shift the panel origin so the opposite edge stays pinned. Same teardown discipline as the drag. + const handleFloatingResizePointerDown = useCallback((event: ReactPointerEvent, direction: FloatResizeDirection) => { + event.preventDefault(); + event.stopPropagation(); + const captureTarget = event.currentTarget; + const pointerId = event.pointerId; + captureTarget.setPointerCapture?.(pointerId); + const startX = event.clientX; + const startY = event.clientY; + const startSize = size; + const startPosition = position; + const previousUserSelect = document.body.style.userSelect; + document.body.style.userSelect = "none"; + + let latestSize = startSize; + let latestPosition = startPosition; + let frame = 0; + + const handlePointerMove = (moveEvent: PointerEvent) => { + if (moveEvent.pointerId !== pointerId) return; + const dx = moveEvent.clientX - startX; + const dy = moveEvent.clientY - startY; + const nextSize = clampFloatSize({ + width: startSize.width + (direction.includes("e") ? dx : direction.includes("w") ? -dx : 0), + height: startSize.height + (direction.includes("s") ? dy : direction.includes("n") ? -dy : 0), + }); + const nextPosition = { + x: startPosition.x + (direction.includes("w") ? startSize.width - nextSize.width : 0), + y: startPosition.y + (direction.includes("n") ? startSize.height - nextSize.height : 0), + }; + latestSize = nextSize; + latestPosition = nextPosition; + if (frame) return; + frame = requestAnimationFrame(() => { + frame = 0; + setSizeState(latestSize); + setPositionState(clampFloatPosition(latestPosition, latestSize)); + }); + }; + const detachListeners = () => { + captureTarget.releasePointerCapture?.(pointerId); + captureTarget.removeEventListener("pointermove", handlePointerMove); + captureTarget.removeEventListener("pointerup", handlePointerUp); + captureTarget.removeEventListener("pointercancel", handlePointerUp); + }; + function handlePointerUp() { + if (frame) cancelAnimationFrame(frame); + persistSize(latestSize); + persistPosition(latestPosition, latestSize); + document.body.style.userSelect = previousUserSelect; + detachListeners(); + dragTeardownRef.current = null; + } + + dragTeardownRef.current = () => { + if (frame) cancelAnimationFrame(frame); + document.body.style.userSelect = previousUserSelect; + detachListeners(); + dragTeardownRef.current = null; + }; + + captureTarget.addEventListener("pointermove", handlePointerMove); + captureTarget.addEventListener("pointerup", handlePointerUp); + captureTarget.addEventListener("pointercancel", handlePointerUp); + }, [persistPosition, persistSize, position, size]); + + // FNXC:NewTask 2026-06-22-20:30: Run any active drag/resize teardown on unmount so element pointer listeners + a pending rAF never outlive the modal. + useEffect(() => () => dragTeardownRef.current?.(), []); + const [dependencies, setDependencies] = useState([]); const [branchMode, setBranchMode] = useState("project-default"); const [branch, setBranch] = useState(""); @@ -505,14 +735,39 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, if (!isOpen) return null; + // FNXC:NewTask 2026-06-22-20:30: Desktop = floating fixed panel positioned by state-driven left/top/width/height. Mobile keeps the keyboard-aware full-screen sheet (no floating). The transparent click-through overlay never dismisses on click; the header X / Cancel / Escape are the only dismissals. + const panelStyle: CSSProperties = isFloating + ? { left: `${position.x}px`, top: `${position.y}px`, width: `${size.width}px`, height: `${size.height}px` } + : keyboardStyle; + return ( -
+
e.stopPropagation()} - style={keyboardStyle} + className={`modal modal-lg new-task-modal${isFloating ? " new-task-modal--floating" : ""}`} + style={panelStyle} > -
+ {isFloating && NEW_TASK_RESIZE_DIRECTIONS.map((direction) => ( +
handleFloatingResizePointerDown(event, direction)} + /> + ))} +

{t("newTaskModal.title", "New Task")}

diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index 0f7cc9cc8b..b1e9be9e67 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -1,7 +1,7 @@ import "./TaskDetailModal.css"; import React, { Suspense, lazy, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Pencil, Bot, X, ChevronDown, ChevronRight, GitBranch, ArrowLeft, Zap, Loader2, AlertTriangle, Sparkles } from "lucide-react"; +import { Pencil, Bot, X, ChevronDown, ChevronRight, GitBranch, ArrowLeft, Zap, Loader2, AlertTriangle, Sparkles, Maximize2 } from "lucide-react"; import { useModalResizePersist } from "../hooks/useModalResizePersist"; import { useMobileScrollLock } from "../hooks/useMobileScrollLock"; import { useOverlayDismiss } from "../hooks/useOverlayDismiss"; @@ -414,6 +414,11 @@ export type TaskDetailContentProps = Omit & { onBackToBoard powers the board-card full-panel "Back to board" affordance rendered in the gray header (far right). It is only honored when embedded is also true, so ListView split-pane and modal usages never show it. */ onBackToBoard?: () => void; + /* + FNXC:FloatingWindow 2026-06-22-20:45: + onPopOut, when supplied, renders a Maximize2 "Pop out" button in the gray header. List/Board wire it to push this task into App's floating task-detail window array, opening the same embedded TaskDetailContent inside a movable, resizable, non-blocking FloatingWindow. It is independent of embedded/onBackToBoard so List split-pane and the board full-panel can both expose it. + */ + onPopOut?: (task: Task) => void; }; function truncate(s: string, max: number): string { @@ -589,6 +594,7 @@ export function TaskDetailContent({ embedded = false, onRequestClose, onBackToBoard, + onPopOut, workflowFieldDefs: workflowFieldDefsProp, }: TaskDetailContentProps) { const { t } = useTranslation("app"); @@ -2754,6 +2760,22 @@ export function TaskDetailContent({ {t("app.taskDetail.backToBoard", "Back to board")} )} + {/* + FNXC:FloatingWindow 2026-06-22-20:45: + "Pop out" affordance opens this task detail in a movable, resizable, non-blocking FloatingWindow. Rendered whenever onPopOut is wired (List split-pane + board full-panel); App dedupes by task id so re-popping focuses the existing window instead of duplicating. + */} + {onPopOut && ( + + )} {!isEditing && canEdit && ( + {/* FNXC:NewTask 2026-06-22-20:30: Hide the disclosure toggle entirely when force-open — there is nothing to collapse, so the New Task dialog shows every advanced control without a click. */} + {!forceMoreOptionsOpen && ( + + )} +
, + document.body, ); } From 590e064e378ed0c0163f8098541b23f074a53f04 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 05:20:18 -0700 Subject: [PATCH 04/11] feat(dashboard): remove the idle 'No agent is working' hint in task chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The idle session hint banner is suppressed (empty) per user request — idle chats stay sendable but no longer show the banner. Done/active hints remain. Test updated to assert the banner is absent. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/dashboard/app/components/TaskChatTab.tsx | 5 ++++- .../app/components/__tests__/TaskChatTab.test.tsx | 8 +++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/dashboard/app/components/TaskChatTab.tsx b/packages/dashboard/app/components/TaskChatTab.tsx index d002170b9b..ec659f81d0 100644 --- a/packages/dashboard/app/components/TaskChatTab.tsx +++ b/packages/dashboard/app/components/TaskChatTab.tsx @@ -509,12 +509,15 @@ export function TaskChatTab({ task, projectId, active, addToast, sessionLive, on /** * FNXC:TaskDetailChat 2026-06-19-22:54: * The task-detail chat must never silently accept a question when no agent session will consume it. Keep idle chats sendable, but surface that the message is saved as guidance for the next task run instead of implying a live reply. + * + * FNXC:TaskDetailChat 2026-06-22-21:20: + * The idle "No agent is working on this task right now…" hint is suppressed (empty) per user request — idle chats stay sendable but no longer show the banner. Done/active hints remain. The render gates on a truthy sessionHint, so the empty idle case renders nothing. */ const sessionHint = isDoneTask ? t("taskChat.doneSessionHint", "Send a message to start a refinement task for this completed task.") : activeSession ? t("taskChat.activeSessionHint", "Message the active agent session. Guidance is delivered to the running session in real time.") - : t("taskChat.idleSessionHint", "No agent is working on this task right now. Your message is saved as guidance and will reach an agent the next time this task runs."); + : ""; const composerPlaceholder = isDoneTask ? t("taskChat.donePlaceholder", "Start a refinement task for this completed task") : t("taskChat.activePlaceholder", "Steer the currently executing agent"); diff --git a/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx b/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx index 874e8e1c16..bbca4701d6 100644 --- a/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx @@ -168,11 +168,9 @@ function expectTranscriptTextOrder(...texts: string[]) { } function expectIdleSessionHint() { - const idleHint = screen.getByTestId("task-chat-idle-hint"); - expect(idleHint).toBeVisible(); - expect(idleHint).toHaveTextContent(/no agent is working on this task right now/i); - expect(idleHint).toHaveTextContent(/saved as guidance/i); - expect(idleHint).toHaveTextContent(/next time this task runs/i); + // FNXC:TaskDetailChat 2026-06-22-21:20: The idle "No agent is working…" banner was removed per user request — idle chats stay sendable with no hint shown. + expect(screen.queryByTestId("task-chat-idle-hint")).not.toBeInTheDocument(); + expect(screen.queryByText(/no agent is working on this task right now/i)).not.toBeInTheDocument(); expect(screen.getByPlaceholderText("Steer the currently executing agent")).toBeInTheDocument(); } From 19be91c9f85d6f86f35f9ba28421d68371c84158 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 05:33:10 -0700 Subject: [PATCH 05/11] feat(dashboard): shared bring-to-front z-stack for all floating modals; fix TaskChatTab tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Floating modals (FloatingWindow, right-dock pop-out, terminal, New Task) now share one z-index stack (floatingWindowStack) — tapping any one raises it above all others regardless of type. Floating overlays reset to z-index:auto so panels interleave in the shared 4000+ band. - TaskChatTab tests: the test i18n now resolves the entryCount/toolCallCount plural forms (production already had them) so '2 entries'/'7 tool calls' render; fixed stale 'Tool call → result/error' casing to match the rendered 'Result'/'Error'. All 179 pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/unified-floating-window-stack.md | 5 ++ .../app/components/FloatingWindow.tsx | 19 +++---- .../dashboard/app/components/NewTaskModal.css | 8 +++ .../dashboard/app/components/NewTaskModal.tsx | 10 +++- .../dashboard/app/components/RightDock.css | 5 ++ .../app/components/RightDockExpandModal.tsx | 14 ++++- .../app/components/TerminalModal.css | 8 +++ .../app/components/TerminalModal.tsx | 13 +++++ .../FloatingWindowStack.cross-type.test.tsx | 52 +++++++++++++++++++ .../components/__tests__/TaskChatTab.test.tsx | 4 +- .../app/components/floatingWindowStack.ts | 17 ++++++ packages/dashboard/vitest.setup.ts | 24 ++++++++- 12 files changed, 163 insertions(+), 16 deletions(-) create mode 100644 .changeset/unified-floating-window-stack.md create mode 100644 packages/dashboard/app/components/__tests__/FloatingWindowStack.cross-type.test.tsx create mode 100644 packages/dashboard/app/components/floatingWindowStack.ts diff --git a/.changeset/unified-floating-window-stack.md b/.changeset/unified-floating-window-stack.md new file mode 100644 index 0000000000..046cc9c26b --- /dev/null +++ b/.changeset/unified-floating-window-stack.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Floating modals (the reusable FloatingWindow, the right-dock pop-out, the floating terminal, and the floating New Task dialog) now share a single z-index stack, so tapping any of them brings it to the front above all the others regardless of type. diff --git a/packages/dashboard/app/components/FloatingWindow.tsx b/packages/dashboard/app/components/FloatingWindow.tsx index 5d4fe35940..57dff2a1d8 100644 --- a/packages/dashboard/app/components/FloatingWindow.tsx +++ b/packages/dashboard/app/components/FloatingWindow.tsx @@ -9,6 +9,7 @@ import { } from "react"; import { createPortal } from "react-dom"; import { X } from "lucide-react"; +import { nextFloatingZ, currentFloatingZ } from "./floatingWindowStack"; import "./FloatingWindow.css"; /* @@ -46,13 +47,9 @@ const DEFAULT_MIN_HEIGHT = 280; const VIEWPORT_PADDING = 16; /* -FNXC:FloatingWindow 2026-06-22-20:45: -Base z-index band sits at 4000+, above ordinary page content and interoperable with the existing terminal/right-dock pop-out band. `nextZ()` bumps the shared counter so a freshly mounted or freshly clicked window comes to the front. The counter is module-level and intentionally monotonic — it only ever climbs, which is fine for a session-length dashboard. +FNXC:FloatingWindow 2026-06-22-21:30: +Z-index now comes from the SHARED `floatingWindowStack` module (`nextFloatingZ`/`currentFloatingZ`) so FloatingWindow stacks in ONE counter with the right-dock pop-out, the floating terminal, and the floating New Task dialog — tapping ANY of them raises it above all the others regardless of type. The local `topZ`/`nextZ` counter this file previously owned is gone. */ -let topZ = 4000; -function nextZ(): number { - return ++topZ; -} type ResizeDirection = "n" | "s" | "e" | "w" | "ne" | "nw" | "se" | "sw"; const RESIZE_DIRECTIONS: ResizeDirection[] = ["n", "s", "e", "w", "ne", "nw", "se", "sw"]; @@ -113,8 +110,8 @@ export function FloatingWindow({ const initialSize = clampSize(defaultSize ?? { width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT }, resolvedMinSize); return defaultPosition ? clampPosition(defaultPosition, initialSize) : defaultPositionFor(windowKey, initialSize); }); - // FNXC:FloatingWindow 2026-06-22-20:45: Each window owns its z-index; mounting claims the front of the stack. - const [zIndex, setZIndex] = useState(() => nextZ()); + // FNXC:FloatingWindow 2026-06-22-21:30: Each window owns its z-index; mounting claims the front of the SHARED cross-type stack. + const [zIndex, setZIndex] = useState(() => nextFloatingZ()); /* FNXC:FloatingWindow 2026-06-22-20:45: @@ -122,12 +119,12 @@ export function FloatingWindow({ */ const dragTeardownRef = useRef<(() => void) | null>(null); - // FNXC:FloatingWindow 2026-06-22-20:45: Focus-to-front. Pointerdown/focus anywhere on the panel raises this window above the rest. + // FNXC:FloatingWindow 2026-06-22-21:30: Focus-to-front. Pointerdown/focus anywhere on the panel raises this window above ALL other floating modals (any type) via the shared stack. const bringToFront = useCallback(() => { setZIndex((current) => { // Only claim a new z if we are not already on top, to avoid needless counter churn on every move. - if (current > topZ) return current; - return nextZ(); + if (current >= currentFloatingZ()) return current; + return nextFloatingZ(); }); }, []); diff --git a/packages/dashboard/app/components/NewTaskModal.css b/packages/dashboard/app/components/NewTaskModal.css index 5d8a0a496e..887cae1453 100644 --- a/packages/dashboard/app/components/NewTaskModal.css +++ b/packages/dashboard/app/components/NewTaskModal.css @@ -16,6 +16,14 @@ The New Task dialog is a FLOATING, DRAGGABLE, RESIZABLE, NON-BLOCKING window (mi pointer-events: none; } +/* +FNXC:FloatingWindow 2026-06-22-21:30: +Only the desktop FLOATING New Task dialog joins the shared cross-type floating stack. When the overlay hosts the floating panel, reset the base `.modal-overlay` z-index:100 to auto so it does NOT establish a stacking context; the panel's inline z-index (from floatingWindowStack, 4000+) then interleaves at the root with the terminal, the right-dock pop-out, and FloatingWindow. The mobile full-screen sheet (no `--floating` panel) keeps the base overlay z-index:100 so it still paints above page content. +*/ +.modal-overlay.new-task-modal-overlay:has(.new-task-modal--floating) { + z-index: auto; +} + /* FNXC:NewTask 2026-06-22-20:30: Floating panel positioned by state-driven inline left/top/width/height. min/max keep content usable and the panel on-screen; `resize: none` because the corner/edge handles own resizing (the native grip conflicts with the pointer handlers). `pointer-events: auto` re-enables interaction on the panel only. Desktop only — mobile keeps the full-screen keyboard-aware sheet. diff --git a/packages/dashboard/app/components/NewTaskModal.tsx b/packages/dashboard/app/components/NewTaskModal.tsx index 0c39156399..be8664db1f 100644 --- a/packages/dashboard/app/components/NewTaskModal.tsx +++ b/packages/dashboard/app/components/NewTaskModal.tsx @@ -17,6 +17,7 @@ import { useMobileScrollLock } from "../hooks/useMobileScrollLock"; import { useNodes } from "../hooks/useNodes"; import { useViewportMode } from "../hooks/useViewportMode"; import { useAgentsMapCache } from "../hooks/useAgentsMapCache"; +import { nextFloatingZ, currentFloatingZ } from "./floatingWindowStack"; interface NewTaskModalProps { isOpen: boolean; @@ -147,6 +148,11 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, const [size, setSizeState] = useState(() => readFloatSize()); const [position, setPositionState] = useState(() => readFloatPosition(readFloatSize())); const dragTeardownRef = useRef<(() => void) | null>(null); + // FNXC:FloatingWindow 2026-06-22-21:30: Floating (desktop) New Task dialog shares the SINGLE cross-type floating z-index stack (floatingWindowStack). Mounting claims the front; tapping the panel (pointerdown/focus capture) raises it above every other floating modal regardless of type. Mobile keeps the full-screen sheet so this z-index is harmless there. + const [zIndex, setZIndex] = useState(() => nextFloatingZ()); + const bringToFront = useCallback(() => { + setZIndex((current) => (current >= currentFloatingZ() ? current : nextFloatingZ())); + }, []); const persistSize = useCallback((next: FloatSize) => { setSizeState(writeFloatSize(next)); @@ -737,7 +743,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, // FNXC:NewTask 2026-06-22-20:30: Desktop = floating fixed panel positioned by state-driven left/top/width/height. Mobile keeps the keyboard-aware full-screen sheet (no floating). The transparent click-through overlay never dismisses on click; the header X / Cancel / Escape are the only dismissals. const panelStyle: CSSProperties = isFloating - ? { left: `${position.x}px`, top: `${position.y}px`, width: `${size.width}px`, height: `${size.height}px` } + ? { left: `${position.x}px`, top: `${position.y}px`, width: `${size.width}px`, height: `${size.height}px`, zIndex } : keyboardStyle; return ( @@ -752,6 +758,8 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
{isFloating && NEW_TASK_RESIZE_DIRECTIONS.map((direction) => (
(() => readExpandSize()); const [position, setPositionState] = useState(() => readExpandPosition(readExpandSize())); + // FNXC:FloatingWindow 2026-06-22-21:30: The right-dock pop-out shares the SINGLE cross-type floating z-index stack (floatingWindowStack). Mounting claims the front; tapping the panel (pointerdown/focus capture) raises it above every other floating modal regardless of type. + const [zIndex, setZIndex] = useState(() => nextFloatingZ()); + const bringToFront = useCallback(() => { + setZIndex((current) => (current >= currentFloatingZ() ? current : nextFloatingZ())); + }, []); /* FNXC:RightDock 2026-06-22-17:40: @@ -294,11 +300,17 @@ export function RightDockExpandModal({ top: `${position.y}px`, width: `${size.width}px`, height: `${size.height}px`, + zIndex, } as CSSProperties; return (
-
+
{EXPAND_RESIZE_DIRECTIONS.map((direction) => (
isTerminalMobileViewport()); const isDockedMode = !isMobileTerminal && displayMode === "docked"; const isFloatingMode = !isMobileTerminal && displayMode === "floating"; + // FNXC:FloatingWindow 2026-06-22-21:30: The FLOATING terminal shares the SINGLE cross-type floating z-index stack (floatingWindowStack) so tapping it raises it above every other floating modal regardless of type. A fresh z is claimed each time the modal opens (see effect below); tapping the panel (pointerdown/focus capture) re-raises it. Docked/mobile modes ignore this z-index (full-width bottom panel / full-screen sheet). + const [floatingZ, setFloatingZ] = useState(() => nextFloatingZ()); + const bringFloatingToFront = useCallback(() => { + if (!isFloatingMode) return; + setFloatingZ((current) => (current >= currentFloatingZ() ? current : nextFloatingZ())); + }, [isFloatingMode]); const terminalRef = useRef(null); const modalRef = useRef(null); @@ -711,9 +718,11 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG // Bump open generation whenever the modal opens so the initialCommand // effect re-evaluates after a close/reopen cycle (deps may be identical). + // FNXC:FloatingWindow 2026-06-22-21:30: Each open also claims the front of the shared floating-window stack so a freshly-opened floating terminal sits above other floating modals. useEffect(() => { if (isOpen) { setOpenGeneration((g) => g + 1); + setFloatingZ(nextFloatingZ()); } }, [isOpen]); @@ -1758,6 +1767,8 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG "--terminal-float-y": `${floatingPosition.y}px`, "--terminal-float-width": `${floatingSize.width}px`, "--terminal-float-height": `${floatingSize.height}px`, + // FNXC:FloatingWindow 2026-06-22-21:30: Inline z from the shared cross-type stack; only the floating panel participates. + zIndex: floatingZ, } : {}), } as CSSProperties; @@ -1783,6 +1794,8 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG className={modalClassName} data-testid="terminal-modal" style={modalStyle} + onPointerDownCapture={isFloatingMode ? bringFloatingToFront : undefined} + onFocusCapture={isFloatingMode ? bringFloatingToFront : undefined} > {isDockedMode && (
{}, projectId: "project-1" } as const; + +describe("floatingWindowStack (cross-type)", () => { + it("hands out a strictly increasing, shared z to every claimant", () => { + const a = nextFloatingZ(); + const b = nextFloatingZ(); + expect(b).toBeGreaterThan(a); + expect(currentFloatingZ()).toBe(b); + }); + + it("tapping a FloatingWindow raises it above a right-dock pop-out opened after it (and vice versa)", () => { + render( + <> + {}}> +
fw body
+
+ {}} /> + , + ); + + const fwPanel = screen.getByTestId("floating-window-fw"); + const dockPanel = screen + .getByTestId("right-dock-expand-modal") + .querySelector(".right-dock-expand-modal--floating") as HTMLElement; + + // Both carry an inline z-index from the shared stack. + expect(fwPanel.style.zIndex).not.toBe(""); + expect(dockPanel.style.zIndex).not.toBe(""); + + // The dock pop-out mounted last → it starts on top of the FloatingWindow, proving one shared stack. + expect(Number(dockPanel.style.zIndex)).toBeGreaterThan(Number(fwPanel.style.zIndex)); + + // Tapping the older FloatingWindow raises it above the dock pop-out — across the type boundary. + fireEvent.pointerDown(fwPanel); + expect(Number(fwPanel.style.zIndex)).toBeGreaterThan(Number(dockPanel.style.zIndex)); + + // Tapping the dock pop-out raises it back above the FloatingWindow. + fireEvent.pointerDown(dockPanel); + expect(Number(dockPanel.style.zIndex)).toBeGreaterThan(Number(fwPanel.style.zIndex)); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx b/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx index bbca4701d6..15443690bd 100644 --- a/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx @@ -670,7 +670,7 @@ describe("TaskChatTab", () => { expect(toolGroup).toHaveAttribute("open"); const invocation = screen.getByTestId("task-chat-tool-invocation"); - const kicker = screen.getByText("Tool call → result"); + const kicker = screen.getByText("Tool call → Result"); expect(invocation).toHaveClass("task-chat-tool-entry", "task-chat-tool-invocation"); expect(kicker).toHaveClass("task-chat-entry-kicker"); expect(kicker).toBeVisible(); @@ -727,7 +727,7 @@ describe("TaskChatTab", () => { await user.click(within(summary as HTMLElement).getByText("1 tool call")); - expect(screen.getByText("Tool call → error")).toBeVisible(); + expect(screen.getByText("Tool call → Error")).toBeVisible(); expect(screen.getByText("Error")).toBeVisible(); expect(screen.getByText("stderr")).toBeVisible(); }); diff --git a/packages/dashboard/app/components/floatingWindowStack.ts b/packages/dashboard/app/components/floatingWindowStack.ts new file mode 100644 index 0000000000..9fa9aa02d9 --- /dev/null +++ b/packages/dashboard/app/components/floatingWindowStack.ts @@ -0,0 +1,17 @@ +/* +FNXC:FloatingWindow 2026-06-22-21:30: +SHARED floating-window z-index stack. This is the ONE source of z-index for every floating modal in the dashboard (FloatingWindow, the right-dock pop-out, the floating terminal, the floating New Task dialog) so they interoperate in a SINGLE stack instead of each type owning a private counter. Previously each modal type managed z-index independently, so tapping e.g. the terminal could not raise it above a popped-out task-detail FloatingWindow. Now every floating modal claims `nextFloatingZ()` on mount/open and again on every panel pointerdown/focus, so the most-recently-interacted window is always on top REGARDLESS of type. + +Base band sits at 4000+ — above ordinary page content and above the base `.modal-overlay` (z-index 100). The counter is module-level and intentionally monotonic: it only ever climbs, which is fine for a session-length dashboard. All floating overlays are `pointer-events: none` (click-through) so raising panels into this shared band never traps clicks on the page behind them. +*/ +let topZ = 4000; + +/** Claim the front of the shared floating-window stack. Monotonic, session-length. */ +export function nextFloatingZ(): number { + return ++topZ; +} + +/** Current top of the stack (read-only). Lets a window skip a needless bump when already on top. */ +export function currentFloatingZ(): number { + return topZ; +} diff --git a/packages/dashboard/vitest.setup.ts b/packages/dashboard/vitest.setup.ts index 1b651cb65e..c2b1471981 100644 --- a/packages/dashboard/vitest.setup.ts +++ b/packages/dashboard/vitest.setup.ts @@ -16,7 +16,29 @@ await i18next.use(initReactI18next).init({ // Each namespace present (empty) so hasLoadedNamespace() is true — an // unloaded namespace makes useTranslation() suspend (no Suspense boundary // in component tests) even with useSuspense disabled belt-and-braces below. - resources: { en: { common: {}, app: {}, errors: {} } }, + // + // FNXC:TestI18n 2026-06-22-21:40: + // Pluralized count keys must resolve from resources, not the singular inline + // default. t("taskChat.entryCount", "{{count}} entry", { count }) renders the + // singular default for ALL counts when the key is absent — so count=2 became + // "2 entry". Provide the _one/_other forms (as the real en locale does) so the + // correct plural ("2 entries", "7 tool calls") renders in tests too. Only these + // keys resolve from the bundle; every other key still falls back to its inline + // default, preserving existing assertions. + resources: { + en: { + common: {}, + app: { + taskChat: { + entryCount_one: "{{count}} entry", + entryCount_other: "{{count}} entries", + toolCallCount_one: "{{count}} tool call", + toolCallCount_other: "{{count}} tool calls", + }, + }, + errors: {}, + }, + }, ns: ["common", "app", "errors"], defaultNS: "common", interpolation: { escapeValue: false }, From 56c4d8be7c1ef59f95102b0f60feb1321756260e Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 05:39:10 -0700 Subject: [PATCH 06/11] fix(dashboard): terminal renders + shortcut bar scrolls on narrow/folded mobile - Spaced-glyph rendering on fold: FitAddon could fit while the container width was mid-fold-transition, computing a wrong cell width. Add a deferred rAF re-fit once width settles + an orientationchange listener so columns re-settle after a fold/rotate. - Shortcut bar now scrolls horizontally: keys are flex:0 0 auto (kept intrinsic width so the row overflows) with touch-action:pan-x + overscroll-behavior-x:contain + momentum scroll. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../app/components/TerminalModal.css | 12 +++++ .../app/components/TerminalModal.tsx | 50 +++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/packages/dashboard/app/components/TerminalModal.css b/packages/dashboard/app/components/TerminalModal.css index 269d0ba8ed..2f62a06ec2 100644 --- a/packages/dashboard/app/components/TerminalModal.css +++ b/packages/dashboard/app/components/TerminalModal.css @@ -833,6 +833,13 @@ The shortcut bar (modifier keys + arrow keys) must sit on ONE line, not stack in background: var(--surface); border-top: 1px solid var(--border); overflow-x: auto; + /* + FNXC:Terminal 2026-06-22-22:00: + On a narrow folded phone the modifier/arrow/letter keys exceed the viewport width, so the bar MUST scroll horizontally to keep every button reachable. touch-action: pan-x lets a horizontal swipe scroll the row (instead of the browser hijacking it as a page gesture), overscroll-behavior-x: contain stops the swipe from bleeding into page/back-navigation at the ends, and -webkit-overflow-scrolling: touch gives momentum scroll on iOS. + */ + touch-action: pan-x; + overscroll-behavior-x: contain; + -webkit-overflow-scrolling: touch; } .terminal-shortcut-modifier-row, @@ -847,6 +854,11 @@ The shortcut bar (modifier keys + arrow keys) must sit on ONE line, not stack in display: inline-flex; align-items: center; justify-content: center; + /* + FNXC:Terminal 2026-06-22-22:00: + Keys keep their intrinsic width and never shrink/grow, so the row's total width exceeds a narrow viewport and the panel's overflow-x: auto produces a real horizontal scroll reaching the rightmost buttons. flex:1 / width:100% here would collapse every key to fit the viewport and defeat the scroll. + */ + flex: 0 0 auto; min-width: calc(var(--space-xl) + var(--space-xs)); min-height: calc(var(--space-xl) + var(--space-xs)); padding: 0 var(--space-xs); diff --git a/packages/dashboard/app/components/TerminalModal.tsx b/packages/dashboard/app/components/TerminalModal.tsx index 599a6de024..b9ba57a256 100644 --- a/packages/dashboard/app/components/TerminalModal.tsx +++ b/packages/dashboard/app/components/TerminalModal.tsx @@ -705,6 +705,33 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG return; } + /* + FNXC:Terminal 2026-06-22-22:00: + On a very narrow folded phone the fold/orientation transition can fire a resize while the xterm container momentarily reports a transient sub-pixel width. We still call fit() (FitAddon no-ops at 0 width, so it can never collapse columns there), but when the container reports a real nonzero width we ALSO schedule one deferred re-fit so the column count re-settles after the fold geometry stabilizes to its final integer box — that deferred pass is what reflows the narrow terminal back to contiguous text instead of the wide-cell "C o p i e d" spaced render. The width probe is read-only and only adds the extra rAF, so jsdom (clientWidth 0) keeps its single synchronous fit and existing tests are unaffected. + */ + const containerWidth = terminalRef.current?.clientWidth ?? 0; + if (containerWidth > 0) { + if (pendingFitRef.current !== null) { + cancelAnimationFrame(pendingFitRef.current); + } + pendingFitRef.current = requestAnimationFrame(() => { + pendingFitRef.current = null; + if ( + (!expectedSessionId || xtermInitializedRef.current === expectedSessionId) && + fitAddonRef.current && + xtermRef.current && + (terminalRef.current?.clientWidth ?? 0) > 0 + ) { + try { + (fitAddonRef.current as InstanceType).fit(); + resizeRef.current?.(xtermRef.current.cols, xtermRef.current.rows); + } catch { + // Ignore fit errors during viewport transitions + } + } + }); + } + try { const fitAddon = currentFitAddon as InstanceType; fitAddon.fit(); @@ -780,10 +807,16 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG update(); // initial measurement vv.addEventListener("resize", update); vv.addEventListener("scroll", update); + /* + FNXC:Terminal 2026-06-22-22:00: + Folding/unfolding a foldable phone (and rotating) changes the terminal's available width without always emitting a visualViewport resize at the settled width. Listen to orientationchange too so xterm re-fits to the new narrow/wide column count after the fold completes; the deferred-fit guard in fitAndResizeForSession ensures the fit only lands once the container has a real width. + */ + window.addEventListener("orientationchange", update); return () => { vv.removeEventListener("resize", update); vv.removeEventListener("scroll", update); + window.removeEventListener("orientationchange", update); // Cancel any pending deferred fit if (pendingFitRef.current !== null) { cancelAnimationFrame(pendingFitRef.current); @@ -1086,6 +1119,23 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG // Initial fit setTimeout(() => { fitAddon.fit(); + // FNXC:Terminal 2026-06-22-22:00: After the first synchronous fit, schedule one deferred re-fit so a terminal opened mid-fold (narrow foldable, where the container width has not settled to its final integer box yet) re-measures columns once layout stabilizes — preventing the collapsed-column spaced-glyph render. Guarded by container width and live session so jsdom/tab-teardown paths stay no-ops. + if ((terminalRef.current?.clientWidth ?? 0) > 0) { + requestAnimationFrame(() => { + if ( + xtermInitializedRef.current === currentSessionId && + fitAddonRef.current === fitAddon && + (terminalRef.current?.clientWidth ?? 0) > 0 + ) { + try { + fitAddon.fit(); + resizeRef.current?.(terminal.cols, terminal.rows); + } catch { + // Ignore fit errors during viewport transitions + } + } + }); + } // Re-focus after fit in case the DOM changed const textarea = terminalRef.current?.querySelector( ".xterm-helper-textarea", From 1ced957dbf14b9801f930eb545ce4b827cdb84d8 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 05:52:44 -0700 Subject: [PATCH 07/11] fix(dashboard): portal all floating modals + lift z-band so they stack above page overlays and tap-to-front works cross-type Root causes of modals being overlapped + tap-to-front not working across types: - Only FloatingWindow was portaled; the dock pop-out, terminal, and New Task rendered inline in their own stacking contexts, so the shared z-counter could not order them across types. Portal all three to document.body so all four floating modals share the one root stacking context. - The floating band started at 4000, below page overlays/popovers at 10000-10001 (log viewer, workflow editor, selection popover, fullscreen overlay) which painted over the modals. Raise the shared band base to 10100. Toasts already sit at 200 (below), unaffected. - Update RightDock dock-toggle test to the current behavior (popping out closes the dock; the floating modal survives) and fix NewTaskModal keyboard-vars test to query the portaled modal from document. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/dashboard/app/components/NewTaskModal.tsx | 7 +++++-- .../app/components/RightDockExpandModal.tsx | 7 +++++-- packages/dashboard/app/components/TerminalModal.tsx | 7 +++++-- .../app/components/__tests__/NewTaskModal.test.tsx | 5 +++-- .../app/components/__tests__/RightDock.test.tsx | 12 +++++++----- .../dashboard/app/components/floatingWindowStack.ts | 5 +++-- 6 files changed, 28 insertions(+), 15 deletions(-) diff --git a/packages/dashboard/app/components/NewTaskModal.tsx b/packages/dashboard/app/components/NewTaskModal.tsx index be8664db1f..197b1a2369 100644 --- a/packages/dashboard/app/components/NewTaskModal.tsx +++ b/packages/dashboard/app/components/NewTaskModal.tsx @@ -1,5 +1,6 @@ import "./NewTaskModal.css"; import { useState, useCallback, useEffect, useRef, type CSSProperties, type PointerEvent as ReactPointerEvent } from "react"; +import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { DEFAULT_TASK_PRIORITY, type Task, type TaskCreateInput, type TaskPriority } from "@fusion/core"; import { getErrorMessage } from "@fusion/core"; @@ -746,7 +747,8 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, ? { left: `${position.x}px`, top: `${position.y}px`, width: `${size.width}px`, height: `${size.height}px`, zIndex } : keyboardStyle; - return ( + // FNXC:FloatingWindow 2026-06-22-22:30: Portaled to document.body so the floating New Task dialog shares the ONE root stacking context with the other floating modals; the shared cross-type z stack only orders correctly at the document root. Mobile sheet is position:fixed, unaffected. + return createPortal(
-
+
, + document.body, ); } diff --git a/packages/dashboard/app/components/RightDockExpandModal.tsx b/packages/dashboard/app/components/RightDockExpandModal.tsx index 1fab358995..915d6ed1ee 100644 --- a/packages/dashboard/app/components/RightDockExpandModal.tsx +++ b/packages/dashboard/app/components/RightDockExpandModal.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent, type RefObject } from "react"; +import { createPortal } from "react-dom"; import { Maximize2, X } from "lucide-react"; import { findOverflowViewEntry, type OverflowViewEntry, type OverflowViewKey, type OverflowViewRenderProps, type OverflowViewVisibilityOptions } from "./overflowViewRegistry"; import { nextFloatingZ, currentFloatingZ } from "./floatingWindowStack"; @@ -303,7 +304,8 @@ export function RightDockExpandModal({ zIndex, } as CSSProperties; - return ( + // FNXC:FloatingWindow 2026-06-22-22:30: Portaled to document.body so this floating modal shares the ONE root stacking context with the other floating modals (FloatingWindow/terminal/New Task) — the shared 10100+ z stack only orders correctly across types when they all live at the document root. + return createPortal(
-
+
, + document.body, ); } diff --git a/packages/dashboard/app/components/TerminalModal.tsx b/packages/dashboard/app/components/TerminalModal.tsx index b9ba57a256..f5dacbda71 100644 --- a/packages/dashboard/app/components/TerminalModal.tsx +++ b/packages/dashboard/app/components/TerminalModal.tsx @@ -1,4 +1,5 @@ import "./TerminalModal.css"; +import { createPortal } from "react-dom"; import { useState, useEffect, @@ -1823,7 +1824,8 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG : {}), } as CSSProperties; - return ( + // FNXC:FloatingWindow 2026-06-22-22:30: Portaled to document.body so the terminal shares the ONE root stacking context with the other floating modals; the shared cross-type z stack only orders correctly when all panels live at the document root. Docked/floating/mobile are all position:fixed, so portaling does not change their placement. + return createPortal(
-
+
, + document.body, ); } diff --git a/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx b/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx index fe05ffc396..17457a03c6 100644 --- a/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx @@ -113,8 +113,9 @@ describe("NewTaskModal", () => { viewportOffsetTop: 50, }); - const { container } = renderNewTaskModal(); - const modal = container.querySelector(".new-task-modal"); + renderNewTaskModal(); + // FNXC: NewTaskModal portals to document.body, so query the modal from document (not the render container). + const modal = document.querySelector(".new-task-modal"); expect(mockUseMobileKeyboard).toHaveBeenCalledWith({ enabled: true }); expect(modal?.getAttribute("style")).toContain("--keyboard-overlap: 250px"); diff --git a/packages/dashboard/app/components/__tests__/RightDock.test.tsx b/packages/dashboard/app/components/__tests__/RightDock.test.tsx index 19ee343600..2c5bbca504 100644 --- a/packages/dashboard/app/components/__tests__/RightDock.test.tsx +++ b/packages/dashboard/app/components/__tests__/RightDock.test.tsx @@ -347,16 +347,18 @@ describe("RightDock", () => { render(); - // Pop out the currently selected (Files) view from the open dock. + // Pop out the currently selected (Files) view: the floating modal appears AND + // popping out closes the dock (pop-out dismisses the dock so the full-width app + // sits behind the movable modal). The dock unmounts; the floating modal survives. fireEvent.click(screen.getByTestId("right-dock-expand")); expect(screen.getByTestId("right-dock-expand-modal")).toBeInTheDocument(); - - // Toggle the dock closed: the dock itself unmounts, the floating modal MUST survive. - fireEvent.click(screen.getByTestId("harness-toggle-dock")); expect(screen.queryByTestId("right-dock")).toBeNull(); - expect(screen.getByTestId("right-dock-expand-modal")).toBeInTheDocument(); expect(screen.getByTestId("right-dock-expand-body")).toBeInTheDocument(); + // Re-opening the dock does not disturb the independent floating modal. + fireEvent.click(screen.getByTestId("harness-toggle-dock")); + expect(screen.getByTestId("right-dock-expand-modal")).toBeInTheDocument(); + // Its own close button still dismisses it. fireEvent.click(screen.getByTestId("right-dock-expand-close")); expect(screen.queryByTestId("right-dock-expand-modal")).toBeNull(); diff --git a/packages/dashboard/app/components/floatingWindowStack.ts b/packages/dashboard/app/components/floatingWindowStack.ts index 9fa9aa02d9..d2e8917a48 100644 --- a/packages/dashboard/app/components/floatingWindowStack.ts +++ b/packages/dashboard/app/components/floatingWindowStack.ts @@ -2,9 +2,10 @@ FNXC:FloatingWindow 2026-06-22-21:30: SHARED floating-window z-index stack. This is the ONE source of z-index for every floating modal in the dashboard (FloatingWindow, the right-dock pop-out, the floating terminal, the floating New Task dialog) so they interoperate in a SINGLE stack instead of each type owning a private counter. Previously each modal type managed z-index independently, so tapping e.g. the terminal could not raise it above a popped-out task-detail FloatingWindow. Now every floating modal claims `nextFloatingZ()` on mount/open and again on every panel pointerdown/focus, so the most-recently-interacted window is always on top REGARDLESS of type. -Base band sits at 4000+ — above ordinary page content and above the base `.modal-overlay` (z-index 100). The counter is module-level and intentionally monotonic: it only ever climbs, which is fine for a session-length dashboard. All floating overlays are `pointer-events: none` (click-through) so raising panels into this shared band never traps clicks on the page behind them. +FNXC:FloatingWindow 2026-06-22-22:30: +Base band sits at 10100+ — ABOVE the page overlay/popover band (log viewer, workflow-editor modal, selection popover, fullscreen overlay at z 10000-10001) so a floating window the user is dragging is never painted over by those. Transient top-right toasts are bumped to 10500 (styles.css) so system feedback still shows above a dragged window. The counter is module-level and intentionally monotonic: it only ever climbs, which is fine for a session-length dashboard. All floating overlays are `pointer-events: none` (click-through) so raising panels into this shared band never traps clicks on the page behind them. CRITICAL: every floating modal must be portaled to document.body so this shared z is compared in ONE root stacking context (an inline panel cannot beat siblings outside its own context no matter its z). */ -let topZ = 4000; +let topZ = 10100; /** Claim the front of the shared floating-window stack. Monotonic, session-length. */ export function nextFloatingZ(): number { From 296fb553b87a2bd87fe71e1e063f54f896232a72 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 08:57:40 -0700 Subject: [PATCH 08/11] test(dashboard): update gm-mobile + mailbox-divider CSS regression assertions - GitManagerModal mobile tab strip is now icon-only one-row: assert non-shrink via flex:0 0 auto + width:auto (was min-height for the old icon+label layout). - MailboxView split-pane divider now mirrors the Chat divider: handle width var(--space-sm) + transparent background, ::before line var(--space-xs) (was --space-xs handle + color-mix bg). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../app/components/__tests__/GitManagerModal.test.tsx | 3 ++- .../app/components/__tests__/MailboxView.test.tsx | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx b/packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx index abddeb8eb9..ac0f842ff7 100644 --- a/packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx @@ -3417,8 +3417,9 @@ describe("GitManagerModal", () => { const navItemRules = getRuleBlocks(mobile768, ".gm-nav-item"); expect(navItemRules).toHaveLength(1); + // Mobile tabs are compact ICON-ONLY in one scrolling row: non-shrinking via flex:0 0 auto + intrinsic width:auto (overrides the base .gm-nav-item width:100% that otherwise made one tab fill the row). expect(navItemRules[0]).toContain("flex: 0 0 auto;"); - expect(navItemRules[0]).toContain("min-height: calc(var(--space-xl) + var(--space-sm));"); + expect(navItemRules[0]).toContain("width: auto;"); expect(mobile720).not.toContain(".gm-sidebar"); expect(mobile720).not.toContain(".gm-nav-item"); diff --git a/packages/dashboard/app/components/__tests__/MailboxView.test.tsx b/packages/dashboard/app/components/__tests__/MailboxView.test.tsx index 9f30123ac7..43be43816b 100644 --- a/packages/dashboard/app/components/__tests__/MailboxView.test.tsx +++ b/packages/dashboard/app/components/__tests__/MailboxView.test.tsx @@ -1884,13 +1884,14 @@ describe("MailboxView", () => { const resizeHandleBlockMatch = css.match(/\.mailbox-view\s+\.mailbox-split-resize-handle\s*\{([^}]*)\}/); expect(resizeHandleBlockMatch).toBeTruthy(); const resizeHandleBlock = resizeHandleBlockMatch![1]; - expect(resizeHandleBlock).toContain("width: var(--space-xs);"); + // FNXC:Mailbox 2026-06-22-18:20: handle mirrors the Chat sidebar divider — hit area var(--space-sm), transparent until hover, centered var(--space-xs) line. + expect(resizeHandleBlock).toContain("width: var(--space-sm);"); expect(resizeHandleBlock).toContain("cursor: col-resize;"); - expect(resizeHandleBlock).toContain("background: color-mix(in srgb, var(--border) 70%, transparent);"); + expect(resizeHandleBlock).toContain("background: transparent;"); const resizeHandleTargetBlockMatch = css.match(/\.mailbox-view\s+\.mailbox-split-resize-handle::before\s*\{([^}]*)\}/); expect(resizeHandleTargetBlockMatch).toBeTruthy(); - expect(resizeHandleTargetBlockMatch![1]).toContain("width: var(--space-sm);"); + expect(resizeHandleTargetBlockMatch![1]).toContain("width: var(--space-xs);"); expect(css).toMatch(/\.mailbox-view\s+\.mailbox-split-resize-handle:hover::before,\s*\n\.mailbox-view\s+\.mailbox-split-resize-handle:active::before\s*\{[^}]*background:\s*color-mix\(in srgb,\s*var\(--todo\)\s*35%,\s*transparent\);[^}]*\}/); const splitEmptyBlockMatch = css.match(/\.mailbox-view\s+\.mailbox-split-empty\s*\{([^}]*)\}/); From 0b34994ae4916b5ccd85adfecfc897fec8fcf14b Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 09:14:51 -0700 Subject: [PATCH 09/11] fix(dashboard): put floating-modal z-index on the overlay, not the panel (fixes overlap + tap-to-front) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified against the live DOM: a panel z-index is trapped inside the position:fixed overlay's stacking context, so page elements that are themselves stacking contexts in body (the right dock at position:absolute z-index:20, card badge contexts) painted OVER the modal, and tap-to-front (which raised the panel z) had no effect at the body level. Move the dynamic shared-stack z onto each modal's fixed overlay (FloatingWindow, RightDockExpandModal, TerminalModal floating, NewTaskModal floating) so the whole window sits at the shared band in body's stacking context — modals now paint above all page content and tapping reliably brings a window to the front across types. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/dashboard/app/components/FloatingWindow.tsx | 2 ++ packages/dashboard/app/components/NewTaskModal.tsx | 2 ++ .../app/components/RightDockExpandModal.tsx | 2 +- packages/dashboard/app/components/TerminalModal.tsx | 12 +++++------- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/dashboard/app/components/FloatingWindow.tsx b/packages/dashboard/app/components/FloatingWindow.tsx index 57dff2a1d8..9941767ed6 100644 --- a/packages/dashboard/app/components/FloatingWindow.tsx +++ b/packages/dashboard/app/components/FloatingWindow.tsx @@ -276,6 +276,8 @@ export function FloatingWindow({ role="dialog" aria-modal="false" data-testid={`floating-window-overlay-${windowKey}`} + // FNXC:FloatingWindow 2026-06-22-23:00: The z-index MUST live on the position:fixed overlay (which creates a stacking context), not the panel. A panel z-index is trapped inside the overlay's context and loses to page elements that are stacking contexts in body's context (e.g. the right dock at position:absolute z-index:20). With z on the overlay, the whole window sits at the shared floating band in body's stacking context and reliably paints above page content + tap-to-front reorders correctly. + style={{ zIndex }} >
+
0 - ? { - "--overlay-padding-top": "0px", - } as CSSProperties - : undefined - } + style={{ + // FNXC:FloatingWindow 2026-06-22-23:00: In floating mode the z-index lives on the fixed overlay (it owns the stacking context); a panel z is trapped inside it and loses to page stacking contexts like the right dock (position:absolute z-index:20). Docked/mobile keep their CSS z. + ...(isFloatingMode ? { zIndex: floatingZ } : {}), + ...(keyboardOverlap > 0 ? { "--overlay-padding-top": "0px" } : {}), + } as CSSProperties} >
Date: Mon, 22 Jun 2026 09:25:26 -0700 Subject: [PATCH 10/11] fix(FN-1721): scope GitHub import remotes to project Pass the active projectId when the GitHub import modal detects remotes so multi-project dashboards do not show a false no-remotes state. Ignore stale remote responses when projectId changes while the modal remains open. References: https://github.com/Runfusion/Fusion/issues/1721 --- .changeset/github-import-project-remotes.md | 5 ++ .../app/components/GitHubImportModal.tsx | 25 ++++++-- .../__tests__/GitHubImportModal.test.tsx | 60 ++++++++++++++++++- 3 files changed, 83 insertions(+), 7 deletions(-) create mode 100644 .changeset/github-import-project-remotes.md diff --git a/.changeset/github-import-project-remotes.md b/.changeset/github-import-project-remotes.md new file mode 100644 index 0000000000..b960e8fc67 --- /dev/null +++ b/.changeset/github-import-project-remotes.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Fix Import from GitHub remote detection in multi-project dashboards by passing the active `projectId` to the `/api/git/remotes` lookup. The dialog now lists configured GitHub remotes instead of showing "No GitHub remotes detected" when the backend requires project scope. diff --git a/packages/dashboard/app/components/GitHubImportModal.tsx b/packages/dashboard/app/components/GitHubImportModal.tsx index 34fe32d204..4b4dd1e72f 100644 --- a/packages/dashboard/app/components/GitHubImportModal.tsx +++ b/packages/dashboard/app/components/GitHubImportModal.tsx @@ -86,6 +86,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, const [loadingRemotes, setLoadingRemotes] = useState(false); const [selectedRemoteName, setSelectedRemoteName] = useState(""); const mountedRef = useRef(false); + const remoteLoadRequestIdRef = useRef(0); const modalRef = useRef(null); useModalResizePersist(modalRef, isOpen && resizePersistEnabled, "fusion:github-modal-size"); const overlayDismissProps = useOverlayDismiss(onClose); @@ -153,11 +154,22 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, autoLoadedRef.current = null; mountedRef.current = true; + const remoteLoadRequestId = remoteLoadRequestIdRef.current + 1; + remoteLoadRequestIdRef.current = remoteLoadRequestId; + let cancelled = false; - // Fetch git remotes - fetchGitRemotes() + /* + FNXC:GitHubImport 2026-06-22-09:08: + Import from GitHub must detect remotes for the active project, not the dashboard process fallback. + The remotes API returns an empty list without projectId in multi-project mode, which incorrectly shows "No GitHub remotes detected" for configured repositories. + + FNXC:GitHubImport 2026-06-22-09:22: + Project changes can happen while the modal stays open, so remote discovery must ignore stale responses from earlier projectId requests. + A mounted-only guard is insufficient because the next effect marks the component mounted again before the older request resolves. + */ + fetchGitRemotes(projectId) .then((fetchedRemotes) => { - if (!mountedRef.current) return; + if (cancelled || !mountedRef.current || remoteLoadRequestId !== remoteLoadRequestIdRef.current) return; setRemotes(fetchedRemotes); setLoadingRemotes(false); @@ -179,16 +191,17 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, // If no remotes, owner/repo remain empty }) .catch(() => { - if (mountedRef.current) { + if (!cancelled && mountedRef.current && remoteLoadRequestId === remoteLoadRequestIdRef.current) { setLoadingRemotes(false); } }); return () => { + cancelled = true; mountedRef.current = false; }; } - }, [isOpen]); + }, [isOpen, projectId]); // Handle remote selection change const handleRemoteChange = useCallback((remoteName: string) => { @@ -453,7 +466,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, setImporting(false); } } - }, [activeTab, selectedIssueNumber, selectedPullNumber, owner, repo, onImport, isMobile, mobileView]); + }, [activeTab, selectedIssueNumber, selectedPullNumber, owner, repo, projectId, onImport, isMobile, mobileView]); const selectedIssue = issues.find((i) => i.number === selectedIssueNumber); const selectedPull = pulls.find((p) => p.number === selectedPullNumber); diff --git a/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx b/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx index e86db036ed..3b4b09f2f2 100644 --- a/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, fireEvent, waitFor, within } from "@testing-library/react"; +import { act, render, screen, fireEvent, waitFor, within } from "@testing-library/react"; import { GitHubImportModal } from "../GitHubImportModal"; import { apiFetchGitHubIssues, @@ -294,6 +294,64 @@ describe("GitHubImportModal", () => { }); describe("with single remote", () => { + it("loads remotes using the active project id", async () => { + vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote); + render(); + + await waitFor(() => { + expect(fetchGitRemotes).toHaveBeenCalledWith("project-1"); + }); + }); + + it("ignores stale remote responses after the active project changes", async () => { + const projectARemote: GitRemote[] = [ + { name: "origin", owner: "project-a", repo: "old-repo", url: "https://github.com/project-a/old-repo.git" }, + ]; + const projectBRemote: GitRemote[] = [ + { name: "origin", owner: "project-b", repo: "new-repo", url: "https://github.com/project-b/new-repo.git" }, + ]; + let resolveProjectA!: (value: GitRemote[]) => void; + let resolveProjectB!: (value: GitRemote[]) => void; + vi.mocked(fetchGitRemotes) + .mockImplementationOnce(() => new Promise((resolve) => { + resolveProjectA = resolve; + })) + .mockImplementationOnce(() => new Promise((resolve) => { + resolveProjectB = resolve; + })); + + const { rerender } = render( + , + ); + + await waitFor(() => { + expect(fetchGitRemotes).toHaveBeenCalledWith("project-a"); + }); + + rerender(); + + await waitFor(() => { + expect(fetchGitRemotes).toHaveBeenCalledWith("project-b"); + }); + + await act(async () => { + resolveProjectB(projectBRemote); + }); + + await waitFor(() => { + expect(screen.getByText("project-b/new-repo")).toBeTruthy(); + }); + + await act(async () => { + resolveProjectA(projectARemote); + }); + + await waitFor(() => { + expect(screen.getByText("project-b/new-repo")).toBeTruthy(); + expect(screen.queryByText("project-a/old-repo")).toBeNull(); + }); + }); + it("auto-selects the remote and shows compact pill", async () => { vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote); render(); From 5e0e9c08f361d5ea6dcdbbdcbf57a02684d034e9 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 09:36:05 -0700 Subject: [PATCH 11/11] fix(FN-1721): expose ViewHeader to dashboard plugin build Map the shared dashboard ViewHeader module in Vite, Vitest, and app TS paths so bundled plugin dashboard views resolve the host component during PR build/typecheck. References: https://github.com/Runfusion/Fusion/issues/1721 --- packages/dashboard/tsconfig.app.json | 1 + packages/dashboard/tsconfig.test-check.json | 1 + packages/dashboard/vite.config.ts | 1 + packages/dashboard/vitest.config.ts | 1 + 4 files changed, 4 insertions(+) diff --git a/packages/dashboard/tsconfig.app.json b/packages/dashboard/tsconfig.app.json index daf938a315..67a6312c05 100644 --- a/packages/dashboard/tsconfig.app.json +++ b/packages/dashboard/tsconfig.app.json @@ -10,6 +10,7 @@ "paths": { "node-pty": ["./src/types/node-pty/index.d.ts"], "@fusion/dashboard/app/components/TaskCard": ["./app/components/TaskCard.tsx"], + "@fusion/dashboard/app/components/ViewHeader": ["./app/components/ViewHeader.tsx"], "@fusion/dashboard/app/plugins/types": ["./app/plugins/types.ts"], "@fusion/dashboard/app/utils/projectStorage": ["./app/utils/projectStorage.ts"], "@fusion/dashboard/app/utils/taskStuck": ["./app/utils/taskStuck.ts"] diff --git a/packages/dashboard/tsconfig.test-check.json b/packages/dashboard/tsconfig.test-check.json index 8cecf3ac3f..dabb21b736 100644 --- a/packages/dashboard/tsconfig.test-check.json +++ b/packages/dashboard/tsconfig.test-check.json @@ -11,6 +11,7 @@ "@fusion/test-utils": ["../core/src/__test-utils__/workspace.ts"], "node-pty": ["./src/types/node-pty/index.d.ts"], "@fusion/dashboard/app/components/TaskCard": ["./app/components/TaskCard.tsx"], + "@fusion/dashboard/app/components/ViewHeader": ["./app/components/ViewHeader.tsx"], "@fusion/dashboard/app/plugins/types": ["./app/plugins/types.ts"], "@fusion/dashboard/app/utils/projectStorage": ["./app/utils/projectStorage.ts"], "@fusion/dashboard/app/utils/taskStuck": ["./app/utils/taskStuck.ts"] diff --git a/packages/dashboard/vite.config.ts b/packages/dashboard/vite.config.ts index d49bde4c15..da06e9a570 100644 --- a/packages/dashboard/vite.config.ts +++ b/packages/dashboard/vite.config.ts @@ -125,6 +125,7 @@ export default defineConfig({ alias: { "@fusion/core": resolve(__dirname, "../core/src/types.ts"), "@fusion/dashboard/app/components/TaskCard": resolve(__dirname, "app/components/TaskCard.tsx"), + "@fusion/dashboard/app/components/ViewHeader": resolve(__dirname, "app/components/ViewHeader.tsx"), "@fusion/dashboard/app/plugins/types": resolve(__dirname, "app/plugins/types.ts"), "@fusion/dashboard/app/utils/projectStorage": resolve(__dirname, "app/utils/projectStorage.ts"), "@fusion/dashboard/app/utils/taskStuck": resolve(__dirname, "app/utils/taskStuck.ts"), diff --git a/packages/dashboard/vitest.config.ts b/packages/dashboard/vitest.config.ts index 19a21eb518..c961da3757 100644 --- a/packages/dashboard/vitest.config.ts +++ b/packages/dashboard/vitest.config.ts @@ -424,6 +424,7 @@ export default defineConfig({ "@fusion/plugin-sdk": resolve(__dirname, "../plugin-sdk/src/index.ts"), "@fusion/test-utils": resolve(__dirname, "../core/src/__test-utils__/workspace.ts"), "@fusion/dashboard/app/components/TaskCard": resolve(__dirname, "app/components/TaskCard.tsx"), + "@fusion/dashboard/app/components/ViewHeader": resolve(__dirname, "app/components/ViewHeader.tsx"), "@fusion/dashboard/app/plugins/types": resolve(__dirname, "app/plugins/types.ts"), "@fusion/dashboard/app/utils/projectStorage": resolve(__dirname, "app/utils/projectStorage.ts"), "@fusion/dashboard/app/utils/taskStuck": resolve(__dirname, "app/utils/taskStuck.ts"),