feat(dashboard): floating windows (pop-out task detail, floating New Task), consolidate AI engine row

- FloatingWindow: reusable non-blocking, draggable, smoothly-resizable window with focus-to-front z-index so multiple coexist (file browser + terminal + several task details open and movable at once).
- Task detail gains a Pop out (Maximize2) button in List + Board; App tracks multiple open floating task-detail windows (dedupe by id).
- New Task dialog is now a floating, draggable, resizable, non-blocking window; all quick-add controls visible without expanding (TaskForm forceMoreOptionsOpen).
- Dashboard Overview: removed the duplicate AI Engine row — View Board / View Agents moved into the first instance (the AI engine card, under Stop AI Engine).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-22 05:10:12 -07:00
parent 2a28967133
commit 62a96e3712
12 changed files with 1148 additions and 138 deletions

View File

@@ -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<Task | TaskDetail | null>(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<Array<Task | TaskDetail>>([]);
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>(taskView);
useEffect(() => {
@@ -2053,6 +2067,7 @@ function AppInner() {
Board-card detail (full main panel) renders its "Back to board" affordance inside TaskDetailContent's gray header (far right, across from the task id) instead of a separate back-row above the content. The prop only renders the header back button when both embedded and onBackToBoard are present, so ListView split-pane and modal usages stay unaffected.
*/
onBackToBoard={closeTaskDetailMainPanel}
onPopOut={popOutTaskDetail}
onOpenDetail={(value) => setMainPanelDetailTask(value)}
onMoveTask={moveTask}
onDeleteTask={deleteTask}
@@ -2148,6 +2163,7 @@ function AppInner() {
onResetTask={resetTask}
onDuplicateTask={duplicateTask}
onOpenDetail={(task, options) => openDetailTask(task, undefined, options)}
onPopOut={popOutTaskDetail}
addToast={addToast}
globalPaused={globalPaused}
onNewTask={openNewTaskWithNav}
@@ -2495,6 +2511,45 @@ function AppInner() {
onToggleModelFavorite={handleToggleModelFavorite}
/>
)}
{/*
FNXC:FloatingWindow 2026-06-22-20:45:
One movable, resizable, non-blocking FloatingWindow per popped-out task. Each hosts the same embedded TaskDetailContent List/Board use, wired to the same App task handlers. Live row preferred by id; falls back to the snapshot. Terminal/destructive actions and the window close button both remove the entry. Multiple entries → multiple coexisting windows; FloatingWindow's per-window z-counter handles focus-to-front so the clicked one comes on top.
*/}
{poppedOutTasks.map((snapshot) => {
const liveTask = tasks.find((candidate) => candidate.id === snapshot.id) ?? snapshot;
const close = () => closePoppedOutTask(snapshot.id);
return (
<FloatingWindow
key={snapshot.id}
windowKey={`task-detail-${snapshot.id}`}
title={
<>
<Maximize2 size={14} aria-hidden="true" />
<span>{liveTask.id}</span>
</>
}
onClose={close}
>
<TaskDetailContent
task={liveTask}
projectId={currentProject?.id}
tasks={tasks}
embedded
onOpenDetail={popOutTaskDetail}
onMoveTask={moveTask}
onDeleteTask={deleteTask}
onMergeTask={mergeTask}
onRetryTask={retryTask}
onResetTask={resetTask}
onDuplicateTask={duplicateTask}
onRequestClose={close}
addToast={addToast}
prAuthAvailable={prAuthAvailable}
autoMergeEnabled={autoMerge}
/>
</FloatingWindow>
);
})}
<AppModals
projectId={currentProject?.id}
tasks={tasks}

View File

@@ -0,0 +1,148 @@
/*
FNXC:FloatingWindow 2026-06-22-20:45:
FloatingWindow is a non-blocking floating window (generalized from RightDockExpandModal). The overlay is a full-viewport, transparent, NON-dimming, NON-blurring, click-through layer: `pointer-events: none` lets every click pass through to the app and to other windows behind it. Only the panel re-enables `pointer-events: auto`. Because the overlay never intercepts clicks there is no overlay click-to-dismiss; the header close button is the only dismissal. Multiple overlays/panels coexist with no mutual blocking — z-stacking is driven by inline `z-index` from the component's per-window counter.
*/
.floating-window-overlay {
position: fixed;
inset: 0;
background: transparent;
backdrop-filter: none;
pointer-events: none;
}
/*
FNXC:FloatingWindow 2026-06-22-20:45:
Floating panel positioned by state-driven inline `left/top/width/height` and stacked by inline `z-index`. min/max keep the panel usable and on-screen. `resize: none` because resizing is handled by the corner/edge handles. `pointer-events: auto` re-enables interaction on the panel only.
*/
.floating-window {
position: fixed;
display: flex;
flex-direction: column;
min-width: calc(var(--space-2xl) * 7.5);
min-height: calc(var(--space-2xl) * 5.83);
max-width: calc(100vw - (var(--space-lg) * 2));
max-height: calc(100dvh - (var(--space-lg) * 2));
overflow: hidden;
background: var(--surface);
border: thin solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xl);
color: var(--text);
resize: none;
pointer-events: auto;
}
/*
FNXC:FloatingWindow 2026-06-22-20:45:
Header is the drag handle. `touch-action: none` (matching the resize handles) hands the whole gesture to the pointer handlers so touch dragging stays smooth and never scrolls the page behind it. A comfortable min-height makes a forgiving touch target. `user-select: none` protects the drag from selecting header text.
*/
.floating-window__header {
display: flex;
flex-shrink: 0;
align-items: center;
gap: var(--space-sm);
min-height: 44px;
padding: var(--space-sm) var(--space-md);
border-bottom: thin solid var(--border);
background: var(--surface-elevated, var(--surface));
cursor: grab;
user-select: none;
touch-action: none;
}
.floating-window__header:active {
cursor: grabbing;
}
.floating-window__title {
display: flex;
flex: 1;
align-items: center;
gap: var(--space-sm);
min-width: 0;
overflow: hidden;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.floating-window__close {
display: inline-flex;
align-items: center;
justify-content: center;
padding: var(--space-xs);
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-muted);
cursor: pointer;
}
.floating-window__close:hover {
background: var(--status-todo-bg, var(--surface));
color: var(--text);
}
/*
FNXC:FloatingWindow 2026-06-22-20:45:
Body is a flex host that lets its single child stretch to the full panel width/height (min-width/min-height:0 so a wide child cannot collapse the flex line, and the child's own overflow can engage).
*/
.floating-window__body {
display: flex;
flex: 1;
min-width: 0;
min-height: 0;
overflow: auto;
}
.floating-window__body > * {
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; }

View File

@@ -0,0 +1,317 @@
import {
useCallback,
useEffect,
useRef,
useState,
type CSSProperties,
type PointerEvent as ReactPointerEvent,
type ReactNode,
} from "react";
import { X } from "lucide-react";
import "./FloatingWindow.css";
/*
FNXC:FloatingWindow 2026-06-22-20:45:
FloatingWindow is the REUSABLE non-blocking floating window. It generalizes the proven RightDockExpandModal technique (transparent `pointer-events:none` overlay, a `position:fixed; pointer-events:auto` panel dragged by its header via setPointerCapture + captured-element listeners + pointerId filtering + rAF-batched position, edge/corner resize handles, `touch-action:none` handles, and a single dragTeardownRef detached on pointerup/cancel AND unmount). It hosts ARBITRARY children so several windows (file browser, terminal, multiple task details) can coexist without blocking the page or each other.
MULTI-WINDOW STACKING: a module-level z-index counter (`topZ`) hands each window a fresh z on mount and on every panel pointerdown/focus, so the most recently interacted-with window floats to the front. All overlays are click-through; only the panels capture pointer events, so every open FloatingWindow is independently movable and none blocks the page behind it.
*/
export interface FloatingWindowSize {
width: number;
height: number;
}
export interface FloatingWindowPosition {
x: number;
y: number;
}
export interface FloatingWindowProps {
title: ReactNode;
onClose: () => void;
children: ReactNode;
/** Stable identity for this window; used to derive a deterministic cascade offset for the default position. */
windowKey: string;
defaultSize?: FloatingWindowSize;
defaultPosition?: FloatingWindowPosition;
minSize?: FloatingWindowSize;
}
const DEFAULT_WIDTH = 720;
const DEFAULT_HEIGHT = 560;
const DEFAULT_MIN_WIDTH = 360;
const DEFAULT_MIN_HEIGHT = 280;
const VIEWPORT_PADDING = 16;
/*
FNXC:FloatingWindow 2026-06-22-20:45:
Base z-index band sits at 4000+, above ordinary page content and interoperable with the existing terminal/right-dock pop-out band. `nextZ()` bumps the shared counter so a freshly mounted or freshly clicked window comes to the front. The counter is module-level and intentionally monotonic — it only ever climbs, which is fine for a session-length dashboard.
*/
let topZ = 4000;
function nextZ(): number {
return ++topZ;
}
type ResizeDirection = "n" | "s" | "e" | "w" | "ne" | "nw" | "se" | "sw";
const RESIZE_DIRECTIONS: ResizeDirection[] = ["n", "s", "e", "w", "ne", "nw", "se", "sw"];
/** Hash a windowKey into a small bounded cascade index so stacked default windows do not perfectly overlap. */
function cascadeIndexFor(windowKey: string): number {
let hash = 0;
for (let i = 0; i < windowKey.length; i += 1) {
hash = (hash * 31 + windowKey.charCodeAt(i)) | 0;
}
return Math.abs(hash) % 6;
}
function clampSize(size: FloatingWindowSize, minSize: FloatingWindowSize): FloatingWindowSize {
if (typeof window === "undefined") return size;
return {
width: Math.min(Math.max(size.width, minSize.width), Math.max(minSize.width, window.innerWidth - VIEWPORT_PADDING * 2)),
height: Math.min(Math.max(size.height, minSize.height), Math.max(minSize.height, window.innerHeight - VIEWPORT_PADDING * 2)),
};
}
function clampPosition(position: FloatingWindowPosition, size: FloatingWindowSize): FloatingWindowPosition {
if (typeof window === "undefined") return position;
return {
x: Math.min(Math.max(position.x, VIEWPORT_PADDING), Math.max(VIEWPORT_PADDING, window.innerWidth - size.width - VIEWPORT_PADDING)),
y: Math.min(Math.max(position.y, VIEWPORT_PADDING), Math.max(VIEWPORT_PADDING, window.innerHeight - size.height - VIEWPORT_PADDING)),
};
}
/*
FNXC:FloatingWindow 2026-06-22-20:45:
Default position cascades by windowKey so opening several windows in a row visibly offsets each one from a roughly-centered origin instead of stacking them pixel-perfect on top of one another.
*/
function defaultPositionFor(windowKey: string, size: FloatingWindowSize): FloatingWindowPosition {
if (typeof window === "undefined") return { x: VIEWPORT_PADDING, y: VIEWPORT_PADDING };
const cascade = cascadeIndexFor(windowKey) * 28;
return clampPosition(
{ x: (window.innerWidth - size.width) / 2 + cascade, y: (window.innerHeight - size.height) / 2 + cascade },
size
);
}
export function FloatingWindow({
title,
onClose,
children,
windowKey,
defaultSize,
defaultPosition,
minSize,
}: FloatingWindowProps) {
const resolvedMinSize: FloatingWindowSize = minSize ?? { width: DEFAULT_MIN_WIDTH, height: DEFAULT_MIN_HEIGHT };
const [size, setSize] = useState<FloatingWindowSize>(() =>
clampSize(defaultSize ?? { width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT }, resolvedMinSize)
);
const [position, setPosition] = useState<FloatingWindowPosition>(() => {
const initialSize = clampSize(defaultSize ?? { width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT }, resolvedMinSize);
return defaultPosition ? clampPosition(defaultPosition, initialSize) : defaultPositionFor(windowKey, initialSize);
});
// FNXC:FloatingWindow 2026-06-22-20:45: Each window owns its z-index; mounting claims the front of the stack.
const [zIndex, setZIndex] = useState<number>(() => nextZ());
/*
FNXC:FloatingWindow 2026-06-22-20:45:
A single active-drag/resize teardown (copied from the RightDockExpandModal pattern). pointerup/pointercancel run it, and the unmount effect runs it too, so an in-progress gesture interrupted by close/unmount never leaks captured-element pointer listeners or a pending rAF.
*/
const dragTeardownRef = useRef<(() => void) | null>(null);
// FNXC:FloatingWindow 2026-06-22-20:45: Focus-to-front. Pointerdown/focus anywhere on the panel raises this window above the rest.
const bringToFront = useCallback(() => {
setZIndex((current) => {
// Only claim a new z if we are not already on top, to avoid needless counter churn on every move.
if (current > topZ) return current;
return nextZ();
});
}, []);
const handleDragPointerDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
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<HTMLDivElement>, direction: ResizeDirection) => {
event.preventDefault();
event.stopPropagation();
bringToFront();
const captureTarget = event.currentTarget;
const pointerId = event.pointerId;
captureTarget.setPointerCapture?.(pointerId);
const startX = event.clientX;
const startY = event.clientY;
const startSize = size;
const startPosition = position;
const previousUserSelect = document.body.style.userSelect;
document.body.style.userSelect = "none";
let latestSize = startSize;
let latestPosition = startPosition;
let frame = 0;
const handlePointerMove = (moveEvent: PointerEvent) => {
if (moveEvent.pointerId !== pointerId) return;
const dx = moveEvent.clientX - startX;
const dy = moveEvent.clientY - startY;
const nextSize = clampSize(
{
width: startSize.width + (direction.includes("e") ? dx : direction.includes("w") ? -dx : 0),
height: startSize.height + (direction.includes("s") ? dy : direction.includes("n") ? -dy : 0),
},
resolvedMinSize
);
const nextPosition = {
x: startPosition.x + (direction.includes("w") ? startSize.width - nextSize.width : 0),
y: startPosition.y + (direction.includes("n") ? startSize.height - nextSize.height : 0),
};
latestSize = nextSize;
latestPosition = nextPosition;
if (frame) return;
frame = requestAnimationFrame(() => {
frame = 0;
setSize(latestSize);
setPosition(clampPosition(latestPosition, latestSize));
});
};
const detachListeners = () => {
captureTarget.releasePointerCapture?.(pointerId);
captureTarget.removeEventListener("pointermove", handlePointerMove);
captureTarget.removeEventListener("pointerup", handlePointerUp);
captureTarget.removeEventListener("pointercancel", handlePointerUp);
};
function handlePointerUp() {
if (frame) cancelAnimationFrame(frame);
setSize(latestSize);
setPosition(clampPosition(latestPosition, latestSize));
document.body.style.userSelect = previousUserSelect;
detachListeners();
dragTeardownRef.current = null;
}
dragTeardownRef.current = () => {
if (frame) cancelAnimationFrame(frame);
document.body.style.userSelect = previousUserSelect;
detachListeners();
dragTeardownRef.current = null;
};
captureTarget.addEventListener("pointermove", handlePointerMove);
captureTarget.addEventListener("pointerup", handlePointerUp);
captureTarget.addEventListener("pointercancel", handlePointerUp);
},
[bringToFront, position, resolvedMinSize, size]
);
// FNXC:FloatingWindow 2026-06-22-20:45: Run any active drag/resize teardown on unmount so captured-element listeners + a pending rAF never outlive the window.
useEffect(() => () => dragTeardownRef.current?.(), []);
const panelStyle = {
left: `${position.x}px`,
top: `${position.y}px`,
width: `${size.width}px`,
height: `${size.height}px`,
zIndex,
} as CSSProperties;
return (
<div
className="floating-window-overlay"
role="dialog"
aria-modal="false"
data-testid={`floating-window-overlay-${windowKey}`}
>
<div
className="floating-window"
style={panelStyle}
data-testid={`floating-window-${windowKey}`}
onPointerDownCapture={bringToFront}
onFocusCapture={bringToFront}
>
{RESIZE_DIRECTIONS.map((direction) => (
<div
key={direction}
className={`floating-window__resize-handle floating-window__resize-handle--${direction}`}
data-testid={`floating-window-resize-${direction}`}
role="separator"
aria-label="Resize floating window"
onPointerDown={(event) => handleResizePointerDown(event, direction)}
/>
))}
<div
className="floating-window__header"
data-testid={`floating-window-drag-handle-${windowKey}`}
onPointerDown={handleDragPointerDown}
>
<div className="floating-window__title">{title}</div>
<button
type="button"
className="floating-window__close"
onClick={onClose}
aria-label="Close floating window"
data-testid={`floating-window-close-${windowKey}`}
>
<X size={18} />
</button>
</div>
<div className="floating-window__body" data-testid={`floating-window-body-${windowKey}`}>
{children}
</div>
</div>
</div>
);
}

View File

@@ -208,6 +208,11 @@ interface ListViewProps {
onResetTask?: (id: string) => Promise<Task>;
onDuplicateTask?: (id: string) => Promise<Task>;
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;

View File

@@ -3,6 +3,99 @@
min-height: min(520px, 80vh);
}
/*
FNXC:NewTask 2026-06-22-20:30:
The New Task dialog is a FLOATING, DRAGGABLE, RESIZABLE, NON-BLOCKING window (mirrors the right-dock pop-out). The overlay MUST out-specify the base `.modal-overlay` (which dims + blurs the page). Both base and override are single-class, so a two-class selector (`.modal-overlay.new-task-modal-overlay`) guarantees the transparent, non-blurring, click-through backdrop regardless of stylesheet order. `pointer-events: none` lets behind-clicks pass through to the app; the floating panel re-enables `pointer-events: auto`. No overlay click-to-dismiss — the header X / Cancel / Escape are the only dismissals.
*/
.modal-overlay.new-task-modal-overlay {
align-items: stretch;
justify-content: flex-start;
padding: 0;
background: transparent;
backdrop-filter: none;
pointer-events: none;
}
/*
FNXC:NewTask 2026-06-22-20:30:
Floating panel positioned by state-driven inline left/top/width/height. min/max keep content usable and the panel on-screen; `resize: none` because the corner/edge handles own resizing (the native grip conflicts with the pointer handlers). `pointer-events: auto` re-enables interaction on the panel only. Desktop only — mobile keeps the full-screen keyboard-aware sheet.
*/
.new-task-modal--floating {
position: fixed;
display: flex;
flex-direction: column;
min-width: calc(var(--space-2xl) * 8.75);
min-height: calc(var(--space-2xl) * 7.5);
max-width: calc(100vw - (var(--space-lg) * 2));
max-height: calc(100dvh - (var(--space-lg) * 2));
resize: none;
pointer-events: auto;
box-shadow: var(--shadow-xl);
}
.new-task-modal--floating .modal-body {
max-height: none;
}
/*
FNXC:NewTask 2026-06-22-20:30:
Header is the drag handle. `touch-action: none` (matching the resize handles) hands the whole gesture to our pointer handlers so a finger drag stays smooth and never scrolls the page behind it. `cursor: grab/grabbing` is desktop-only signal.
*/
.new-task-modal__header--draggable {
cursor: grab;
user-select: none;
touch-action: none;
}
.new-task-modal__header--draggable:active {
cursor: grabbing;
}
/*
FNXC:NewTask 2026-06-22-20:30:
Edge + corner resize handles. touch-action:none keeps the drag from being hijacked by scroll/gestures so resizing stays smooth.
*/
.new-task-resize-handle {
position: absolute;
z-index: 2;
touch-action: none;
}
.new-task-resize-handle--n,
.new-task-resize-handle--s {
left: var(--space-sm);
right: var(--space-sm);
height: var(--space-sm);
cursor: ns-resize;
}
.new-task-resize-handle--n { top: 0; }
.new-task-resize-handle--s { bottom: 0; }
.new-task-resize-handle--e,
.new-task-resize-handle--w {
top: var(--space-sm);
bottom: var(--space-sm);
width: var(--space-sm);
cursor: ew-resize;
}
.new-task-resize-handle--e { right: 0; }
.new-task-resize-handle--w { left: 0; }
.new-task-resize-handle--ne,
.new-task-resize-handle--nw,
.new-task-resize-handle--se,
.new-task-resize-handle--sw {
width: var(--space-lg);
height: var(--space-lg);
}
.new-task-resize-handle--ne { top: 0; right: 0; cursor: nesw-resize; }
.new-task-resize-handle--nw { top: 0; left: 0; cursor: nwse-resize; }
.new-task-resize-handle--se { bottom: 0; right: 0; cursor: nwse-resize; }
.new-task-resize-handle--sw { bottom: 0; left: 0; cursor: nesw-resize; }
.new-task-modal .modal-body {
padding: var(--space-xl);
overflow-y: auto;

View File

@@ -1,5 +1,5 @@
import "./NewTaskModal.css";
import { useState, useCallback, useEffect, useRef } from "react";
import { useState, useCallback, useEffect, useRef, type CSSProperties, type PointerEvent as ReactPointerEvent } from "react";
import { useTranslation } from "react-i18next";
import { DEFAULT_TASK_PRIORITY, type Task, type TaskCreateInput, type TaskPriority } from "@fusion/core";
import { getErrorMessage } from "@fusion/core";
@@ -30,6 +30,97 @@ interface NewTaskModalProps {
onSubtaskBreakdown?: (description: string, workflowId?: string | null) => void;
}
/*
FNXC:NewTask 2026-06-22-20:30:
The New Task dialog is a FLOATING, DRAGGABLE, RESIZABLE, NON-BLOCKING window matching the right-dock pop-out (RightDockExpandModal). The overlay is transparent and `pointer-events: none` so the app behind stays usable and behind-clicks pass through — there is therefore NO overlay click-to-dismiss; the header close (X) and Cancel button are the only dismissals (plus Escape). The panel is `position: fixed; pointer-events: auto`, dragged by its header and resized from corner/edge handles, with rAF-batched position/size state and a single teardown ref invoked on pointerup/pointercancel AND on unmount so no document/element listeners or pending rAF leak. Size/position persist to localStorage. On mobile we keep the full-screen sheet behavior (no floating) so the keyboard-aware layout still works.
*/
const NEW_TASK_MODAL_SIZE_STORAGE_KEY = "fusion:new-task-modal-size";
const NEW_TASK_MODAL_POSITION_STORAGE_KEY = "fusion:new-task-modal-position";
const NEW_TASK_DEFAULT_WIDTH = 720;
const NEW_TASK_DEFAULT_HEIGHT = 640;
const NEW_TASK_MIN_WIDTH = 420;
const NEW_TASK_MIN_HEIGHT = 360;
const NEW_TASK_VIEWPORT_PADDING = 16;
interface FloatSize {
width: number;
height: number;
}
interface FloatPosition {
x: number;
y: number;
}
function clampFloatSize(size: FloatSize): FloatSize {
if (typeof window === "undefined") return size;
return {
width: Math.min(Math.max(size.width, NEW_TASK_MIN_WIDTH), Math.max(NEW_TASK_MIN_WIDTH, window.innerWidth - NEW_TASK_VIEWPORT_PADDING * 2)),
height: Math.min(Math.max(size.height, NEW_TASK_MIN_HEIGHT), Math.max(NEW_TASK_MIN_HEIGHT, window.innerHeight - NEW_TASK_VIEWPORT_PADDING * 2)),
};
}
function clampFloatPosition(position: FloatPosition, size: FloatSize): FloatPosition {
if (typeof window === "undefined") return position;
return {
x: Math.min(Math.max(position.x, NEW_TASK_VIEWPORT_PADDING), Math.max(NEW_TASK_VIEWPORT_PADDING, window.innerWidth - size.width - NEW_TASK_VIEWPORT_PADDING)),
y: Math.min(Math.max(position.y, NEW_TASK_VIEWPORT_PADDING), Math.max(NEW_TASK_VIEWPORT_PADDING, window.innerHeight - size.height - NEW_TASK_VIEWPORT_PADDING)),
};
}
function readFloatSize(): FloatSize {
if (typeof window === "undefined") return { width: NEW_TASK_DEFAULT_WIDTH, height: NEW_TASK_DEFAULT_HEIGHT };
try {
const raw = window.localStorage.getItem(NEW_TASK_MODAL_SIZE_STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw) as Partial<FloatSize>;
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<FloatPosition>;
if (typeof parsed.x === "number" && typeof parsed.y === "number") {
return clampFloatPosition({ x: parsed.x, y: parsed.y }, size);
}
}
} catch {
// ignore corrupted persisted position
}
// Default: roughly centered.
return clampFloatPosition({ x: (window.innerWidth - size.width) / 2, y: (window.innerHeight - size.height) / 2 }, size);
}
function writeFloatPosition(position: FloatPosition, size: FloatSize): FloatPosition {
const clamped = clampFloatPosition(position, size);
if (typeof window !== "undefined") {
window.localStorage.setItem(NEW_TASK_MODAL_POSITION_STORAGE_KEY, JSON.stringify(clamped));
}
return clamped;
}
type FloatResizeDirection = "n" | "s" | "e" | "w" | "ne" | "nw" | "se" | "sw";
const NEW_TASK_RESIZE_DIRECTIONS: FloatResizeDirection[] = ["n", "s", "e", "w", "ne", "nw", "se", "sw"];
export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, addToast, initialDescription = "", onPlanningMode, onSubtaskBreakdown }: NewTaskModalProps) {
const { t } = useTranslation("app");
const { confirm } = useConfirm();
@@ -47,6 +138,145 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
: {};
const [description, setDescription] = useState("");
const wasOpenRef = useRef(false);
/*
FNXC:NewTask 2026-06-22-20:30:
Floating window position/size state (desktop only). Mobile keeps the full-screen sheet, so we only apply the floating panel style and drag/resize handlers when not mobile. A single active-drag teardown (drag OR resize) lives in dragTeardownRef; pointerup/pointercancel AND the unmount effect run it so an interrupted drag never leaks element pointer listeners or a pending rAF.
*/
const isFloating = viewportMode !== "mobile";
const [size, setSizeState] = useState<FloatSize>(() => readFloatSize());
const [position, setPositionState] = useState<FloatPosition>(() => readFloatPosition(readFloatSize()));
const dragTeardownRef = useRef<(() => void) | null>(null);
const persistSize = useCallback((next: FloatSize) => {
setSizeState(writeFloatSize(next));
}, []);
const persistPosition = useCallback((next: FloatPosition, withSize: FloatSize) => {
setPositionState(writeFloatPosition(next, withSize));
}, []);
// FNXC:NewTask 2026-06-22-20:30: Header drag. setPointerCapture redirects the pointer stream to the captured header element, so element-scoped pointermove/up listeners receive the full drag even off the header; moves are rAF-batched; the panel is clamped on-screen. Close button clicks are excluded so dragging never swallows close.
const handleFloatingDragPointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
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<HTMLDivElement>, 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<string[]>([]);
const [branchMode, setBranchMode] = useState<BranchSelectionMode>("project-default");
const [branch, setBranch] = useState("");
@@ -505,14 +735,39 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
if (!isOpen) return null;
// FNXC:NewTask 2026-06-22-20:30: Desktop = floating fixed panel positioned by state-driven left/top/width/height. Mobile keeps the keyboard-aware full-screen sheet (no floating). The transparent click-through overlay never dismisses on click; the header X / Cancel / Escape are the only dismissals.
const panelStyle: CSSProperties = isFloating
? { left: `${position.x}px`, top: `${position.y}px`, width: `${size.width}px`, height: `${size.height}px` }
: keyboardStyle;
return (
<div className="modal-overlay open" onClick={handleClose} onKeyDown={handleKeyDown} role="dialog" aria-modal="true">
<div
className="modal-overlay open new-task-modal-overlay"
onKeyDown={handleKeyDown}
role="dialog"
aria-modal="false"
aria-label={t("newTaskModal.title", "New Task")}
data-testid="new-task-modal-overlay"
>
<div
className="modal modal-lg new-task-modal"
onClick={(e) => e.stopPropagation()}
style={keyboardStyle}
className={`modal modal-lg new-task-modal${isFloating ? " new-task-modal--floating" : ""}`}
style={panelStyle}
>
<div className="modal-header">
{isFloating && NEW_TASK_RESIZE_DIRECTIONS.map((direction) => (
<div
key={direction}
className={`new-task-resize-handle new-task-resize-handle--${direction}`}
data-testid={`new-task-resize-${direction}`}
role="separator"
aria-label={t("newTaskModal.resize", "Resize new task window")}
onPointerDown={(event) => handleFloatingResizePointerDown(event, direction)}
/>
))}
<div
className={`modal-header${isFloating ? " new-task-modal__header--draggable" : ""}`}
data-testid="new-task-drag-handle"
onPointerDown={isFloating ? handleFloatingDragPointerDown : undefined}
>
<h3>{t("newTaskModal.title", "New Task")}</h3>
<button className="modal-close" onClick={handleClose} disabled={isSubmitting} aria-label={t("actions.close", "Close")}>
&times;
@@ -583,6 +838,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
renderBelowPrimary={quickFields}
hideDependencies={true}
autoExpandMoreOptionsOnSelection={false}
forceMoreOptionsOpen={true}
/>
</div>

View File

@@ -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<TaskDetailModalProps, "onClose"> & {
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({
<span>{t("app.taskDetail.backToBoard", "Back to board")}</span>
</button>
)}
{/*
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 && (
<button
type="button"
className="modal-edit-btn"
onClick={() => onPopOut(task)}
title={t("taskDetail.header.popOut", "Pop out")}
aria-label="Pop out"
data-testid="task-detail-pop-out"
>
<Maximize2 size={14} />
</button>
)}
{!isEditing && canEdit && (
<button
className="modal-edit-btn"

View File

@@ -146,6 +146,11 @@ export interface TaskFormProps {
hideDependencies?: boolean;
/** When true (default), More options auto-expands when non-default advanced selections are present. */
autoExpandMoreOptionsOnSelection?: boolean;
/**
* FNXC:NewTask 2026-06-22-20:30:
* When true, the advanced ("More options") controls are always shown — the collapsible disclosure is force-open and its toggle is hidden. The New Task dialog sets this so every quick-add control QuickEntryBox exposes (priority, execution-mode/Fast toggle, model selectors, attachments, node, GitHub tracking, etc.) is visible without a click. Other surfaces keep the default collapsed disclosure.
*/
forceMoreOptionsOpen?: boolean;
}
export function TaskForm({
@@ -200,6 +205,7 @@ export function TaskForm({
renderBelowModelConfiguration,
hideDependencies,
autoExpandMoreOptionsOnSelection = true,
forceMoreOptionsOpen = false,
reviewLevel,
onReviewLevelChange,
autoMerge,
@@ -234,6 +240,8 @@ export function TaskForm({
const [showMoreOptions, setShowMoreOptions] = useState(
autoExpandMoreOptionsOnSelection ? hasInitialMoreOptions : false,
);
// FNXC:NewTask 2026-06-22-20:30: When force-open (New Task dialog), the advanced section is always expanded regardless of the local disclosure toggle.
const moreOptionsOpen = forceMoreOptionsOpen || showMoreOptions;
const [depSearch, setDepSearch] = useState("");
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
const [favoriteProviders, setFavoriteProviders] = useState<string[]>([]);
@@ -435,10 +443,10 @@ export function TaskForm({
// Keep dependency dropdown state clean when advanced options are collapsed.
useEffect(() => {
if (showMoreOptions) return;
if (moreOptionsOpen) return;
setShowDepDropdown(false);
setDepSearch("");
}, [showMoreOptions]);
}, [moreOptionsOpen]);
// Auto-select title input text in edit mode (focus is handled by autoFocus)
useEffect(() => {
@@ -876,24 +884,27 @@ export function TaskForm({
{renderBelowPrimary}
<button
type="button"
className="task-form-more-options-toggle"
onClick={() => setShowMoreOptions((prev) => !prev)}
aria-expanded={showMoreOptions}
aria-controls="task-form-more-options"
disabled={disabled}
data-testid="task-form-more-options-toggle"
>
<span>{t("taskForm.moreOptions", "More options")}</span>
{showMoreOptions ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
{/* 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 && (
<button
type="button"
className="task-form-more-options-toggle"
onClick={() => setShowMoreOptions((prev) => !prev)}
aria-expanded={showMoreOptions}
aria-controls="task-form-more-options"
disabled={disabled}
data-testid="task-form-more-options-toggle"
>
<span>{t("taskForm.moreOptions", "More options")}</span>
{showMoreOptions ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
)}
<div
id="task-form-more-options"
className={`task-form-more-options${showMoreOptions ? "" : " collapsed"}`}
aria-hidden={!showMoreOptions}
hidden={!showMoreOptions}
className={`task-form-more-options${moreOptionsOpen ? "" : " collapsed"}`}
aria-hidden={!moreOptionsOpen}
hidden={!moreOptionsOpen}
data-testid="task-form-more-options"
>
{/* Attachments */}

View File

@@ -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(
<FloatingWindow windowKey="alpha" title="Alpha" onClose={() => {}}>
<div>alpha body</div>
</FloatingWindow>
);
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(
<FloatingWindow windowKey="beta" title="Beta" onClose={() => {}}>
<div>beta body</div>
</FloatingWindow>
);
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(
<>
<FloatingWindow windowKey="first" title="First" onClose={() => {}}>
<div>first</div>
</FloatingWindow>
<FloatingWindow windowKey="second" title="Second" onClose={() => {}}>
<div>second</div>
</FloatingWindow>
</>
);
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(
<FloatingWindow windowKey="gamma" title="Gamma" onClose={onClose}>
<div>gamma body</div>
</FloatingWindow>
);
fireEvent.click(screen.getByTestId("floating-window-close-gamma"));
expect(onClose).toHaveBeenCalledTimes(1);
});
it("multiple windows coexist independently (each renders its own panel)", () => {
render(
<>
<FloatingWindow windowKey="w1" title="W1" onClose={() => {}}>
<div>one</div>
</FloatingWindow>
<FloatingWindow windowKey="w2" title="W2" onClose={() => {}}>
<div>two</div>
</FloatingWindow>
<FloatingWindow windowKey="w3" title="W3" onClose={() => {}}>
<div>three</div>
</FloatingWindow>
</>
);
expect(screen.getByTestId("floating-window-w1")).toBeTruthy();
expect(screen.getByTestId("floating-window-w2")).toBeTruthy();
expect(screen.getByTestId("floating-window-w3")).toBeTruthy();
});
});

View File

@@ -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<ComponentProps<typeof NewTaskModal>>
describe("NewTaskModal", () => {
beforeEach(() => {
vi.clearAllMocks();
mockViewportMode = "mobile";
mockConfirm.mockReset();
mockConfirm.mockResolvedValue(true);
mockUseMobileKeyboard.mockReturnValue({
@@ -131,7 +134,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 +143,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 +158,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 +167,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 +178,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 +188,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 +205,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 +218,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 +235,6 @@ describe("NewTaskModal", () => {
rerender(<NewTaskModal {...props} isOpen={false} />);
rerender(<NewTaskModal {...props} isOpen={true} />);
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
expect(screen.getByTestId("task-form-execution-mode-select")).toHaveValue("standard");
});
@@ -250,7 +247,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 +261,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 +280,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 +330,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 +339,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(<NewTaskModal {...props} initialDescription="Different seed" />);
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 +472,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 +495,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 +515,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 +528,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 +548,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 +561,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 +590,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 +606,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 +622,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 +635,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 +662,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 +680,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 +705,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 +717,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 +751,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 +792,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 +841,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 +860,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 +881,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 +908,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 +926,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 +953,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 +981,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 +994,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 +1011,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 +1030,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 +1045,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 +1065,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 +1134,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 +1160,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 +1182,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 +1252,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 +1276,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 +1295,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 +1308,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));
});
});
});

View File

@@ -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 = (
<div className="cc-overview-engine-panel" data-testid="command-center-engine-panel">
<div className="cc-overview-engine-panel-header">
<Cpu size={18} aria-hidden="true" />
<span className="cc-overview-engine-panel-title">
{t("commandCenter.overview.aiEngine", "AI Engine")}
</span>
</div>
{!liveSnapshotLoading ? (
<p className="cc-overview-engine-panel-status" data-testid="command-center-engine-panel-status">
{t("commandCenter.overview.aiEngineStatus", "{{agents}} agents working · {{tasks}} tasks in progress", {
agents: formatCount(activeAgents),
tasks: formatCount(inProgressTasks),
})}
</p>
) : null}
{onChangeView ? (
<div className="cc-overview-engine-nav">
<button
type="button"
className="btn btn-sm cc-overview-engine-nav-btn"
onClick={() => onChangeView("board")}
>
{t("commandCenter.controls.engine.viewBoard", "View Board")}
</button>
<button
type="button"
className="btn btn-sm cc-overview-engine-nav-btn"
onClick={() => onChangeView("agents")}
>
{t("commandCenter.controls.engine.viewAgents", "View Agents")}
</button>
</div>
) : null}
</div>
);
const controlsSection = (
<>
<CommandCenterControls
@@ -318,8 +282,8 @@ function OverviewTab({
onColorThemeChange={onColorThemeChange}
onThemeModeChange={onThemeModeChange}
onShadcnCustomColorsChange={onShadcnCustomColorsChange}
onChangeView={onChangeView}
/>
{enginePanel}
</>
);
const throughputSection = (

View File

@@ -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<string, string>) => 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<T> =
@@ -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")}
</span>
</button>
{onChangeView ? (
<div className="cc-overview-engine-nav" data-testid="command-center-engine-panel">
<button
type="button"
className="btn btn-sm cc-overview-engine-nav-btn"
onClick={() => onChangeView("board")}
>
{t("commandCenter.controls.engine.viewBoard", "View Board")}
</button>
<button
type="button"
className="btn btn-sm cc-overview-engine-nav-btn"
onClick={() => onChangeView("agents")}
>
{t("commandCenter.controls.engine.viewAgents", "View Agents")}
</button>
</div>
) : null}
</section>
<section className="card cc-controls-card" data-testid="cc-controls-theme">