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:
@@ -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}
|
||||
|
||||
148
packages/dashboard/app/components/FloatingWindow.css
Normal file
148
packages/dashboard/app/components/FloatingWindow.css
Normal 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; }
|
||||
317
packages/dashboard/app/components/FloatingWindow.tsx
Normal file
317
packages/dashboard/app/components/FloatingWindow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")}>
|
||||
×
|
||||
@@ -583,6 +838,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
renderBelowPrimary={quickFields}
|
||||
hideDependencies={true}
|
||||
autoExpandMoreOptionsOnSelection={false}
|
||||
forceMoreOptionsOpen={true}
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user