From 62a96e371202af9bac4fcfc15ec6a00092f94802 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 05:10:12 -0700 Subject: [PATCH] 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 && ( + + )}