diff --git a/.changeset/github-import-project-remotes.md b/.changeset/github-import-project-remotes.md new file mode 100644 index 0000000000..b960e8fc67 --- /dev/null +++ b/.changeset/github-import-project-remotes.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Fix Import from GitHub remote detection in multi-project dashboards by passing the active `projectId` to the `/api/git/remotes` lookup. The dialog now lists configured GitHub remotes instead of showing "No GitHub remotes detected" when the backend requires project scope. diff --git a/.changeset/unified-floating-window-stack.md b/.changeset/unified-floating-window-stack.md new file mode 100644 index 0000000000..046cc9c26b --- /dev/null +++ b/.changeset/unified-floating-window-stack.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Floating modals (the reusable FloatingWindow, the right-dock pop-out, the floating terminal, and the floating New Task dialog) now share a single z-index stack, so tapping any of them brings it to the front above all the others regardless of type. diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 9c5b4e5b8b..2c3d8a35ca 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,8 @@ 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} + /* FNXC:FloatingWindow 2026-06-22-21:10: Popping out from the board's full-panel detail also returns the main panel to the board, so the board (not the emptied detail) sits behind the floating window. */ + onPopOut={(task) => { popOutTaskDetail(task); closeTaskDetailMainPanel(); }} onOpenDetail={(value) => setMainPanelDetailTask(value)} onMoveTask={moveTask} onDeleteTask={deleteTask} @@ -2148,6 +2164,7 @@ function AppInner() { onResetTask={resetTask} onDuplicateTask={duplicateTask} onOpenDetail={(task, options) => openDetailTask(task, undefined, options)} + onPopOut={popOutTaskDetail} addToast={addToast} globalPaused={globalPaused} onNewTask={openNewTaskWithNav} @@ -2496,6 +2513,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..9941767ed6 --- /dev/null +++ b/packages/dashboard/app/components/FloatingWindow.tsx @@ -0,0 +1,322 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type CSSProperties, + type PointerEvent as ReactPointerEvent, + type ReactNode, +} from "react"; +import { createPortal } from "react-dom"; +import { X } from "lucide-react"; +import { nextFloatingZ, currentFloatingZ } from "./floatingWindowStack"; +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-21:30: +Z-index now comes from the SHARED `floatingWindowStack` module (`nextFloatingZ`/`currentFloatingZ`) so FloatingWindow stacks in ONE counter with the right-dock pop-out, the floating terminal, and the floating New Task dialog — tapping ANY of them raises it above all the others regardless of type. The local `topZ`/`nextZ` counter this file previously owned is gone. +*/ + +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-21:30: Each window owns its z-index; mounting claims the front of the SHARED cross-type stack. + const [zIndex, setZIndex] = useState(() => nextFloatingZ()); + + /* + FNXC:FloatingWindow 2026-06-22-20:45: + 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-21:30: Focus-to-front. Pointerdown/focus anywhere on the panel raises this window above ALL other floating modals (any type) via the shared stack. + const bringToFront = useCallback(() => { + setZIndex((current) => { + // Only claim a new z if we are not already on top, to avoid needless counter churn on every move. + if (current >= currentFloatingZ()) return current; + return nextFloatingZ(); + }); + }, []); + + 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; + + /* + FNXC:FloatingWindow 2026-06-22-21:10: + Rendered via a portal to document.body so the window escapes every ancestor stacking context (board card badges, the List view's sticky sort header + column divider, transformed columns, etc.). Without the portal the panel's z-index battles inside whatever subtree mounted it, letting card dependency/overlap tags and the list divider/sort header paint over the modal. At document.body the 4000+ z-index wins over all page content. + */ + return createPortal( +
+
+ {RESIZE_DIRECTIONS.map((direction) => ( +
handleResizePointerDown(event, direction)} + /> + ))} +
+
{title}
+ +
+
+ {children} +
+
+
, + document.body, + ); +} diff --git a/packages/dashboard/app/components/GitHubImportModal.tsx b/packages/dashboard/app/components/GitHubImportModal.tsx index 34fe32d204..4b4dd1e72f 100644 --- a/packages/dashboard/app/components/GitHubImportModal.tsx +++ b/packages/dashboard/app/components/GitHubImportModal.tsx @@ -86,6 +86,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, const [loadingRemotes, setLoadingRemotes] = useState(false); const [selectedRemoteName, setSelectedRemoteName] = useState(""); const mountedRef = useRef(false); + const remoteLoadRequestIdRef = useRef(0); const modalRef = useRef(null); useModalResizePersist(modalRef, isOpen && resizePersistEnabled, "fusion:github-modal-size"); const overlayDismissProps = useOverlayDismiss(onClose); @@ -153,11 +154,22 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, autoLoadedRef.current = null; mountedRef.current = true; + const remoteLoadRequestId = remoteLoadRequestIdRef.current + 1; + remoteLoadRequestIdRef.current = remoteLoadRequestId; + let cancelled = false; - // Fetch git remotes - fetchGitRemotes() + /* + FNXC:GitHubImport 2026-06-22-09:08: + Import from GitHub must detect remotes for the active project, not the dashboard process fallback. + The remotes API returns an empty list without projectId in multi-project mode, which incorrectly shows "No GitHub remotes detected" for configured repositories. + + FNXC:GitHubImport 2026-06-22-09:22: + Project changes can happen while the modal stays open, so remote discovery must ignore stale responses from earlier projectId requests. + A mounted-only guard is insufficient because the next effect marks the component mounted again before the older request resolves. + */ + fetchGitRemotes(projectId) .then((fetchedRemotes) => { - if (!mountedRef.current) return; + if (cancelled || !mountedRef.current || remoteLoadRequestId !== remoteLoadRequestIdRef.current) return; setRemotes(fetchedRemotes); setLoadingRemotes(false); @@ -179,16 +191,17 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, // If no remotes, owner/repo remain empty }) .catch(() => { - if (mountedRef.current) { + if (!cancelled && mountedRef.current && remoteLoadRequestId === remoteLoadRequestIdRef.current) { setLoadingRemotes(false); } }); return () => { + cancelled = true; mountedRef.current = false; }; } - }, [isOpen]); + }, [isOpen, projectId]); // Handle remote selection change const handleRemoteChange = useCallback((remoteName: string) => { @@ -453,7 +466,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, setImporting(false); } } - }, [activeTab, selectedIssueNumber, selectedPullNumber, owner, repo, onImport, isMobile, mobileView]); + }, [activeTab, selectedIssueNumber, selectedPullNumber, owner, repo, projectId, onImport, isMobile, mobileView]); const selectedIssue = issues.find((i) => i.number === selectedIssueNumber); const selectedPull = pulls.find((p) => p.number === selectedPullNumber); diff --git a/packages/dashboard/app/components/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..887cae1453 100644 --- a/packages/dashboard/app/components/NewTaskModal.css +++ b/packages/dashboard/app/components/NewTaskModal.css @@ -3,6 +3,107 @@ 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:FloatingWindow 2026-06-22-21:30: +Only the desktop FLOATING New Task dialog joins the shared cross-type floating stack. When the overlay hosts the floating panel, reset the base `.modal-overlay` z-index:100 to auto so it does NOT establish a stacking context; the panel's inline z-index (from floatingWindowStack, 4000+) then interleaves at the root with the terminal, the right-dock pop-out, and FloatingWindow. The mobile full-screen sheet (no `--floating` panel) keeps the base overlay z-index:100 so it still paints above page content. +*/ +.modal-overlay.new-task-modal-overlay:has(.new-task-modal--floating) { + z-index: auto; +} + +/* +FNXC:NewTask 2026-06-22-20:30: +Floating panel positioned by state-driven inline left/top/width/height. min/max keep content usable and the panel on-screen; `resize: none` because the corner/edge handles own resizing (the native grip conflicts with the pointer handlers). `pointer-events: auto` re-enables interaction on the panel only. Desktop only — mobile keeps the full-screen keyboard-aware sheet. +*/ +.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..7539040849 100644 --- a/packages/dashboard/app/components/NewTaskModal.tsx +++ b/packages/dashboard/app/components/NewTaskModal.tsx @@ -1,5 +1,6 @@ import "./NewTaskModal.css"; -import { useState, useCallback, useEffect, useRef } from "react"; +import { useState, useCallback, useEffect, useRef, type CSSProperties, type PointerEvent as ReactPointerEvent } from "react"; +import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { DEFAULT_TASK_PRIORITY, type Task, type TaskCreateInput, type TaskPriority } from "@fusion/core"; import { getErrorMessage } from "@fusion/core"; @@ -17,6 +18,7 @@ import { useMobileScrollLock } from "../hooks/useMobileScrollLock"; import { useNodes } from "../hooks/useNodes"; import { useViewportMode } from "../hooks/useViewportMode"; import { useAgentsMapCache } from "../hooks/useAgentsMapCache"; +import { nextFloatingZ, currentFloatingZ } from "./floatingWindowStack"; interface NewTaskModalProps { isOpen: boolean; @@ -30,6 +32,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 +140,150 @@ 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); + // FNXC:FloatingWindow 2026-06-22-21:30: Floating (desktop) New Task dialog shares the SINGLE cross-type floating z-index stack (floatingWindowStack). Mounting claims the front; tapping the panel (pointerdown/focus capture) raises it above every other floating modal regardless of type. Mobile keeps the full-screen sheet so this z-index is harmless there. + const [zIndex, setZIndex] = useState(() => nextFloatingZ()); + const bringToFront = useCallback(() => { + setZIndex((current) => (current >= currentFloatingZ() ? current : nextFloatingZ())); + }, []); + + const persistSize = useCallback((next: FloatSize) => { + setSizeState(writeFloatSize(next)); + }, []); + + 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 +742,44 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, if (!isOpen) return null; - return ( -
+ // 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`, zIndex } + : keyboardStyle; + + // FNXC:FloatingWindow 2026-06-22-22:30: Portaled to document.body so the floating New Task dialog shares the ONE root stacking context with the other floating modals; the shared cross-type z stack only orders correctly at the document root. Mobile sheet is position:fixed, unaffected. + return createPortal( +
e.stopPropagation()} - style={keyboardStyle} + className={`modal modal-lg new-task-modal${isFloating ? " new-task-modal--floating" : ""}`} + style={panelStyle} + onPointerDownCapture={isFloating ? bringToFront : undefined} + onFocusCapture={isFloating ? bringToFront : undefined} > -
+ {isFloating && NEW_TASK_RESIZE_DIRECTIONS.map((direction) => ( +
handleFloatingResizePointerDown(event, direction)} + /> + ))} +

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

