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