fix(dashboard): smooth terminal drag/resize; task-detail tweaks + popup fix

- 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) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-22 04:50:43 -07:00
parent 4b4f50c69e
commit 2a28967133
5 changed files with 173 additions and 61 deletions

View File

@@ -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 {

View File

@@ -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.
*/}
<div className="detail-heading-row">
<h2
ref={titleRef}

View File

@@ -124,9 +124,14 @@ Larger grab target for the docked terminal top resize handle: it straddles the p
box-shadow: var(--shadow-xl);
}
/*
FNXC:Terminal 2026-06-22-19:50:
The floating-mode header is the move grip. `touch-action: none` is required so a touch-drag on it is delivered as a continuous pointermove stream (paired with setPointerCapture on the captured element) instead of being hijacked by the browser into page scroll/pan. Without it the floating drag stutters on touch — same fix the right-dock pop-out drag handle uses. cursor: grab/grabbing signals the move affordance on desktop.
*/
.terminal-header--draggable {
cursor: grab;
user-select: none;
touch-action: none;
}
.terminal-header--draggable:active {

View File

@@ -433,7 +433,10 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
const pendingFitRef = useRef<number | null>(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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>, 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]);
/**

View File

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