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();