@@ -604,6 +872,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
-
+
, + document.body, ); } diff --git a/packages/dashboard/app/components/RightDock.css b/packages/dashboard/app/components/RightDock.css index fe20d7ee7a..38309ebd0c 100644 --- a/packages/dashboard/app/components/RightDock.css +++ b/packages/dashboard/app/components/RightDock.css @@ -170,6 +170,11 @@ The right-dock pop-out is a FLOATING, DRAGGABLE, RESIZABLE, NON-BLOCKING window. background: transparent; backdrop-filter: none; pointer-events: none; + /* + FNXC:FloatingWindow 2026-06-22-21:30: + Reset the base `.modal-overlay` z-index:100 to auto so this click-through overlay does NOT establish a stacking context. The floating panel carries an inline z-index from the SHARED floatingWindowStack (4000+); without this reset that inline z would be clamped inside the overlay's own stacking context and could never interleave with the other floating modal types (terminal, New Task, FloatingWindow) that all draw from the same stack. + */ + z-index: auto; } .right-dock-expand-modal { diff --git a/packages/dashboard/app/components/RightDockExpandModal.tsx b/packages/dashboard/app/components/RightDockExpandModal.tsx index e9c55b9559..3236c57e93 100644 --- a/packages/dashboard/app/components/RightDockExpandModal.tsx +++ b/packages/dashboard/app/components/RightDockExpandModal.tsx @@ -1,6 +1,8 @@ import { useCallback, useEffect, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent, type RefObject } from "react"; +import { createPortal } from "react-dom"; import { Maximize2, X } from "lucide-react"; import { findOverflowViewEntry, type OverflowViewEntry, type OverflowViewKey, type OverflowViewRenderProps, type OverflowViewVisibilityOptions } from "./overflowViewRegistry"; +import { nextFloatingZ, currentFloatingZ } from "./floatingWindowStack"; import "./RightDock.css"; const RIGHT_DOCK_EXPAND_MODAL_SIZE_STORAGE_KEY = "fusion:right-dock-expand-modal-size"; @@ -124,6 +126,11 @@ export function RightDockExpandModal({ const [size, setSizeState] = useState(() => readExpandSize()); const [position, setPositionState] = useState(() => readExpandPosition(readExpandSize())); + // FNXC:FloatingWindow 2026-06-22-21:30: The right-dock pop-out shares the SINGLE cross-type floating z-index stack (floatingWindowStack). Mounting claims the front; tapping the panel (pointerdown/focus capture) raises it above every other floating modal regardless of type. + const [zIndex, setZIndex] = useState(() => nextFloatingZ()); + const bringToFront = useCallback(() => { + setZIndex((current) => (current >= currentFloatingZ() ? current : nextFloatingZ())); + }, []); /* FNXC:RightDock 2026-06-22-17:40: @@ -294,11 +301,18 @@ export function RightDockExpandModal({ top: `${position.y}px`, width: `${size.width}px`, height: `${size.height}px`, + zIndex, } as CSSProperties; - return ( -
-
+ // FNXC:FloatingWindow 2026-06-22-22:30: Portaled to document.body so this floating modal shares the ONE root stacking context with the other floating modals (FloatingWindow/terminal/New Task) — the shared 10100+ z stack only orders correctly across types when they all live at the document root. + return createPortal( +
+
{EXPAND_RESIZE_DIRECTIONS.map((direction) => (
-
+
, + document.body, ); } diff --git a/packages/dashboard/app/components/TaskChatTab.tsx b/packages/dashboard/app/components/TaskChatTab.tsx index d002170b9b..ec659f81d0 100644 --- a/packages/dashboard/app/components/TaskChatTab.tsx +++ b/packages/dashboard/app/components/TaskChatTab.tsx @@ -509,12 +509,15 @@ export function TaskChatTab({ task, projectId, active, addToast, sessionLive, on /** * FNXC:TaskDetailChat 2026-06-19-22:54: * The task-detail chat must never silently accept a question when no agent session will consume it. Keep idle chats sendable, but surface that the message is saved as guidance for the next task run instead of implying a live reply. + * + * FNXC:TaskDetailChat 2026-06-22-21:20: + * The idle "No agent is working on this task right now…" hint is suppressed (empty) per user request — idle chats stay sendable but no longer show the banner. Done/active hints remain. The render gates on a truthy sessionHint, so the empty idle case renders nothing. */ const sessionHint = isDoneTask ? t("taskChat.doneSessionHint", "Send a message to start a refinement task for this completed task.") : activeSession ? t("taskChat.activeSessionHint", "Message the active agent session. Guidance is delivered to the running session in real time.") - : t("taskChat.idleSessionHint", "No agent is working on this task right now. Your message is saved as guidance and will reach an agent the next time this task runs."); + : ""; const composerPlaceholder = isDoneTask ? t("taskChat.donePlaceholder", "Start a refinement task for this completed task") : t("taskChat.activePlaceholder", "Steer the currently executing agent"); diff --git a/packages/dashboard/app/components/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..b1e9be9e67 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -1,7 +1,7 @@ import "./TaskDetailModal.css"; import React, { Suspense, lazy, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Pencil, Bot, X, ChevronDown, ChevronRight, GitBranch, ArrowLeft, Zap, Loader2, AlertTriangle, Sparkles } from "lucide-react"; +import { Pencil, Bot, X, ChevronDown, ChevronRight, GitBranch, ArrowLeft, Zap, Loader2, AlertTriangle, Sparkles, Maximize2 } from "lucide-react"; import { useModalResizePersist } from "../hooks/useModalResizePersist"; import { useMobileScrollLock } from "../hooks/useMobileScrollLock"; import { useOverlayDismiss } from "../hooks/useOverlayDismiss"; @@ -414,6 +414,11 @@ export type TaskDetailContentProps = Omit & { onBackToBoard powers the board-card full-panel "Back to board" affordance rendered in the gray header (far right). It is only honored when embedded is also true, so ListView split-pane and modal usages never show it. */ onBackToBoard?: () => void; + /* + FNXC:FloatingWindow 2026-06-22-20:45: + onPopOut, when supplied, renders a Maximize2 "Pop out" button in the gray header. List/Board wire it to push this task into App's floating task-detail window array, opening the same embedded TaskDetailContent inside a movable, resizable, non-blocking FloatingWindow. It is independent of embedded/onBackToBoard so List split-pane and the board full-panel can both expose it. + */ + onPopOut?: (task: Task) => void; }; function truncate(s: string, max: number): string { @@ -589,6 +594,7 @@ export function TaskDetailContent({ embedded = false, onRequestClose, onBackToBoard, + onPopOut, workflowFieldDefs: workflowFieldDefsProp, }: TaskDetailContentProps) { const { t } = useTranslation("app"); @@ -2754,6 +2760,22 @@ export function TaskDetailContent({ {t("app.taskDetail.backToBoard", "Back to board")} )} + {/* + FNXC:FloatingWindow 2026-06-22-20:45: + "Pop out" affordance opens this task detail in a movable, resizable, non-blocking FloatingWindow. Rendered whenever onPopOut is wired (List split-pane + board full-panel); App dedupes by task id so re-popping focuses the existing window instead of duplicating. + */} + {onPopOut && ( + + )} {!isEditing && canEdit && ( + {/* FNXC:NewTask 2026-06-22-20:30: Hide the disclosure toggle entirely when force-open — there is nothing to collapse, so the New Task dialog shows every advanced control without a click. */} + {!forceMoreOptionsOpen && ( + + )} , + document.body, ); } diff --git a/packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx b/packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx new file mode 100644 index 0000000000..768d560a1c --- /dev/null +++ b/packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx @@ -0,0 +1,94 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { FloatingWindow } from "../FloatingWindow"; + +/* +FNXC:FloatingWindow 2026-06-22-20:45: +Contract tests for the reusable non-blocking floating window: +- the overlay is click-through (pointer-events:none) so the page and other windows behind it stay interactive, +- the panel re-enables pointer events and carries a header drag handle + resize handles, +- focus-to-front raises this window's z-index above any previously-opened window, +- close removes the window (onClose fires). +JSDOM has no real layout/pointer-capture, so drag math is asserted in the RightDockExpandModal pattern's own suite; here we assert the structural + stacking contract that makes multiple coexisting windows non-blocking. +*/ + +describe("FloatingWindow", () => { + it("renders a non-blocking, click-through transparent overlay with a pointer-events:auto panel", () => { + render( + {}}> +
alpha body
+
+ ); + const overlay = screen.getByTestId("floating-window-overlay-alpha"); + // styles.css is not loaded here, so assert via the class contract the CSS attaches pointer-events:none to. + expect(overlay.className).toContain("floating-window-overlay"); + const panel = screen.getByTestId("floating-window-alpha"); + expect(panel.className).toContain("floating-window"); + // Panel is positioned/stacked via inline style. + expect(panel.style.position === "" || panel.style.left).toBeDefined(); + expect(panel.style.zIndex).not.toBe(""); + }); + + it("exposes a header drag handle and resize handles", () => { + render( + {}}> +
beta body
+
+ ); + expect(screen.getByTestId("floating-window-drag-handle-beta")).toBeTruthy(); + // 8 edge/corner resize handles. + for (const dir of ["n", "s", "e", "w", "ne", "nw", "se", "sw"]) { + expect(screen.getByTestId(`floating-window-resize-${dir}`)).toBeTruthy(); + } + }); + + it("focus-to-front: interacting with an older window raises its z-index above the newest", () => { + render( + <> + {}}> +
first
+
+ {}}> +
second
+
+ + ); + const first = screen.getByTestId("floating-window-first"); + const second = screen.getByTestId("floating-window-second"); + // Second mounted last → starts on top. + expect(Number(second.style.zIndex)).toBeGreaterThan(Number(first.style.zIndex)); + // Clicking the first panel raises it above the second. + fireEvent.pointerDown(first); + expect(Number(first.style.zIndex)).toBeGreaterThan(Number(second.style.zIndex)); + }); + + it("close button removes the window via onClose", () => { + const onClose = vi.fn(); + render( + +
gamma body
+
+ ); + fireEvent.click(screen.getByTestId("floating-window-close-gamma")); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("multiple windows coexist independently (each renders its own panel)", () => { + render( + <> + {}}> +
one
+
+ {}}> +
two
+
+ {}}> +
three
+
+ + ); + expect(screen.getByTestId("floating-window-w1")).toBeTruthy(); + expect(screen.getByTestId("floating-window-w2")).toBeTruthy(); + expect(screen.getByTestId("floating-window-w3")).toBeTruthy(); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/FloatingWindowStack.cross-type.test.tsx b/packages/dashboard/app/components/__tests__/FloatingWindowStack.cross-type.test.tsx new file mode 100644 index 0000000000..d67b745728 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/FloatingWindowStack.cross-type.test.tsx @@ -0,0 +1,52 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { FloatingWindow } from "../FloatingWindow"; +import { RightDockExpandModal } from "../RightDockExpandModal"; +import { nextFloatingZ, currentFloatingZ } from "../floatingWindowStack"; + +/* +FNXC:FloatingWindow 2026-06-22-21:30: +Cross-type shared-stack contract. Every floating modal type (FloatingWindow, the right-dock pop-out, the floating terminal, the floating New Task dialog) must draw its z-index from the SINGLE module-level `floatingWindowStack` counter so tapping ANY of them raises it above ALL the others REGARDLESS of type. Before this, each type owned a private counter and tapping the terminal could not raise it above a popped-out FloatingWindow. This suite proves two different component types interleave in one monotonic stack and that tapping the older one raises it above the newer one across the type boundary. RightDockExpandModal stands in for the three non-FloatingWindow floating modals (terminal + New Task wire the identical claim-on-mount + bring-to-front-on-pointerdown pattern; they are heavier to mount in JSDOM and assert the same inline-zIndex contract). +*/ + +const renderProps = { addToast: () => {}, projectId: "project-1" } as const; + +describe("floatingWindowStack (cross-type)", () => { + it("hands out a strictly increasing, shared z to every claimant", () => { + const a = nextFloatingZ(); + const b = nextFloatingZ(); + expect(b).toBeGreaterThan(a); + expect(currentFloatingZ()).toBe(b); + }); + + it("tapping a FloatingWindow raises it above a right-dock pop-out opened after it (and vice versa)", () => { + render( + <> + {}}> +
fw body
+
+ {}} /> + , + ); + + const fwPanel = screen.getByTestId("floating-window-fw"); + const dockPanel = screen + .getByTestId("right-dock-expand-modal") + .querySelector(".right-dock-expand-modal--floating") as HTMLElement; + + // Both carry an inline z-index from the shared stack. + expect(fwPanel.style.zIndex).not.toBe(""); + expect(dockPanel.style.zIndex).not.toBe(""); + + // The dock pop-out mounted last → it starts on top of the FloatingWindow, proving one shared stack. + expect(Number(dockPanel.style.zIndex)).toBeGreaterThan(Number(fwPanel.style.zIndex)); + + // Tapping the older FloatingWindow raises it above the dock pop-out — across the type boundary. + fireEvent.pointerDown(fwPanel); + expect(Number(fwPanel.style.zIndex)).toBeGreaterThan(Number(dockPanel.style.zIndex)); + + // Tapping the dock pop-out raises it back above the FloatingWindow. + fireEvent.pointerDown(dockPanel); + expect(Number(dockPanel.style.zIndex)).toBeGreaterThan(Number(fwPanel.style.zIndex)); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx b/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx index e86db036ed..3b4b09f2f2 100644 --- a/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, fireEvent, waitFor, within } from "@testing-library/react"; +import { act, render, screen, fireEvent, waitFor, within } from "@testing-library/react"; import { GitHubImportModal } from "../GitHubImportModal"; import { apiFetchGitHubIssues, @@ -294,6 +294,64 @@ describe("GitHubImportModal", () => { }); describe("with single remote", () => { + it("loads remotes using the active project id", async () => { + vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote); + render(); + + await waitFor(() => { + expect(fetchGitRemotes).toHaveBeenCalledWith("project-1"); + }); + }); + + it("ignores stale remote responses after the active project changes", async () => { + const projectARemote: GitRemote[] = [ + { name: "origin", owner: "project-a", repo: "old-repo", url: "https://github.com/project-a/old-repo.git" }, + ]; + const projectBRemote: GitRemote[] = [ + { name: "origin", owner: "project-b", repo: "new-repo", url: "https://github.com/project-b/new-repo.git" }, + ]; + let resolveProjectA!: (value: GitRemote[]) => void; + let resolveProjectB!: (value: GitRemote[]) => void; + vi.mocked(fetchGitRemotes) + .mockImplementationOnce(() => new Promise((resolve) => { + resolveProjectA = resolve; + })) + .mockImplementationOnce(() => new Promise((resolve) => { + resolveProjectB = resolve; + })); + + const { rerender } = render( + , + ); + + await waitFor(() => { + expect(fetchGitRemotes).toHaveBeenCalledWith("project-a"); + }); + + rerender(); + + await waitFor(() => { + expect(fetchGitRemotes).toHaveBeenCalledWith("project-b"); + }); + + await act(async () => { + resolveProjectB(projectBRemote); + }); + + await waitFor(() => { + expect(screen.getByText("project-b/new-repo")).toBeTruthy(); + }); + + await act(async () => { + resolveProjectA(projectARemote); + }); + + await waitFor(() => { + expect(screen.getByText("project-b/new-repo")).toBeTruthy(); + expect(screen.queryByText("project-a/old-repo")).toBeNull(); + }); + }); + it("auto-selects the remote and shows compact pill", async () => { vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote); render(); diff --git a/packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx b/packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx index abddeb8eb9..ac0f842ff7 100644 --- a/packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx @@ -3417,8 +3417,9 @@ describe("GitManagerModal", () => { const navItemRules = getRuleBlocks(mobile768, ".gm-nav-item"); expect(navItemRules).toHaveLength(1); + // Mobile tabs are compact ICON-ONLY in one scrolling row: non-shrinking via flex:0 0 auto + intrinsic width:auto (overrides the base .gm-nav-item width:100% that otherwise made one tab fill the row). expect(navItemRules[0]).toContain("flex: 0 0 auto;"); - expect(navItemRules[0]).toContain("min-height: calc(var(--space-xl) + var(--space-sm));"); + expect(navItemRules[0]).toContain("width: auto;"); expect(mobile720).not.toContain(".gm-sidebar"); expect(mobile720).not.toContain(".gm-nav-item"); diff --git a/packages/dashboard/app/components/__tests__/MailboxView.test.tsx b/packages/dashboard/app/components/__tests__/MailboxView.test.tsx index 9f30123ac7..43be43816b 100644 --- a/packages/dashboard/app/components/__tests__/MailboxView.test.tsx +++ b/packages/dashboard/app/components/__tests__/MailboxView.test.tsx @@ -1884,13 +1884,14 @@ describe("MailboxView", () => { const resizeHandleBlockMatch = css.match(/\.mailbox-view\s+\.mailbox-split-resize-handle\s*\{([^}]*)\}/); expect(resizeHandleBlockMatch).toBeTruthy(); const resizeHandleBlock = resizeHandleBlockMatch![1]; - expect(resizeHandleBlock).toContain("width: var(--space-xs);"); + // FNXC:Mailbox 2026-06-22-18:20: handle mirrors the Chat sidebar divider — hit area var(--space-sm), transparent until hover, centered var(--space-xs) line. + expect(resizeHandleBlock).toContain("width: var(--space-sm);"); expect(resizeHandleBlock).toContain("cursor: col-resize;"); - expect(resizeHandleBlock).toContain("background: color-mix(in srgb, var(--border) 70%, transparent);"); + expect(resizeHandleBlock).toContain("background: transparent;"); const resizeHandleTargetBlockMatch = css.match(/\.mailbox-view\s+\.mailbox-split-resize-handle::before\s*\{([^}]*)\}/); expect(resizeHandleTargetBlockMatch).toBeTruthy(); - expect(resizeHandleTargetBlockMatch![1]).toContain("width: var(--space-sm);"); + expect(resizeHandleTargetBlockMatch![1]).toContain("width: var(--space-xs);"); expect(css).toMatch(/\.mailbox-view\s+\.mailbox-split-resize-handle:hover::before,\s*\n\.mailbox-view\s+\.mailbox-split-resize-handle:active::before\s*\{[^}]*background:\s*color-mix\(in srgb,\s*var\(--todo\)\s*35%,\s*transparent\);[^}]*\}/); const splitEmptyBlockMatch = css.match(/\.mailbox-view\s+\.mailbox-split-empty\s*\{([^}]*)\}/); diff --git a/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx b/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx index 2c2d34501b..17457a03c6 100644 --- a/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx @@ -53,11 +53,13 @@ vi.mock("../../hooks/useMobileKeyboard", () => ({ useMobileKeyboard: (...args: unknown[]) => mockUseMobileKeyboard(...args), })); +// FNXC:NewTask 2026-06-22-20:30: viewport mode is switchable so we can exercise both the mobile sheet (default) and the desktop floating window. Defaults to mobile to preserve the existing suite's layout assumptions. +let mockViewportMode: "mobile" | "desktop" = "mobile"; vi.mock("../../hooks/useViewportMode", () => ({ MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)", - getViewportMode: () => "mobile", - isMobileViewport: () => true, - useViewportMode: () => "mobile", + getViewportMode: () => mockViewportMode, + isMobileViewport: () => mockViewportMode === "mobile", + useViewportMode: () => mockViewportMode, })); function makeTask(id: string): Task { @@ -92,6 +94,7 @@ function renderNewTaskModal(props: Partial> describe("NewTaskModal", () => { beforeEach(() => { vi.clearAllMocks(); + mockViewportMode = "mobile"; mockConfirm.mockReset(); mockConfirm.mockResolvedValue(true); mockUseMobileKeyboard.mockReturnValue({ @@ -110,8 +113,9 @@ describe("NewTaskModal", () => { viewportOffsetTop: 50, }); - const { container } = renderNewTaskModal(); - const modal = container.querySelector(".new-task-modal"); + renderNewTaskModal(); + // FNXC: NewTaskModal portals to document.body, so query the modal from document (not the render container). + const modal = document.querySelector(".new-task-modal"); expect(mockUseMobileKeyboard).toHaveBeenCalledWith({ enabled: true }); expect(modal?.getAttribute("style")).toContain("--keyboard-overlap: 250px"); @@ -131,7 +135,7 @@ describe("NewTaskModal", () => { renderNewTaskModal(); expect(screen.getByText("New Task")).toBeTruthy(); - expect(screen.getByRole('textbox')).toBeTruthy(); + expect(screen.getByPlaceholderText("What needs to be done?")).toBeTruthy(); expect(screen.queryByRole("button", { name: "Plan" })).toBeNull(); expect(screen.queryByRole("button", { name: "Subtask" })).toBeNull(); expect(screen.queryByTestId("task-form-description-actions")).toBeNull(); @@ -140,7 +144,6 @@ describe("NewTaskModal", () => { expect(screen.getByTestId("dep-trigger")).toBeInTheDocument(); expect(screen.getByTestId("new-task-agent-button")).toBeInTheDocument(); - fireEvent.click(screen.getByTestId("task-form-more-options-toggle")); await waitFor(() => { expect(screen.getByText(/Model Configuration/i)).toBeTruthy(); @@ -156,7 +159,7 @@ describe("NewTaskModal", () => { onSubtaskBreakdown: vi.fn(), }); - fireEvent.change(screen.getByRole("textbox"), { target: { value: "Create parity coverage" } }); + fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Create parity coverage" } }); // Canonical QuickEntryBox action row includes Plan, Subtask, Refine, Deps, Attach, Models, Node, and Agent affordances; the modal maps these to existing TaskForm/quick-field controls instead of duplicating implementations. expect(screen.getAllByTestId("task-form-plan-button")).toHaveLength(1); @@ -165,7 +168,6 @@ describe("NewTaskModal", () => { expect(screen.getByTestId("dep-trigger")).toBeInTheDocument(); expect(screen.getByTestId("new-task-agent-button")).toBeInTheDocument(); - fireEvent.click(screen.getByTestId("task-form-more-options-toggle")); expect(screen.getByTestId("task-form-execution-mode-select")).toBeInTheDocument(); expect(screen.getByTestId("task-form-github-tracking")).toBeInTheDocument(); @@ -177,7 +179,6 @@ describe("NewTaskModal", () => { it("renders the Fast and standard execution-mode affordance inside More options", () => { renderNewTaskModal(); - fireEvent.click(screen.getByTestId("task-form-more-options-toggle")); const select = screen.getByTestId("task-form-execution-mode-select") as HTMLSelectElement; expect(select).toBeInTheDocument(); @@ -188,7 +189,6 @@ describe("NewTaskModal", () => { it("includes executionMode fast in the create payload when Fast is selected", async () => { const { props } = renderNewTaskModal(); - fireEvent.click(screen.getByTestId("task-form-more-options-toggle")); fireEvent.change(screen.getByTestId("task-form-execution-mode-select"), { target: { value: "fast" } }); fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Fast parity task" } }); fireEvent.click(screen.getByRole("button", { name: "Create Task" })); @@ -206,7 +206,7 @@ describe("NewTaskModal", () => { it("omits executionMode from the create payload when Standard is selected", async () => { const { props } = renderNewTaskModal(); - fireEvent.change(screen.getByRole("textbox"), { target: { value: "Standard parity task" } }); + fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Standard parity task" } }); fireEvent.click(screen.getByRole("button", { name: "Create Task" })); await waitFor(() => { @@ -219,7 +219,6 @@ describe("NewTaskModal", () => { it("resets executionMode to standard after canceling and discarding changes", async () => { const { props, rerender } = renderNewTaskModal(); - fireEvent.click(screen.getByTestId("task-form-more-options-toggle")); fireEvent.change(screen.getByTestId("task-form-execution-mode-select"), { target: { value: "fast" } }); await waitFor(() => { @@ -237,7 +236,6 @@ describe("NewTaskModal", () => { rerender(); rerender(); - fireEvent.click(screen.getByTestId("task-form-more-options-toggle")); expect(screen.getByTestId("task-form-execution-mode-select")).toHaveValue("standard"); }); @@ -250,7 +248,7 @@ describe("NewTaskModal", () => { onSubtaskBreakdown, }); - fireEvent.change(screen.getByRole("textbox"), { target: { value: " Break this down " } }); + fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: " Break this down " } }); fireEvent.click(screen.getByTestId("task-form-plan-button")); expect(props.onClose).toHaveBeenCalledTimes(1); @@ -264,7 +262,7 @@ describe("NewTaskModal", () => { onSubtaskBreakdown, }); - fireEvent.change(screen.getByRole("textbox"), { target: { value: " Split into subtasks " } }); + fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: " Split into subtasks " } }); fireEvent.click(screen.getByTestId("task-form-subtask-button")); expect(onSubtaskBreakdown).toHaveBeenCalledWith("Split into subtasks"); @@ -283,52 +281,45 @@ describe("NewTaskModal", () => { expect(planButton).toBeDisabled(); expect(subtaskButton).toBeDisabled(); - fireEvent.change(screen.getByRole("textbox"), { target: { value: "Ready to plan" } }); + fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Ready to plan" } }); expect(planButton).not.toBeDisabled(); expect(subtaskButton).not.toBeDisabled(); }); - it("shows More options toggle and reveals advanced fields when clicked", async () => { + // FNXC:NewTask 2026-06-22-20:30: The New Task dialog force-opens TaskForm's advanced controls (forceMoreOptionsOpen), so every quick-add control is visible by default with NO disclosure toggle and nothing hidden. + it("shows all advanced fields by default without a More options toggle", () => { renderNewTaskModal(); - const toggle = screen.getByTestId("task-form-more-options-toggle"); const moreOptions = screen.getByTestId("task-form-more-options"); - expect(toggle).toHaveAttribute("aria-expanded", "false"); - expect(moreOptions).toHaveAttribute("hidden"); - // Dependencies are now in quick-fields (visible by default), so the dep-trigger is present + // No collapse toggle is rendered in the force-open New Task context. + expect(screen.queryByTestId("task-form-more-options-toggle")).toBeNull(); + // The advanced section is open (not hidden) from the start. + expect(moreOptions).not.toHaveAttribute("hidden"); expect(screen.getByTestId("dep-trigger")).toBeInTheDocument(); - fireEvent.click(toggle); - - await waitFor(() => { - expect(toggle).toHaveAttribute("aria-expanded", "true"); - expect(moreOptions).not.toHaveAttribute("hidden"); - }); - // Model Configuration, Attachments, and the Workflow picker are revealed + // Model Configuration, Attachments, and the Workflow picker are all visible immediately. expect(screen.getByText(/Model Configuration/i)).toBeTruthy(); expect(screen.getByText(/Attachments/i)).toBeTruthy(); expect(screen.getByText("Workflow")).toBeTruthy(); }); - it("shows dependencies and agent picker by default without expanding More options", () => { + it("shows dependencies and agent picker by default", () => { renderNewTaskModal(); // Both dep-trigger and agent button should be visible by default expect(screen.getByTestId("dep-trigger")).toBeInTheDocument(); expect(screen.getByTestId("new-task-agent-button")).toBeInTheDocument(); - // More options should be collapsed - expect(screen.getByTestId("task-form-more-options-toggle")).toHaveAttribute("aria-expanded", "false"); + // Advanced options are force-open: no collapse toggle exists. + expect(screen.queryByTestId("task-form-more-options-toggle")).toBeNull(); + expect(screen.getByTestId("task-form-more-options")).not.toHaveAttribute("hidden"); }); it("renders dependencies before attachments in form order (quick-fields before More options)", () => { renderNewTaskModal(); const dependenciesLabel = screen.getByText("Dependencies"); - // Attachments is inside the collapsed "More options" section, so we need to expand first - const toggle = screen.getByTestId("task-form-more-options-toggle"); - fireEvent.click(toggle); - + // Attachments is in the always-visible advanced section. const attachmentsLabel = screen.getByText("Attachments"); // Dependencies (in quick-fields) appears before Attachments (in More options) @@ -340,7 +331,7 @@ describe("NewTaskModal", () => { it("focuses description textarea when modal opens", async () => { renderNewTaskModal(); - const textarea = screen.getByRole('textbox'); + const textarea = screen.getByPlaceholderText("What needs to be done?"); await waitFor(() => { expect(document.activeElement).toBe(textarea); }); @@ -349,24 +340,24 @@ describe("NewTaskModal", () => { it("seeds the description when opened with an initial description", () => { renderNewTaskModal({ initialDescription: "File: README.md\n\nComment:\nFollow up" }); - expect(screen.getByRole("textbox")).toHaveValue("File: README.md\n\nComment:\nFollow up"); + expect(screen.getByPlaceholderText("What needs to be done?")).toHaveValue("File: README.md\n\nComment:\nFollow up"); expect(screen.getByRole("button", { name: "Create Task" })).not.toBeDisabled(); }); it("does not clobber user edits when initialDescription changes while open", () => { const { rerender, props } = renderNewTaskModal({ initialDescription: "Seeded description" }); - const descTextarea = screen.getByRole("textbox"); + const descTextarea = screen.getByPlaceholderText("What needs to be done?"); fireEvent.change(descTextarea, { target: { value: "User edited text" } }); rerender(); - expect(screen.getByRole("textbox")).toHaveValue("User edited text"); + expect(screen.getByPlaceholderText("What needs to be done?")).toHaveValue("User edited text"); }); it("creates task with description when submitted", async () => { const { props } = renderNewTaskModal(); - const descTextarea = screen.getByRole('textbox'); + const descTextarea = screen.getByPlaceholderText("What needs to be done?"); fireEvent.change(descTextarea, { target: { value: "Test description" } }); fireEvent.click(screen.getByRole("button", { name: "Create Task" })); @@ -482,7 +473,6 @@ describe("NewTaskModal", () => { const { props } = renderNewTaskModal(); fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task with branches" } }); - fireEvent.click(screen.getByTestId("task-form-more-options-toggle")); fireEvent.change(screen.getByLabelText("Branch strategy"), { target: { value: "existing" } }); fireEvent.change(screen.getByLabelText("Branch name"), { target: { value: " feature/fn-3422 " } }); fireEvent.change(screen.getByLabelText("Merge target / base branch"), { target: { value: " main " } }); @@ -506,7 +496,6 @@ describe("NewTaskModal", () => { const { props } = renderNewTaskModal(); fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task with auto new" } }); - fireEvent.click(screen.getByTestId("task-form-more-options-toggle")); fireEvent.change(screen.getByLabelText("Branch strategy"), { target: { value: "auto-new" } }); fireEvent.change(screen.getByLabelText("Merge target / base branch"), { target: { value: " main " } }); fireEvent.click(screen.getByRole("button", { name: "Create Task" })); @@ -527,7 +516,6 @@ describe("NewTaskModal", () => { const { props } = renderNewTaskModal(); fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task with branches" } }); - fireEvent.click(screen.getByTestId("task-form-more-options-toggle")); fireEvent.change(screen.getByLabelText("Branch strategy"), { target: { value: "custom-new" } }); expect(screen.getByRole("button", { name: "Create Task" })).toBeDisabled(); @@ -541,7 +529,6 @@ describe("NewTaskModal", () => { const { props } = renderNewTaskModal(); fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task with custom new" } }); - fireEvent.click(screen.getByTestId("task-form-more-options-toggle")); fireEvent.change(screen.getByLabelText("Branch strategy"), { target: { value: "custom-new" } }); fireEvent.change(screen.getByLabelText("Branch name"), { target: { value: " feature/custom " } }); fireEvent.click(screen.getByRole("button", { name: "Create Task" })); @@ -562,7 +549,6 @@ describe("NewTaskModal", () => { const { props } = renderNewTaskModal(); fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task with shared group" } }); - fireEvent.click(screen.getByTestId("task-form-more-options-toggle")); fireEvent.change(screen.getByLabelText("Branch strategy"), { target: { value: "shared-group" } }); expect(screen.getByRole("button", { name: "Create Task" })).toBeDisabled(); @@ -576,7 +562,6 @@ describe("NewTaskModal", () => { const { props } = renderNewTaskModal(); fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task with shared group" } }); - fireEvent.click(screen.getByTestId("task-form-more-options-toggle")); fireEvent.change(screen.getByLabelText("Branch strategy"), { target: { value: "shared-group" } }); fireEvent.change(screen.getByLabelText("Shared feature branch"), { target: { value: " feature/shared " } }); fireEvent.click(screen.getByRole("button", { name: "Create Task" })); @@ -606,7 +591,7 @@ describe("NewTaskModal", () => { expect(screen.getByText("GitHub not connected")).toBeTruthy(); }); - const descTextarea = screen.getByRole("textbox"); + const descTextarea = screen.getByPlaceholderText("What needs to be done?"); fireEvent.change(descTextarea, { target: { value: "Submit despite warning" } }); fireEvent.click(screen.getByRole("button", { name: "Create Task" })); @@ -622,7 +607,7 @@ describe("NewTaskModal", () => { it("closes modal after successful creation", async () => { const { props } = renderNewTaskModal(); - const descTextarea = screen.getByRole('textbox'); + const descTextarea = screen.getByPlaceholderText("What needs to be done?"); fireEvent.change(descTextarea, { target: { value: "Test" } }); fireEvent.click(screen.getByRole("button", { name: "Create Task" })); @@ -638,7 +623,7 @@ describe("NewTaskModal", () => { onCreateTask: vi.fn().mockResolvedValue({ id: "FN-042" }), }); - const descTextarea = screen.getByRole('textbox'); + const descTextarea = screen.getByPlaceholderText("What needs to be done?"); fireEvent.change(descTextarea, { target: { value: "Test description" } }); fireEvent.click(screen.getByRole("button", { name: "Create Task" })); @@ -651,7 +636,7 @@ describe("NewTaskModal", () => { it("confirms before closing with dirty state", async () => { const { props } = renderNewTaskModal(); - const descTextarea = screen.getByRole('textbox'); + const descTextarea = screen.getByPlaceholderText("What needs to be done?"); fireEvent.change(descTextarea, { target: { value: "Test description" } }); mockConfirm.mockResolvedValueOnce(false); @@ -678,7 +663,7 @@ describe("NewTaskModal", () => { it("creates task with title undefined by default", async () => { const { props } = renderNewTaskModal(); - const descTextarea = screen.getByRole('textbox'); + const descTextarea = screen.getByPlaceholderText("What needs to be done?"); fireEvent.change(descTextarea, { target: { value: "Only description" } }); fireEvent.click(screen.getByRole("button", { name: "Create Task" })); @@ -696,7 +681,7 @@ describe("NewTaskModal", () => { it("calls onCreateTask when form is submitted", async () => { const { props } = renderNewTaskModal(); - const descTextarea = screen.getByRole('textbox'); + const descTextarea = screen.getByPlaceholderText("What needs to be done?"); fireEvent.change(descTextarea, { target: { value: "Normal task" } }); fireEvent.click(screen.getByRole("button", { name: "Create Task" })); @@ -721,7 +706,7 @@ describe("NewTaskModal", () => { it("enables Create Task when description has content", () => { renderNewTaskModal(); - const descTextarea = screen.getByRole('textbox'); + const descTextarea = screen.getByPlaceholderText("What needs to be done?"); fireEvent.change(descTextarea, { target: { value: "Some text" } }); const createButton = screen.getByRole("button", { name: "Create Task" }); @@ -733,7 +718,7 @@ describe("NewTaskModal", () => { it("omits modelPresetId from payload when in default mode", async () => { const { props } = renderNewTaskModal(); - const descTextarea = screen.getByRole('textbox'); + const descTextarea = screen.getByPlaceholderText("What needs to be done?"); fireEvent.change(descTextarea, { target: { value: "Default mode task" } }); fireEvent.click(screen.getByRole("button", { name: "Create Task" })); @@ -767,7 +752,7 @@ describe("NewTaskModal", () => { }); // Type a description - fireEvent.change(screen.getByRole('textbox'), { target: { value: "Preset task" } }); + fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Preset task" } }); // Select the preset const select = document.getElementById("model-preset") as HTMLSelectElement; @@ -808,7 +793,7 @@ describe("NewTaskModal", () => { }); // Type a description - fireEvent.change(screen.getByRole('textbox'), { target: { value: "Custom task" } }); + fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Custom task" } }); // Select a preset first const select = document.getElementById("model-preset") as HTMLSelectElement; @@ -857,7 +842,7 @@ describe("NewTaskModal", () => { expect(screen.getByTestId("task-workflow-select")).toBeTruthy(); }); - fireEvent.change(screen.getByRole("textbox"), { target: { value: "Inherit default" } }); + fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Inherit default" } }); fireEvent.click(screen.getByRole("button", { name: "Create Task" })); await waitFor(() => { @@ -876,7 +861,7 @@ describe("NewTaskModal", () => { }); fireEvent.change(screen.getByTestId("task-workflow-select"), { target: { value: "WF-1" } }); - fireEvent.change(screen.getByRole("textbox"), { target: { value: "Pick a workflow" } }); + fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Pick a workflow" } }); fireEvent.click(screen.getByRole("button", { name: "Create Task" })); await waitFor(() => { @@ -897,7 +882,7 @@ describe("NewTaskModal", () => { // Pick a workflow, then switch to "No workflow" to register an explicit null. fireEvent.change(screen.getByTestId("task-workflow-select"), { target: { value: "WF-1" } }); fireEvent.change(screen.getByTestId("task-workflow-select"), { target: { value: "__none__" } }); - fireEvent.change(screen.getByRole("textbox"), { target: { value: "No workflow task" } }); + fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "No workflow task" } }); fireEvent.click(screen.getByRole("button", { name: "Create Task" })); await waitFor(() => { @@ -924,7 +909,7 @@ describe("NewTaskModal", () => { it("omits reviewLevel from payload when not selected", async () => { const { props } = renderNewTaskModal(); - const descTextarea = screen.getByRole('textbox'); + const descTextarea = screen.getByPlaceholderText("What needs to be done?"); fireEvent.change(descTextarea, { target: { value: "Task without review level" } }); fireEvent.click(screen.getByRole("button", { name: "Create Task" })); @@ -942,7 +927,6 @@ describe("NewTaskModal", () => { const { props } = renderNewTaskModal(); // Open more options to access the review level selector - fireEvent.click(screen.getByTestId("task-form-more-options-toggle")); await waitFor(() => { expect(screen.getByLabelText("Review")).toBeTruthy(); @@ -970,7 +954,6 @@ describe("NewTaskModal", () => { const { props } = renderNewTaskModal(); // Open more options to access the review level selector - fireEvent.click(screen.getByTestId("task-form-more-options-toggle")); await waitFor(() => { expect(screen.getByLabelText("Review")).toBeTruthy(); @@ -999,7 +982,7 @@ describe("NewTaskModal", () => { it("omits autoMerge from payload when default is selected", async () => { const { props } = renderNewTaskModal(); - fireEvent.change(screen.getByRole("textbox"), { target: { value: "Task default auto-merge" } }); + fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task default auto-merge" } }); fireEvent.click(screen.getByRole("button", { name: "Create Task" })); await waitFor(() => { @@ -1012,7 +995,6 @@ describe("NewTaskModal", () => { it("includes autoMerge true when Enabled is selected", async () => { const { props } = renderNewTaskModal(); - fireEvent.click(screen.getByTestId("task-form-more-options-toggle")); await waitFor(() => { expect(screen.getByTestId("task-automerge-select")).toBeTruthy(); }); @@ -1030,7 +1012,6 @@ describe("NewTaskModal", () => { it("includes autoMerge false when Disabled is selected", async () => { const { props } = renderNewTaskModal(); - fireEvent.click(screen.getByTestId("task-form-more-options-toggle")); await waitFor(() => { expect(screen.getByTestId("task-automerge-select")).toBeTruthy(); }); @@ -1050,7 +1031,7 @@ describe("NewTaskModal", () => { it("includes default normal priority in create payload", async () => { const { props } = renderNewTaskModal(); - fireEvent.change(screen.getByRole("textbox"), { target: { value: "Task with default priority" } }); + fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task with default priority" } }); fireEvent.click(screen.getByRole("button", { name: "Create Task" })); await waitFor(() => { @@ -1065,7 +1046,6 @@ describe("NewTaskModal", () => { it("includes selected priority and resets back to normal after submit", async () => { const { props } = renderNewTaskModal(); - fireEvent.click(screen.getByTestId("task-form-more-options-toggle")); fireEvent.change(screen.getByTestId("task-priority-select"), { target: { value: "urgent" } }); fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task with urgent priority" } }); fireEvent.click(screen.getByRole("button", { name: "Create Task" })); @@ -1086,7 +1066,6 @@ describe("NewTaskModal", () => { it("treats non-default priority as dirty state on cancel", async () => { renderNewTaskModal(); - fireEvent.click(screen.getByTestId("task-form-more-options-toggle")); fireEvent.change(screen.getByTestId("task-priority-select"), { target: { value: "high" } }); mockConfirm.mockResolvedValueOnce(false); @@ -1156,7 +1135,7 @@ describe("NewTaskModal", () => { const { props } = renderNewTaskModal(); // Type description - fireEvent.change(screen.getByRole('textbox'), { target: { value: "Task with agent" } }); + fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task with agent" } }); // Open agent picker and select agent fireEvent.click(screen.getByTestId("new-task-agent-button")); @@ -1182,7 +1161,7 @@ describe("NewTaskModal", () => { it("omits assignedAgentId from payload when no agent is selected", async () => { const { props } = renderNewTaskModal(); - fireEvent.change(screen.getByRole('textbox'), { target: { value: "Task without agent" } }); + fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task without agent" } }); fireEvent.click(screen.getByRole("button", { name: "Create Task" })); @@ -1204,7 +1183,7 @@ describe("NewTaskModal", () => { const { props } = renderNewTaskModal(); // Type description - fireEvent.change(screen.getByRole('textbox'), { target: { value: "Task with agent" } }); + fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task with agent" } }); // Open agent picker and select agent fireEvent.click(screen.getByTestId("new-task-agent-button")); @@ -1274,7 +1253,7 @@ describe("NewTaskModal", () => { renderNewTaskModal(); // Type description - fireEvent.change(screen.getByRole('textbox'), { target: { value: "Task with agent" } }); + fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task with agent" } }); // Open agent picker and select agent fireEvent.click(screen.getByTestId("new-task-agent-button")); @@ -1298,7 +1277,6 @@ describe("NewTaskModal", () => { it("renders GitHub tracking after the Workflow picker in more options", async () => { renderNewTaskModal(); - fireEvent.click(screen.getByTestId("task-form-more-options-toggle")); const workflowLabel = await screen.findByText("Workflow"); const githubTrackingSection = screen.getByTestId("task-form-github-tracking"); @@ -1318,7 +1296,7 @@ describe("NewTaskModal", () => { }); const { props } = renderNewTaskModal(); - fireEvent.change(screen.getByRole("textbox"), { target: { value: "Task with tracking" } }); + fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task with tracking" } }); const toggle = await screen.findByLabelText("Enable GitHub issue tracking for this task"); fireEvent.click(toggle); @@ -1331,4 +1309,49 @@ describe("NewTaskModal", () => { }); }); }); + + /* + FNXC:NewTask 2026-06-22-20:30: + On desktop the New Task dialog is a floating, draggable, resizable, NON-BLOCKING window: the overlay is `pointer-events: none` and aria-modal="false" so behind-clicks pass through and never close the dialog (only the header X / Cancel / Escape dismiss). It carries a draggable header handle and resize handles. + */ + describe("desktop floating window", () => { + beforeEach(() => { + mockViewportMode = "desktop"; + }); + + it("renders a non-blocking (pointer-events: none, aria-modal=false) overlay that does not dismiss on click", () => { + const onClose = vi.fn(); + renderNewTaskModal({ onClose }); + + const overlay = screen.getByTestId("new-task-modal-overlay"); + // Non-blocking: click-through overlay, not a modal. + expect(overlay).toHaveClass("new-task-modal-overlay"); + expect(overlay).toHaveAttribute("aria-modal", "false"); + + // A behind-click on the overlay must NOT close the dialog (no overlay click-to-dismiss). + fireEvent.click(overlay); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("exposes a draggable header handle and resize handles", () => { + renderNewTaskModal(); + + expect(screen.getByTestId("new-task-drag-handle")).toHaveClass("new-task-modal__header--draggable"); + // All eight corner/edge resize handles are present. + for (const dir of ["n", "s", "e", "w", "ne", "nw", "se", "sw"]) { + expect(screen.getByTestId(`new-task-resize-${dir}`)).toBeInTheDocument(); + } + // The floating panel is the fixed-positioned window. + const panel = document.querySelector(".new-task-modal--floating"); + expect(panel).not.toBeNull(); + }); + + it("still closes via the header close button (X)", async () => { + const onClose = vi.fn(); + renderNewTaskModal({ onClose }); + + fireEvent.click(screen.getByLabelText("Close")); + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); + }); + }); }); diff --git a/packages/dashboard/app/components/__tests__/RightDock.test.tsx b/packages/dashboard/app/components/__tests__/RightDock.test.tsx index 19ee343600..2c5bbca504 100644 --- a/packages/dashboard/app/components/__tests__/RightDock.test.tsx +++ b/packages/dashboard/app/components/__tests__/RightDock.test.tsx @@ -347,16 +347,18 @@ describe("RightDock", () => { render(); - // Pop out the currently selected (Files) view from the open dock. + // Pop out the currently selected (Files) view: the floating modal appears AND + // popping out closes the dock (pop-out dismisses the dock so the full-width app + // sits behind the movable modal). The dock unmounts; the floating modal survives. fireEvent.click(screen.getByTestId("right-dock-expand")); expect(screen.getByTestId("right-dock-expand-modal")).toBeInTheDocument(); - - // Toggle the dock closed: the dock itself unmounts, the floating modal MUST survive. - fireEvent.click(screen.getByTestId("harness-toggle-dock")); expect(screen.queryByTestId("right-dock")).toBeNull(); - expect(screen.getByTestId("right-dock-expand-modal")).toBeInTheDocument(); expect(screen.getByTestId("right-dock-expand-body")).toBeInTheDocument(); + // Re-opening the dock does not disturb the independent floating modal. + fireEvent.click(screen.getByTestId("harness-toggle-dock")); + expect(screen.getByTestId("right-dock-expand-modal")).toBeInTheDocument(); + // Its own close button still dismisses it. fireEvent.click(screen.getByTestId("right-dock-expand-close")); expect(screen.queryByTestId("right-dock-expand-modal")).toBeNull(); diff --git a/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx b/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx index 874e8e1c16..15443690bd 100644 --- a/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx @@ -168,11 +168,9 @@ function expectTranscriptTextOrder(...texts: string[]) { } function expectIdleSessionHint() { - const idleHint = screen.getByTestId("task-chat-idle-hint"); - expect(idleHint).toBeVisible(); - expect(idleHint).toHaveTextContent(/no agent is working on this task right now/i); - expect(idleHint).toHaveTextContent(/saved as guidance/i); - expect(idleHint).toHaveTextContent(/next time this task runs/i); + // FNXC:TaskDetailChat 2026-06-22-21:20: The idle "No agent is working…" banner was removed per user request — idle chats stay sendable with no hint shown. + expect(screen.queryByTestId("task-chat-idle-hint")).not.toBeInTheDocument(); + expect(screen.queryByText(/no agent is working on this task right now/i)).not.toBeInTheDocument(); expect(screen.getByPlaceholderText("Steer the currently executing agent")).toBeInTheDocument(); } @@ -672,7 +670,7 @@ describe("TaskChatTab", () => { expect(toolGroup).toHaveAttribute("open"); const invocation = screen.getByTestId("task-chat-tool-invocation"); - const kicker = screen.getByText("Tool call → result"); + const kicker = screen.getByText("Tool call → Result"); expect(invocation).toHaveClass("task-chat-tool-entry", "task-chat-tool-invocation"); expect(kicker).toHaveClass("task-chat-entry-kicker"); expect(kicker).toBeVisible(); @@ -729,7 +727,7 @@ describe("TaskChatTab", () => { await user.click(within(summary as HTMLElement).getByText("1 tool call")); - expect(screen.getByText("Tool call → error")).toBeVisible(); + expect(screen.getByText("Tool call → Error")).toBeVisible(); expect(screen.getByText("Error")).toBeVisible(); expect(screen.getByText("stderr")).toBeVisible(); }); diff --git a/packages/dashboard/app/components/__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(); diff --git a/packages/dashboard/app/components/command-center/CommandCenter.tsx b/packages/dashboard/app/components/command-center/CommandCenter.tsx index b33e7d89fe..b02cbea20c 100644 --- a/packages/dashboard/app/components/command-center/CommandCenter.tsx +++ b/packages/dashboard/app/components/command-center/CommandCenter.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { AlertCircle, Cpu, Gauge } from "lucide-react"; +import { AlertCircle, Gauge } from "lucide-react"; import type { ActivityAnalytics, ColorTheme, LiveSnapshot, SignalsAnalytics, ThemeMode, TokenAnalytics, ToolAnalytics } from "@fusion/core"; import { api } from "../../api/legacy"; import { DateRangePicker, defaultPresets, rangeFromPreset, type DateRange } from "./DateRangePicker"; @@ -268,45 +268,9 @@ function OverviewTab({ // its own empty state, so it renders even when the stat-card aggregates have no // data yet. /* - FNXC:CommandCenter 2026-06-22-18:00: - The "AI Engine" panel hosts the "View Board"/"View Agents" navigation shortcuts and lives inside controlsSection, which renders in EVERY Overview branch (loading/error/empty/populated), so the panel is always visible regardless of data state. It previously rendered only inside the populated return as the .cc-overview-engine-nav row, leaving loading/empty/error states with no shortcuts. The optional status line reuses already-fetched live-snapshot (inProgressTasks) and activity (activeAgents) data — no new endpoint — and is skipped while the live snapshot is still loading. Navigation is owned by App (onChangeView), so the button row only renders when wired up. + FNXC:CommandCenter 2026-06-22-20:55: + The Overview's AI-engine controls are a SINGLE instance: the CommandCenterControls "AI engine" card (Stop AI Engine) now also hosts the "View Board"/"View Agents" shortcuts (threaded onChangeView). The earlier duplicate `.cc-overview-engine-panel` (a second AI Engine row) was removed — the buttons moved into the first instance. */ - const enginePanel = ( -
-
-
- {!liveSnapshotLoading ? ( -

- {t("commandCenter.overview.aiEngineStatus", "{{agents}} agents working · {{tasks}} tasks in progress", { - agents: formatCount(activeAgents), - tasks: formatCount(inProgressTasks), - })} -

- ) : null} - {onChangeView ? ( -
- - -
- ) : null} -
- ); const controlsSection = ( <> - {enginePanel} ); const throughputSection = ( diff --git a/packages/dashboard/app/components/command-center/CommandCenterControls.tsx b/packages/dashboard/app/components/command-center/CommandCenterControls.tsx index ef0fef7029..039d980283 100644 --- a/packages/dashboard/app/components/command-center/CommandCenterControls.tsx +++ b/packages/dashboard/app/components/command-center/CommandCenterControls.tsx @@ -5,6 +5,7 @@ import { DEFAULT_PROJECT_SETTINGS, type ColorTheme, type ThemeMode } from "@fusi import { fetchConfig, fetchSettings, updateSettings } from "../../api/legacy"; import { useAppSettings } from "../../hooks/useAppSettings"; import { ThemeDropdown } from "../ThemeDropdown"; +import type { TaskView } from "../../hooks/useViewState"; import "./CommandCenterControls.css"; export interface CommandCenterControlsProps { @@ -16,6 +17,8 @@ export interface CommandCenterControlsProps { onColorThemeChange: (theme: ColorTheme) => void; onThemeModeChange: (mode: ThemeMode) => void; onShadcnCustomColorsChange?: (colors: Record) => void; + /* FNXC:CommandCenter 2026-06-22-20:55: View Board / View Agents shortcuts live in the AI engine card (under Stop AI Engine), so this is the single AI-engine instance on Overview — the duplicate cc-overview-engine-panel was removed. */ + onChangeView?: (view: TaskView) => void; } type AsyncState = @@ -66,7 +69,7 @@ function StatusPill({ paused, label }: { paused: boolean; label: string }) { ); } -export function CommandCenterControls({ projectId, colorTheme, themeMode, shadcnCustomColors = {}, resolvedThemeMode = themeMode === "light" ? "light" : "dark", onColorThemeChange, onThemeModeChange, onShadcnCustomColorsChange = () => {} }: CommandCenterControlsProps) { +export function CommandCenterControls({ projectId, colorTheme, themeMode, shadcnCustomColors = {}, resolvedThemeMode = themeMode === "light" ? "light" : "dark", onColorThemeChange, onThemeModeChange, onShadcnCustomColorsChange = () => {}, onChangeView }: CommandCenterControlsProps) { const { t } = useTranslation("app"); const { globalPaused, @@ -179,6 +182,24 @@ export function CommandCenterControls({ projectId, colorTheme, themeMode, shadcn : t("header.stopAiEngine", "Stop AI Engine")} + {onChangeView ? ( +
+ + +
+ ) : null}
diff --git a/packages/dashboard/app/components/floatingWindowStack.ts b/packages/dashboard/app/components/floatingWindowStack.ts new file mode 100644 index 0000000000..d2e8917a48 --- /dev/null +++ b/packages/dashboard/app/components/floatingWindowStack.ts @@ -0,0 +1,18 @@ +/* +FNXC:FloatingWindow 2026-06-22-21:30: +SHARED floating-window z-index stack. This is the ONE source of z-index for every floating modal in the dashboard (FloatingWindow, the right-dock pop-out, the floating terminal, the floating New Task dialog) so they interoperate in a SINGLE stack instead of each type owning a private counter. Previously each modal type managed z-index independently, so tapping e.g. the terminal could not raise it above a popped-out task-detail FloatingWindow. Now every floating modal claims `nextFloatingZ()` on mount/open and again on every panel pointerdown/focus, so the most-recently-interacted window is always on top REGARDLESS of type. + +FNXC:FloatingWindow 2026-06-22-22:30: +Base band sits at 10100+ — ABOVE the page overlay/popover band (log viewer, workflow-editor modal, selection popover, fullscreen overlay at z 10000-10001) so a floating window the user is dragging is never painted over by those. Transient top-right toasts are bumped to 10500 (styles.css) so system feedback still shows above a dragged window. The counter is module-level and intentionally monotonic: it only ever climbs, which is fine for a session-length dashboard. All floating overlays are `pointer-events: none` (click-through) so raising panels into this shared band never traps clicks on the page behind them. CRITICAL: every floating modal must be portaled to document.body so this shared z is compared in ONE root stacking context (an inline panel cannot beat siblings outside its own context no matter its z). +*/ +let topZ = 10100; + +/** Claim the front of the shared floating-window stack. Monotonic, session-length. */ +export function nextFloatingZ(): number { + return ++topZ; +} + +/** Current top of the stack (read-only). Lets a window skip a needless bump when already on top. */ +export function currentFloatingZ(): number { + return topZ; +} diff --git a/packages/dashboard/vitest.config.ts b/packages/dashboard/vitest.config.ts index 19a21eb518..c961da3757 100644 --- a/packages/dashboard/vitest.config.ts +++ b/packages/dashboard/vitest.config.ts @@ -424,6 +424,7 @@ export default defineConfig({ "@fusion/plugin-sdk": resolve(__dirname, "../plugin-sdk/src/index.ts"), "@fusion/test-utils": resolve(__dirname, "../core/src/__test-utils__/workspace.ts"), "@fusion/dashboard/app/components/TaskCard": resolve(__dirname, "app/components/TaskCard.tsx"), + "@fusion/dashboard/app/components/ViewHeader": resolve(__dirname, "app/components/ViewHeader.tsx"), "@fusion/dashboard/app/plugins/types": resolve(__dirname, "app/plugins/types.ts"), "@fusion/dashboard/app/utils/projectStorage": resolve(__dirname, "app/utils/projectStorage.ts"), "@fusion/dashboard/app/utils/taskStuck": resolve(__dirname, "app/utils/taskStuck.ts"), diff --git a/packages/dashboard/vitest.setup.ts b/packages/dashboard/vitest.setup.ts index 1b651cb65e..c2b1471981 100644 --- a/packages/dashboard/vitest.setup.ts +++ b/packages/dashboard/vitest.setup.ts @@ -16,7 +16,29 @@ await i18next.use(initReactI18next).init({ // Each namespace present (empty) so hasLoadedNamespace() is true — an // unloaded namespace makes useTranslation() suspend (no Suspense boundary // in component tests) even with useSuspense disabled belt-and-braces below. - resources: { en: { common: {}, app: {}, errors: {} } }, + // + // FNXC:TestI18n 2026-06-22-21:40: + // Pluralized count keys must resolve from resources, not the singular inline + // default. t("taskChat.entryCount", "{{count}} entry", { count }) renders the + // singular default for ALL counts when the key is absent — so count=2 became + // "2 entry". Provide the _one/_other forms (as the real en locale does) so the + // correct plural ("2 entries", "7 tool calls") renders in tests too. Only these + // keys resolve from the bundle; every other key still falls back to its inline + // default, preserving existing assertions. + resources: { + en: { + common: {}, + app: { + taskChat: { + entryCount_one: "{{count}} entry", + entryCount_other: "{{count}} entries", + toolCallCount_one: "{{count}} tool call", + toolCallCount_other: "{{count}} tool calls", + }, + }, + errors: {}, + }, + }, ns: ["common", "app", "errors"], defaultNS: "common", interpolation: { escapeValue: false },