Merge remote-tracking branch 'origin/main' into feature/onboarding-improve

# Conflicts:
#	packages/dashboard/vite.config.ts
This commit is contained in:
gsxdsm
2026-06-22 09:47:52 -07:00
31 changed files with 1674 additions and 241 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix Import from GitHub remote detection in multi-project dashboards by passing the active `projectId` to the `/api/git/remotes` lookup. The dialog now lists configured GitHub remotes instead of showing "No GitHub remotes detected" when the backend requires project scope.

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Floating modals (the reusable FloatingWindow, the right-dock pop-out, the floating terminal, and the floating New Task dialog) now share a single z-index stack, so tapping any of them brings it to the front above all the others regardless of type.

View File

@@ -13,7 +13,9 @@ import { Header, useViewportMode } from "./components/Header";
import { Board } from "./components/Board";
import { TaskCard } from "./components/TaskCard";
import { ListView } from "./components/ListView";
import { Maximize2 } from "lucide-react";
import { TaskDetailContent } from "./components/TaskDetailModal";
import { FloatingWindow } from "./components/FloatingWindow";
import { ProjectOverview } from "./components/ProjectOverview";
import { MissionManager } from "./components/MissionManager";
import { MailboxView } from "./components/MailboxView";
@@ -553,6 +555,18 @@ function AppInner() {
*/
const [mainPanelDetailTask, setMainPanelDetailTask] = useState<Task | TaskDetail | null>(null);
/*
FNXC:FloatingWindow 2026-06-22-20:45:
Open popped-out task-detail windows. Each entry is a task snapshot rendered inside its own movable, resizable, non-blocking FloatingWindow. Several can be open at once and coexist with the right-dock pop-out and terminal (all click-through overlays). Snapshots survive a tasks revalidation; rendering prefers the live row by id and falls back to the snapshot. Pop-out dedupes by task id — re-popping an already-open task is a no-op (its window stays; focus-to-front in FloatingWindow handles re-raising on click).
*/
const [poppedOutTasks, setPoppedOutTasks] = useState<Array<Task | TaskDetail>>([]);
const popOutTaskDetail = useCallback((task: Task | TaskDetail) => {
setPoppedOutTasks((current) => (current.some((entry) => entry.id === task.id) ? current : [...current, task]));
}, []);
const closePoppedOutTask = useCallback((taskId: string) => {
setPoppedOutTasks((current) => current.filter((entry) => entry.id !== taskId));
}, []);
const previousTaskViewRef = useRef<TaskView>(taskView);
useEffect(() => {
@@ -2053,6 +2067,8 @@ function AppInner() {
Board-card detail (full main panel) renders its "Back to board" affordance inside TaskDetailContent's gray header (far right, across from the task id) instead of a separate back-row above the content. The prop only renders the header back button when both embedded and onBackToBoard are present, so ListView split-pane and modal usages stay unaffected.
*/
onBackToBoard={closeTaskDetailMainPanel}
/* FNXC:FloatingWindow 2026-06-22-21:10: Popping out from the board's full-panel detail also returns the main panel to the board, so the board (not the emptied detail) sits behind the floating window. */
onPopOut={(task) => { popOutTaskDetail(task); closeTaskDetailMainPanel(); }}
onOpenDetail={(value) => setMainPanelDetailTask(value)}
onMoveTask={moveTask}
onDeleteTask={deleteTask}
@@ -2148,6 +2164,7 @@ function AppInner() {
onResetTask={resetTask}
onDuplicateTask={duplicateTask}
onOpenDetail={(task, options) => openDetailTask(task, undefined, options)}
onPopOut={popOutTaskDetail}
addToast={addToast}
globalPaused={globalPaused}
onNewTask={openNewTaskWithNav}
@@ -2496,6 +2513,45 @@ function AppInner() {
onToggleModelFavorite={handleToggleModelFavorite}
/>
)}
{/*
FNXC:FloatingWindow 2026-06-22-20:45:
One movable, resizable, non-blocking FloatingWindow per popped-out task. Each hosts the same embedded TaskDetailContent List/Board use, wired to the same App task handlers. Live row preferred by id; falls back to the snapshot. Terminal/destructive actions and the window close button both remove the entry. Multiple entries → multiple coexisting windows; FloatingWindow's per-window z-counter handles focus-to-front so the clicked one comes on top.
*/}
{poppedOutTasks.map((snapshot) => {
const liveTask = tasks.find((candidate) => candidate.id === snapshot.id) ?? snapshot;
const close = () => closePoppedOutTask(snapshot.id);
return (
<FloatingWindow
key={snapshot.id}
windowKey={`task-detail-${snapshot.id}`}
title={
<>
<Maximize2 size={14} aria-hidden="true" />
<span>{liveTask.id}</span>
</>
}
onClose={close}
>
<TaskDetailContent
task={liveTask}
projectId={currentProject?.id}
tasks={tasks}
embedded
onOpenDetail={popOutTaskDetail}
onMoveTask={moveTask}
onDeleteTask={deleteTask}
onMergeTask={mergeTask}
onRetryTask={retryTask}
onResetTask={resetTask}
onDuplicateTask={duplicateTask}
onRequestClose={close}
addToast={addToast}
prAuthAvailable={prAuthAvailable}
autoMergeEnabled={autoMerge}
/>
</FloatingWindow>
);
})}
<AppModals
projectId={currentProject?.id}
tasks={tasks}

View File

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

View File

@@ -0,0 +1,322 @@
import {
useCallback,
useEffect,
useRef,
useState,
type CSSProperties,
type PointerEvent as ReactPointerEvent,
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import { X } from "lucide-react";
import { nextFloatingZ, currentFloatingZ } from "./floatingWindowStack";
import "./FloatingWindow.css";
/*
FNXC:FloatingWindow 2026-06-22-20:45:
FloatingWindow is the REUSABLE non-blocking floating window. It generalizes the proven RightDockExpandModal technique (transparent `pointer-events:none` overlay, a `position:fixed; pointer-events:auto` panel dragged by its header via setPointerCapture + captured-element listeners + pointerId filtering + rAF-batched position, edge/corner resize handles, `touch-action:none` handles, and a single dragTeardownRef detached on pointerup/cancel AND unmount). It hosts ARBITRARY children so several windows (file browser, terminal, multiple task details) can coexist without blocking the page or each other.
MULTI-WINDOW STACKING: a module-level z-index counter (`topZ`) hands each window a fresh z on mount and on every panel pointerdown/focus, so the most recently interacted-with window floats to the front. All overlays are click-through; only the panels capture pointer events, so every open FloatingWindow is independently movable and none blocks the page behind it.
*/
export interface FloatingWindowSize {
width: number;
height: number;
}
export interface FloatingWindowPosition {
x: number;
y: number;
}
export interface FloatingWindowProps {
title: ReactNode;
onClose: () => void;
children: ReactNode;
/** Stable identity for this window; used to derive a deterministic cascade offset for the default position. */
windowKey: string;
defaultSize?: FloatingWindowSize;
defaultPosition?: FloatingWindowPosition;
minSize?: FloatingWindowSize;
}
const DEFAULT_WIDTH = 720;
const DEFAULT_HEIGHT = 560;
const DEFAULT_MIN_WIDTH = 360;
const DEFAULT_MIN_HEIGHT = 280;
const VIEWPORT_PADDING = 16;
/*
FNXC:FloatingWindow 2026-06-22-21:30:
Z-index now comes from the SHARED `floatingWindowStack` module (`nextFloatingZ`/`currentFloatingZ`) so FloatingWindow stacks in ONE counter with the right-dock pop-out, the floating terminal, and the floating New Task dialog — tapping ANY of them raises it above all the others regardless of type. The local `topZ`/`nextZ` counter this file previously owned is gone.
*/
type ResizeDirection = "n" | "s" | "e" | "w" | "ne" | "nw" | "se" | "sw";
const RESIZE_DIRECTIONS: ResizeDirection[] = ["n", "s", "e", "w", "ne", "nw", "se", "sw"];
/** Hash a windowKey into a small bounded cascade index so stacked default windows do not perfectly overlap. */
function cascadeIndexFor(windowKey: string): number {
let hash = 0;
for (let i = 0; i < windowKey.length; i += 1) {
hash = (hash * 31 + windowKey.charCodeAt(i)) | 0;
}
return Math.abs(hash) % 6;
}
function clampSize(size: FloatingWindowSize, minSize: FloatingWindowSize): FloatingWindowSize {
if (typeof window === "undefined") return size;
return {
width: Math.min(Math.max(size.width, minSize.width), Math.max(minSize.width, window.innerWidth - VIEWPORT_PADDING * 2)),
height: Math.min(Math.max(size.height, minSize.height), Math.max(minSize.height, window.innerHeight - VIEWPORT_PADDING * 2)),
};
}
function clampPosition(position: FloatingWindowPosition, size: FloatingWindowSize): FloatingWindowPosition {
if (typeof window === "undefined") return position;
return {
x: Math.min(Math.max(position.x, VIEWPORT_PADDING), Math.max(VIEWPORT_PADDING, window.innerWidth - size.width - VIEWPORT_PADDING)),
y: Math.min(Math.max(position.y, VIEWPORT_PADDING), Math.max(VIEWPORT_PADDING, window.innerHeight - size.height - VIEWPORT_PADDING)),
};
}
/*
FNXC:FloatingWindow 2026-06-22-20:45:
Default position cascades by windowKey so opening several windows in a row visibly offsets each one from a roughly-centered origin instead of stacking them pixel-perfect on top of one another.
*/
function defaultPositionFor(windowKey: string, size: FloatingWindowSize): FloatingWindowPosition {
if (typeof window === "undefined") return { x: VIEWPORT_PADDING, y: VIEWPORT_PADDING };
const cascade = cascadeIndexFor(windowKey) * 28;
return clampPosition(
{ x: (window.innerWidth - size.width) / 2 + cascade, y: (window.innerHeight - size.height) / 2 + cascade },
size
);
}
export function FloatingWindow({
title,
onClose,
children,
windowKey,
defaultSize,
defaultPosition,
minSize,
}: FloatingWindowProps) {
const resolvedMinSize: FloatingWindowSize = minSize ?? { width: DEFAULT_MIN_WIDTH, height: DEFAULT_MIN_HEIGHT };
const [size, setSize] = useState<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-21:30: Each window owns its z-index; mounting claims the front of the SHARED cross-type stack.
const [zIndex, setZIndex] = useState<number>(() => nextFloatingZ());
/*
FNXC:FloatingWindow 2026-06-22-20:45:
A single active-drag/resize teardown (copied from the RightDockExpandModal pattern). pointerup/pointercancel run it, and the unmount effect runs it too, so an in-progress gesture interrupted by close/unmount never leaks captured-element pointer listeners or a pending rAF.
*/
const dragTeardownRef = useRef<(() => void) | null>(null);
// FNXC:FloatingWindow 2026-06-22-21:30: Focus-to-front. Pointerdown/focus anywhere on the panel raises this window above ALL other floating modals (any type) via the shared stack.
const bringToFront = useCallback(() => {
setZIndex((current) => {
// Only claim a new z if we are not already on top, to avoid needless counter churn on every move.
if (current >= currentFloatingZ()) return current;
return nextFloatingZ();
});
}, []);
const handleDragPointerDown = useCallback(
(event: ReactPointerEvent<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;
/*
FNXC:FloatingWindow 2026-06-22-21:10:
Rendered via a portal to document.body so the window escapes every ancestor stacking context (board card badges, the List view's sticky sort header + column divider, transformed columns, etc.). Without the portal the panel's z-index battles inside whatever subtree mounted it, letting card dependency/overlap tags and the list divider/sort header paint over the modal. At document.body the 4000+ z-index wins over all page content.
*/
return createPortal(
<div
className="floating-window-overlay"
role="dialog"
aria-modal="false"
data-testid={`floating-window-overlay-${windowKey}`}
// FNXC:FloatingWindow 2026-06-22-23:00: The z-index MUST live on the position:fixed overlay (which creates a stacking context), not the panel. A panel z-index is trapped inside the overlay's context and loses to page elements that are stacking contexts in body's context (e.g. the right dock at position:absolute z-index:20). With z on the overlay, the whole window sits at the shared floating band in body's stacking context and reliably paints above page content + tap-to-front reorders correctly.
style={{ zIndex }}
>
<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>,
document.body,
);
}

View File

@@ -86,6 +86,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
const [loadingRemotes, setLoadingRemotes] = useState(false);
const [selectedRemoteName, setSelectedRemoteName] = useState<string>("");
const mountedRef = useRef(false);
const remoteLoadRequestIdRef = useRef(0);
const modalRef = useRef<HTMLDivElement>(null);
useModalResizePersist(modalRef, isOpen && resizePersistEnabled, "fusion:github-modal-size");
const overlayDismissProps = useOverlayDismiss(onClose);
@@ -153,11 +154,22 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
autoLoadedRef.current = null;
mountedRef.current = true;
const remoteLoadRequestId = remoteLoadRequestIdRef.current + 1;
remoteLoadRequestIdRef.current = remoteLoadRequestId;
let cancelled = false;
// Fetch git remotes
fetchGitRemotes()
/*
FNXC:GitHubImport 2026-06-22-09:08:
Import from GitHub must detect remotes for the active project, not the dashboard process fallback.
The remotes API returns an empty list without projectId in multi-project mode, which incorrectly shows "No GitHub remotes detected" for configured repositories.
FNXC:GitHubImport 2026-06-22-09:22:
Project changes can happen while the modal stays open, so remote discovery must ignore stale responses from earlier projectId requests.
A mounted-only guard is insufficient because the next effect marks the component mounted again before the older request resolves.
*/
fetchGitRemotes(projectId)
.then((fetchedRemotes) => {
if (!mountedRef.current) return;
if (cancelled || !mountedRef.current || remoteLoadRequestId !== remoteLoadRequestIdRef.current) return;
setRemotes(fetchedRemotes);
setLoadingRemotes(false);
@@ -179,16 +191,17 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
// If no remotes, owner/repo remain empty
})
.catch(() => {
if (mountedRef.current) {
if (!cancelled && mountedRef.current && remoteLoadRequestId === remoteLoadRequestIdRef.current) {
setLoadingRemotes(false);
}
});
return () => {
cancelled = true;
mountedRef.current = false;
};
}
}, [isOpen]);
}, [isOpen, projectId]);
// Handle remote selection change
const handleRemoteChange = useCallback((remoteName: string) => {
@@ -453,7 +466,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
setImporting(false);
}
}
}, [activeTab, selectedIssueNumber, selectedPullNumber, owner, repo, onImport, isMobile, mobileView]);
}, [activeTab, selectedIssueNumber, selectedPullNumber, owner, repo, projectId, onImport, isMobile, mobileView]);
const selectedIssue = issues.find((i) => i.number === selectedIssueNumber);
const selectedPull = pulls.find((p) => p.number === selectedPullNumber);

View File

@@ -208,6 +208,11 @@ interface ListViewProps {
onResetTask?: (id: string) => Promise<Task>;
onDuplicateTask?: (id: string) => Promise<Task>;
onOpenDetail: (task: Task | TaskDetail, options?: { origin?: "list-mobile" }) => void;
/*
FNXC:FloatingWindow 2026-06-22-20:45:
onPopOut pops the split-pane task detail into a movable, resizable, non-blocking FloatingWindow managed at App level. Wired to the Maximize2 "Pop out" button in TaskDetailContent's header.
*/
onPopOut?: (task: Task | TaskDetail) => void;
addToast: (message: string, type?: ToastType) => void;
globalPaused?: boolean;
onNewTask?: () => void;
@@ -291,6 +296,7 @@ export function ListView({
onMergeTask,
onResetTask,
onDuplicateTask,
onPopOut,
onOpenDetail,
addToast,
globalPaused,
@@ -2473,6 +2479,7 @@ export function ListView({
onRetryTask={onRetryTask}
onResetTask={onResetTask}
onDuplicateTask={onDuplicateTask}
onPopOut={onPopOut ? () => onPopOut(selectedTaskSnapshot) : undefined}
onTaskUpdated={(updatedTask) => {
setSelectedTaskSnapshot((previous) => {
if (!previous || previous.id !== updatedTask.id) return previous;

View File

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

View File

@@ -1,5 +1,6 @@
import "./NewTaskModal.css";
import { useState, useCallback, useEffect, useRef } from "react";
import { useState, useCallback, useEffect, useRef, type CSSProperties, type PointerEvent as ReactPointerEvent } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { DEFAULT_TASK_PRIORITY, type Task, type TaskCreateInput, type TaskPriority } from "@fusion/core";
import { getErrorMessage } from "@fusion/core";
@@ -17,6 +18,7 @@ import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { useNodes } from "../hooks/useNodes";
import { useViewportMode } from "../hooks/useViewportMode";
import { useAgentsMapCache } from "../hooks/useAgentsMapCache";
import { nextFloatingZ, currentFloatingZ } from "./floatingWindowStack";
interface NewTaskModalProps {
isOpen: boolean;
@@ -30,6 +32,97 @@ interface NewTaskModalProps {
onSubtaskBreakdown?: (description: string, workflowId?: string | null) => void;
}
/*
FNXC:NewTask 2026-06-22-20:30:
The New Task dialog is a FLOATING, DRAGGABLE, RESIZABLE, NON-BLOCKING window matching the right-dock pop-out (RightDockExpandModal). The overlay is transparent and `pointer-events: none` so the app behind stays usable and behind-clicks pass through — there is therefore NO overlay click-to-dismiss; the header close (X) and Cancel button are the only dismissals (plus Escape). The panel is `position: fixed; pointer-events: auto`, dragged by its header and resized from corner/edge handles, with rAF-batched position/size state and a single teardown ref invoked on pointerup/pointercancel AND on unmount so no document/element listeners or pending rAF leak. Size/position persist to localStorage. On mobile we keep the full-screen sheet behavior (no floating) so the keyboard-aware layout still works.
*/
const NEW_TASK_MODAL_SIZE_STORAGE_KEY = "fusion:new-task-modal-size";
const NEW_TASK_MODAL_POSITION_STORAGE_KEY = "fusion:new-task-modal-position";
const NEW_TASK_DEFAULT_WIDTH = 720;
const NEW_TASK_DEFAULT_HEIGHT = 640;
const NEW_TASK_MIN_WIDTH = 420;
const NEW_TASK_MIN_HEIGHT = 360;
const NEW_TASK_VIEWPORT_PADDING = 16;
interface FloatSize {
width: number;
height: number;
}
interface FloatPosition {
x: number;
y: number;
}
function clampFloatSize(size: FloatSize): FloatSize {
if (typeof window === "undefined") return size;
return {
width: Math.min(Math.max(size.width, NEW_TASK_MIN_WIDTH), Math.max(NEW_TASK_MIN_WIDTH, window.innerWidth - NEW_TASK_VIEWPORT_PADDING * 2)),
height: Math.min(Math.max(size.height, NEW_TASK_MIN_HEIGHT), Math.max(NEW_TASK_MIN_HEIGHT, window.innerHeight - NEW_TASK_VIEWPORT_PADDING * 2)),
};
}
function clampFloatPosition(position: FloatPosition, size: FloatSize): FloatPosition {
if (typeof window === "undefined") return position;
return {
x: Math.min(Math.max(position.x, NEW_TASK_VIEWPORT_PADDING), Math.max(NEW_TASK_VIEWPORT_PADDING, window.innerWidth - size.width - NEW_TASK_VIEWPORT_PADDING)),
y: Math.min(Math.max(position.y, NEW_TASK_VIEWPORT_PADDING), Math.max(NEW_TASK_VIEWPORT_PADDING, window.innerHeight - size.height - NEW_TASK_VIEWPORT_PADDING)),
};
}
function readFloatSize(): FloatSize {
if (typeof window === "undefined") return { width: NEW_TASK_DEFAULT_WIDTH, height: NEW_TASK_DEFAULT_HEIGHT };
try {
const raw = window.localStorage.getItem(NEW_TASK_MODAL_SIZE_STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw) as Partial<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 +140,150 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
: {};
const [description, setDescription] = useState("");
const wasOpenRef = useRef(false);
/*
FNXC:NewTask 2026-06-22-20:30:
Floating window position/size state (desktop only). Mobile keeps the full-screen sheet, so we only apply the floating panel style and drag/resize handlers when not mobile. A single active-drag teardown (drag OR resize) lives in dragTeardownRef; pointerup/pointercancel AND the unmount effect run it so an interrupted drag never leaks element pointer listeners or a pending rAF.
*/
const isFloating = viewportMode !== "mobile";
const [size, setSizeState] = useState<FloatSize>(() => readFloatSize());
const [position, setPositionState] = useState<FloatPosition>(() => readFloatPosition(readFloatSize()));
const dragTeardownRef = useRef<(() => void) | null>(null);
// FNXC:FloatingWindow 2026-06-22-21:30: Floating (desktop) New Task dialog shares the SINGLE cross-type floating z-index stack (floatingWindowStack). Mounting claims the front; tapping the panel (pointerdown/focus capture) raises it above every other floating modal regardless of type. Mobile keeps the full-screen sheet so this z-index is harmless there.
const [zIndex, setZIndex] = useState<number>(() => nextFloatingZ());
const bringToFront = useCallback(() => {
setZIndex((current) => (current >= currentFloatingZ() ? current : nextFloatingZ()));
}, []);
const persistSize = useCallback((next: FloatSize) => {
setSizeState(writeFloatSize(next));
}, []);
const persistPosition = useCallback((next: FloatPosition, withSize: FloatSize) => {
setPositionState(writeFloatPosition(next, withSize));
}, []);
// FNXC:NewTask 2026-06-22-20:30: Header drag. setPointerCapture redirects the pointer stream to the captured header element, so element-scoped pointermove/up listeners receive the full drag even off the header; moves are rAF-batched; the panel is clamped on-screen. Close button clicks are excluded so dragging never swallows close.
const handleFloatingDragPointerDown = useCallback((event: ReactPointerEvent<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 +742,44 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
if (!isOpen) return null;
return (
<div className="modal-overlay open" onClick={handleClose} onKeyDown={handleKeyDown} role="dialog" aria-modal="true">
// FNXC:NewTask 2026-06-22-20:30: Desktop = floating fixed panel positioned by state-driven left/top/width/height. Mobile keeps the keyboard-aware full-screen sheet (no floating). The transparent click-through overlay never dismisses on click; the header X / Cancel / Escape are the only dismissals.
const panelStyle: CSSProperties = isFloating
? { left: `${position.x}px`, top: `${position.y}px`, width: `${size.width}px`, height: `${size.height}px`, zIndex }
: keyboardStyle;
// FNXC:FloatingWindow 2026-06-22-22:30: Portaled to document.body so the floating New Task dialog shares the ONE root stacking context with the other floating modals; the shared cross-type z stack only orders correctly at the document root. Mobile sheet is position:fixed, unaffected.
return createPortal(
<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"
/* FNXC:FloatingWindow 2026-06-22-23:00: In floating mode the z-index lives on the fixed overlay (it owns the stacking context); a panel z is trapped and loses to page stacking contexts like the right dock. Mobile keeps its CSS z. */
style={isFloating ? { zIndex } : undefined}
>
<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}
onPointerDownCapture={isFloating ? bringToFront : undefined}
onFocusCapture={isFloating ? bringToFront : undefined}
>
<div className="modal-header">
{isFloating && NEW_TASK_RESIZE_DIRECTIONS.map((direction) => (
<div
key={direction}
className={`new-task-resize-handle new-task-resize-handle--${direction}`}
data-testid={`new-task-resize-${direction}`}
role="separator"
aria-label={t("newTaskModal.resize", "Resize new task window")}
onPointerDown={(event) => handleFloatingResizePointerDown(event, direction)}
/>
))}
<div
className={`modal-header${isFloating ? " new-task-modal__header--draggable" : ""}`}
data-testid="new-task-drag-handle"
onPointerDown={isFloating ? handleFloatingDragPointerDown : undefined}
>
<h3>{t("newTaskModal.title", "New Task")}</h3>
<button className="modal-close" onClick={handleClose} disabled={isSubmitting} aria-label={t("actions.close", "Close")}>
&times;
@@ -583,6 +850,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
renderBelowPrimary={quickFields}
hideDependencies={true}
autoExpandMoreOptionsOnSelection={false}
forceMoreOptionsOpen={true}
/>
</div>
@@ -604,6 +872,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
</button>
</div>
</div>
</div>
</div>,
document.body,
);
}

View File

@@ -170,6 +170,11 @@ The right-dock pop-out is a FLOATING, DRAGGABLE, RESIZABLE, NON-BLOCKING window.
background: transparent;
backdrop-filter: none;
pointer-events: none;
/*
FNXC:FloatingWindow 2026-06-22-21:30:
Reset the base `.modal-overlay` z-index:100 to auto so this click-through overlay does NOT establish a stacking context. The floating panel carries an inline z-index from the SHARED floatingWindowStack (4000+); without this reset that inline z would be clamped inside the overlay's own stacking context and could never interleave with the other floating modal types (terminal, New Task, FloatingWindow) that all draw from the same stack.
*/
z-index: auto;
}
.right-dock-expand-modal {

View File

@@ -1,6 +1,8 @@
import { useCallback, useEffect, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent, type RefObject } from "react";
import { createPortal } from "react-dom";
import { Maximize2, X } from "lucide-react";
import { findOverflowViewEntry, type OverflowViewEntry, type OverflowViewKey, type OverflowViewRenderProps, type OverflowViewVisibilityOptions } from "./overflowViewRegistry";
import { nextFloatingZ, currentFloatingZ } from "./floatingWindowStack";
import "./RightDock.css";
const RIGHT_DOCK_EXPAND_MODAL_SIZE_STORAGE_KEY = "fusion:right-dock-expand-modal-size";
@@ -124,6 +126,11 @@ export function RightDockExpandModal({
const [size, setSizeState] = useState<ExpandSize>(() => readExpandSize());
const [position, setPositionState] = useState<ExpandPosition>(() => readExpandPosition(readExpandSize()));
// FNXC:FloatingWindow 2026-06-22-21:30: The right-dock pop-out shares the SINGLE cross-type floating z-index stack (floatingWindowStack). Mounting claims the front; tapping the panel (pointerdown/focus capture) raises it above every other floating modal regardless of type.
const [zIndex, setZIndex] = useState<number>(() => nextFloatingZ());
const bringToFront = useCallback(() => {
setZIndex((current) => (current >= currentFloatingZ() ? current : nextFloatingZ()));
}, []);
/*
FNXC:RightDock 2026-06-22-17:40:
@@ -294,11 +301,18 @@ export function RightDockExpandModal({
top: `${position.y}px`,
width: `${size.width}px`,
height: `${size.height}px`,
zIndex,
} as CSSProperties;
return (
<div className="modal-overlay open right-dock-expand-modal-overlay" role="dialog" aria-modal="false" aria-label={`${entry.label} expanded`} data-testid="right-dock-expand-modal">
<div className="modal right-dock-expand-modal right-dock-expand-modal--floating" style={panelStyle}>
// FNXC:FloatingWindow 2026-06-22-22:30: Portaled to document.body so this floating modal shares the ONE root stacking context with the other floating modals (FloatingWindow/terminal/New Task) — the shared 10100+ z stack only orders correctly across types when they all live at the document root.
return createPortal(
<div className="modal-overlay open right-dock-expand-modal-overlay" role="dialog" aria-modal="false" aria-label={`${entry.label} expanded`} data-testid="right-dock-expand-modal" style={{ zIndex }}>
<div
className="modal right-dock-expand-modal right-dock-expand-modal--floating"
style={panelStyle}
onPointerDownCapture={bringToFront}
onFocusCapture={bringToFront}
>
{EXPAND_RESIZE_DIRECTIONS.map((direction) => (
<div
key={direction}
@@ -331,6 +345,7 @@ export function RightDockExpandModal({
{entry.render({ ...renderProps, surface: "expand" })}
</div>
</div>
</div>
</div>,
document.body,
);
}

View File

@@ -509,12 +509,15 @@ export function TaskChatTab({ task, projectId, active, addToast, sessionLive, on
/**
* FNXC:TaskDetailChat 2026-06-19-22:54:
* The task-detail chat must never silently accept a question when no agent session will consume it. Keep idle chats sendable, but surface that the message is saved as guidance for the next task run instead of implying a live reply.
*
* FNXC:TaskDetailChat 2026-06-22-21:20:
* The idle "No agent is working on this task right now…" hint is suppressed (empty) per user request — idle chats stay sendable but no longer show the banner. Done/active hints remain. The render gates on a truthy sessionHint, so the empty idle case renders nothing.
*/
const sessionHint = isDoneTask
? t("taskChat.doneSessionHint", "Send a message to start a refinement task for this completed task.")
: activeSession
? t("taskChat.activeSessionHint", "Message the active agent session. Guidance is delivered to the running session in real time.")
: t("taskChat.idleSessionHint", "No agent is working on this task right now. Your message is saved as guidance and will reach an agent the next time this task runs.");
: "";
const composerPlaceholder = isDoneTask
? t("taskChat.donePlaceholder", "Start a refinement task for this completed task")
: t("taskChat.activePlaceholder", "Steer the currently executing agent");

View File

@@ -17,6 +17,14 @@
resize: both;
}
/*
FNXC:TaskDetail 2026-06-22-20:00:
The gray top header band (task id + column badge) was over-padded. Trim its vertical padding for a more compact band, scoped to the task-detail header so the shared global .modal-header (used by other modals) is unaffected. Keep horizontal padding from --modal-padding; only the block padding shrinks.
*/
.task-detail-content > .modal-header {
padding-block: var(--space-sm);
}
.detail-title-row {
display: flex;
align-items: center;
@@ -121,16 +129,22 @@
overflow: hidden;
}
/*
FNXC:TaskDetail 2026-06-22-20:00:
Summarize-as-title is an in-field affordance, not a separate full-width row: it sits inline with the title, pinned to the far right and bottom of the title area. Use a nowrap flex row where the title flexes to fill and the button is pushed right (margin-left:auto) and bottom-aligned (align-self:flex-end). The button shrinks to its content so it never steals title space.
*/
.detail-heading-row {
display: flex;
align-items: baseline;
flex-wrap: wrap;
align-items: flex-end;
flex-wrap: nowrap;
gap: var(--space-sm);
margin-bottom: var(--space-md);
}
.detail-heading-row .detail-title {
margin-bottom: 0;
flex: 1 1 auto;
min-width: 0;
}
.detail-summarize-title-btn {
@@ -143,7 +157,11 @@
font-size: 0.8125rem;
padding: 0;
cursor: pointer;
text-align: left;
text-align: right;
margin-left: auto;
align-self: flex-end;
flex: 0 0 auto;
white-space: nowrap;
}
.detail-summarize-title-btn:hover:not(:disabled) {
@@ -257,8 +275,12 @@ The task-detail modal metadata must keep priority, execution mode, provenance, P
}
@media (max-width: 768px) {
/*
FNXC:TaskDetail 2026-06-22-20:00:
Keep summarize-as-title pinned bottom-right inline with the title on mobile too (no wrap to a separate row), matching the desktop in-field affordance.
*/
.detail-heading-row {
align-items: flex-start;
align-items: flex-end;
}
.detail-summarize-title-btn {
@@ -291,7 +313,11 @@ The task-detail modal metadata must keep priority, execution mode, provenance, P
}
.detail-meta-inline-controls {
--detail-priority-control-min-height: calc(var(--space-2xl) + var(--space-xs));
/*
FNXC:TaskDetail 2026-06-22-20:00:
Priority chip and speed (execution-mode) toggle share one min-height token so they render at identical, equal height. Reduced from the old calc(space-2xl + space-xs) (~too tall) to a compact 30px that stays legible and tappable. Both controls also get trimmed vertical padding to match.
*/
--detail-priority-control-min-height: 30px;
display: flex;
align-items: stretch;
@@ -302,6 +328,7 @@ The task-detail modal metadata must keep priority, execution mode, provenance, P
.detail-priority-chip {
gap: var(--space-xs);
min-height: var(--detail-priority-control-min-height);
padding-block: var(--space-xs);
box-sizing: border-box;
}
@@ -342,6 +369,7 @@ The task-detail modal metadata must keep priority, execution mode, provenance, P
align-items: center;
gap: var(--space-xs);
min-height: var(--detail-priority-control-min-height);
padding-block: var(--space-xs);
box-sizing: border-box;
}
@@ -1115,10 +1143,14 @@ FNXC:TaskDetail 2026-06-22-18:40:
background: var(--card-hover);
}
/*
FNXC:TaskDetail 2026-06-22-20:15:
The footer Actions/Move dropdown buttons sit at the BOTTOM of the embedded panel, so the menus must open UPWARD (above the button). The earlier embedded rule opened them downward (top:100%), which dropped the menu off the panel bottom where the body's overflow clipped it — the popups appeared to vanish. Anchor to bottom:100% so they always open above the trigger and stay on-screen.
*/
.task-detail-content--embedded .detail-actions-menu,
.task-detail-content--embedded .detail-move-menu {
top: calc(100% + var(--space-xs));
bottom: auto;
bottom: calc(100% + var(--space-xs));
top: auto;
}
.detail-refine-title {

View File

@@ -1,7 +1,7 @@
import "./TaskDetailModal.css";
import React, { Suspense, lazy, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Pencil, Bot, X, ChevronDown, ChevronRight, GitBranch, ArrowLeft, Zap, Loader2, AlertTriangle, Sparkles } from "lucide-react";
import { Pencil, Bot, X, ChevronDown, ChevronRight, GitBranch, ArrowLeft, Zap, Loader2, AlertTriangle, Sparkles, Maximize2 } from "lucide-react";
import { useModalResizePersist } from "../hooks/useModalResizePersist";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
@@ -414,6 +414,11 @@ export type TaskDetailContentProps = Omit<TaskDetailModalProps, "onClose"> & {
onBackToBoard powers the board-card full-panel "Back to board" affordance rendered in the gray header (far right). It is only honored when embedded is also true, so ListView split-pane and modal usages never show it.
*/
onBackToBoard?: () => void;
/*
FNXC:FloatingWindow 2026-06-22-20:45:
onPopOut, when supplied, renders a Maximize2 "Pop out" button in the gray header. List/Board wire it to push this task into App's floating task-detail window array, opening the same embedded TaskDetailContent inside a movable, resizable, non-blocking FloatingWindow. It is independent of embedded/onBackToBoard so List split-pane and the board full-panel can both expose it.
*/
onPopOut?: (task: Task) => void;
};
function truncate(s: string, max: number): string {
@@ -589,6 +594,7 @@ export function TaskDetailContent({
embedded = false,
onRequestClose,
onBackToBoard,
onPopOut,
workflowFieldDefs: workflowFieldDefsProp,
}: TaskDetailContentProps) {
const { t } = useTranslation("app");
@@ -2754,6 +2760,22 @@ export function TaskDetailContent({
<span>{t("app.taskDetail.backToBoard", "Back to board")}</span>
</button>
)}
{/*
FNXC:FloatingWindow 2026-06-22-20:45:
"Pop out" affordance opens this task detail in a movable, resizable, non-blocking FloatingWindow. Rendered whenever onPopOut is wired (List split-pane + board full-panel); App dedupes by task id so re-popping focuses the existing window instead of duplicating.
*/}
{onPopOut && (
<button
type="button"
className="modal-edit-btn"
onClick={() => onPopOut(task)}
title={t("taskDetail.header.popOut", "Pop out")}
aria-label="Pop out"
data-testid="task-detail-pop-out"
>
<Maximize2 size={14} />
</button>
)}
{!isEditing && canEdit && (
<button
className="modal-edit-btn"
@@ -2877,6 +2899,10 @@ export function TaskDetailContent({
) : (
<>
<>
{/*
FNXC:TaskDetail 2026-06-22-20:00:
Summarize-as-title renders inline with the title inside .detail-heading-row and is positioned (CSS) to the far bottom-right as an in-field affordance, not a separate full-width row. Markup order is preserved; only layout changed.
*/}
<div className="detail-heading-row">
<h2
ref={titleRef}

View File

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

View File

@@ -44,6 +44,14 @@ The override MUST out-specify the base `.modal-overlay` (which sets a dimmed bac
pointer-events: none;
}
/*
FNXC:FloatingWindow 2026-06-22-21:30:
Only the FLOATING terminal joins the shared cross-type floating stack. Reset the base `.modal-overlay` z-index:100 to auto so this click-through overlay does NOT establish a stacking context; the floating panel's inline z-index (from floatingWindowStack, 4000+) then interleaves at the root with the right-dock pop-out, the floating New Task dialog, and FloatingWindow. Docked mode keeps the base overlay stacking (full-width bottom panel) and is intentionally excluded.
*/
.modal-overlay.terminal-modal-overlay--floating {
z-index: auto;
}
.modal.terminal-modal {
/* Initial dimensions are applied only when no persisted size has been
restored — see :not([style*=...]) selectors below. */
@@ -124,9 +132,14 @@ Larger grab target for the docked terminal top resize handle: it straddles the p
box-shadow: var(--shadow-xl);
}
/*
FNXC:Terminal 2026-06-22-19:50:
The floating-mode header is the move grip. `touch-action: none` is required so a touch-drag on it is delivered as a continuous pointermove stream (paired with setPointerCapture on the captured element) instead of being hijacked by the browser into page scroll/pan. Without it the floating drag stutters on touch — same fix the right-dock pop-out drag handle uses. cursor: grab/grabbing signals the move affordance on desktop.
*/
.terminal-header--draggable {
cursor: grab;
user-select: none;
touch-action: none;
}
.terminal-header--draggable:active {
@@ -820,6 +833,13 @@ The shortcut bar (modifier keys + arrow keys) must sit on ONE line, not stack in
background: var(--surface);
border-top: 1px solid var(--border);
overflow-x: auto;
/*
FNXC:Terminal 2026-06-22-22:00:
On a narrow folded phone the modifier/arrow/letter keys exceed the viewport width, so the bar MUST scroll horizontally to keep every button reachable. touch-action: pan-x lets a horizontal swipe scroll the row (instead of the browser hijacking it as a page gesture), overscroll-behavior-x: contain stops the swipe from bleeding into page/back-navigation at the ends, and -webkit-overflow-scrolling: touch gives momentum scroll on iOS.
*/
touch-action: pan-x;
overscroll-behavior-x: contain;
-webkit-overflow-scrolling: touch;
}
.terminal-shortcut-modifier-row,
@@ -834,6 +854,11 @@ The shortcut bar (modifier keys + arrow keys) must sit on ONE line, not stack in
display: inline-flex;
align-items: center;
justify-content: center;
/*
FNXC:Terminal 2026-06-22-22:00:
Keys keep their intrinsic width and never shrink/grow, so the row's total width exceeds a narrow viewport and the panel's overflow-x: auto produces a real horizontal scroll reaching the rightmost buttons. flex:1 / width:100% here would collapse every key to fit the viewport and defeat the scroll.
*/
flex: 0 0 auto;
min-width: calc(var(--space-xl) + var(--space-xs));
min-height: calc(var(--space-xl) + var(--space-xs));
padding: 0 var(--space-xs);

View File

@@ -1,4 +1,5 @@
import "./TerminalModal.css";
import { createPortal } from "react-dom";
import {
useState,
useEffect,
@@ -25,6 +26,7 @@ import {
} from "lucide-react";
import { useTerminal } from "../hooks/useTerminal";
import { useTerminalSessions } from "../hooks/useTerminalSessions";
import { nextFloatingZ, currentFloatingZ } from "./floatingWindowStack";
import { getPathBasename } from "../utils/pathDisplay";
import {
DEFAULT_TERMINAL_PREFERENCES,
@@ -405,6 +407,12 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
const [isMobileTerminal, setIsMobileTerminal] = useState(() => isTerminalMobileViewport());
const isDockedMode = !isMobileTerminal && displayMode === "docked";
const isFloatingMode = !isMobileTerminal && displayMode === "floating";
// FNXC:FloatingWindow 2026-06-22-21:30: The FLOATING terminal shares the SINGLE cross-type floating z-index stack (floatingWindowStack) so tapping it raises it above every other floating modal regardless of type. A fresh z is claimed each time the modal opens (see effect below); tapping the panel (pointerdown/focus capture) re-raises it. Docked/mobile modes ignore this z-index (full-width bottom panel / full-screen sheet).
const [floatingZ, setFloatingZ] = useState<number>(() => nextFloatingZ());
const bringFloatingToFront = useCallback(() => {
if (!isFloatingMode) return;
setFloatingZ((current) => (current >= currentFloatingZ() ? current : nextFloatingZ()));
}, [isFloatingMode]);
const terminalRef = useRef<HTMLDivElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
@@ -433,7 +441,10 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
const pendingFitRef = useRef<number | null>(null);
/*
FNXC:Terminal 2026-06-22-09:00:
Docked-resize, floating-drag, and floating-resize each attach document pointer listeners (and docked schedules a rAF) for the duration of a drag. If the modal closes or the component unmounts mid-drag, those listeners + the pending frame would leak. Track the active drag teardown here and run it from the close/unmount effect.
Docked-resize, floating-drag, and floating-resize each attach pointer listeners and schedule a rAF for the duration of a drag. If the modal closes or the component unmounts mid-drag, those listeners + the pending frame would leak. Track the active drag teardown here and run it from the close/unmount effect.
FNXC:Terminal 2026-06-22-19:50:
All three families now capture the pointer and attach listeners to the CAPTURED handle element (not `document`), so the teardown also releasePointerCapture()s; the close/unmount effect still drives it through this single ref.
*/
const dragTeardownRef = useRef<(() => void) | null>(null);
/** Tracks the previous projectId to detect project switches and invalidate xterm. */
@@ -489,22 +500,26 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
FNXC:Terminal 2026-06-21-22:45:
The pop-out terminal mode uses project-scoped `fusion:terminal-modal-size-${projectId}` and `fusion:terminal-float-pos-${projectId}` keys so floating windows restore independently per project while avoiding the old bottom-right native resize grip conflict.
*/
/*
FNXC:Terminal 2026-06-22-19:50:
Docked top-edge resize, smooth on touch + desktop (same technique as the right-dock pop-out RightDockExpandModal). On pointerdown we setPointerCapture on the handle and attach pointermove/up/cancel to the CAPTURED element (`captureTarget` = event.currentTarget), NOT `document` — capture redirects the full pointer stream for this pointerId to that element so element-scoped listeners receive every move even when the finger drifts off the handle, and they pair cleanly with the handle's `touch-action: none` (CSS) without a non-passive document listener. Moves are filtered by pointerId and coalesced into one rAF, so we set height at most once per frame and never thrash layout on a flood of touch-move events. localStorage is written only on pointerup (existing behavior). Teardown (pointerup/cancel + unmount via dragTeardownRef) cancels the pending rAF, releases pointer capture, and detaches listeners.
*/
const handleDockedResizePointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
if (!isDockedMode) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
const captureTarget = event.currentTarget;
const pointerId = event.pointerId;
captureTarget.setPointerCapture?.(pointerId);
const startY = event.clientY;
const startHeight = dockedHeight;
const previousUserSelect = document.body.style.userSelect;
document.body.style.userSelect = "none";
/*
FNXC:Terminal 2026-06-22-01:30:
Smooth docked resize: batch height state to one update per animation frame during the drag and write localStorage only once on pointer-up, instead of a synchronous clamp + localStorage write on every pointermove (which janked the drag).
*/
let latestHeight = startHeight;
let frame = 0;
const handlePointerMove = (moveEvent: PointerEvent) => {
if (moveEvent.pointerId !== pointerId) return;
latestHeight = clampTerminalDockedHeight(startHeight + (startY - moveEvent.clientY));
if (frame) return;
frame = requestAnimationFrame(() => {
@@ -512,64 +527,100 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
setDockedHeight(latestHeight);
});
};
const handlePointerUp = () => {
const detachListeners = () => {
captureTarget.releasePointerCapture?.(pointerId);
captureTarget.removeEventListener("pointermove", handlePointerMove);
captureTarget.removeEventListener("pointerup", handlePointerUp);
captureTarget.removeEventListener("pointercancel", handlePointerUp);
};
function handlePointerUp() {
if (frame) cancelAnimationFrame(frame);
setDockedHeight(writeTerminalDockedHeight(latestHeight, projectId));
document.body.style.userSelect = previousUserSelect;
document.removeEventListener("pointermove", handlePointerMove);
document.removeEventListener("pointerup", handlePointerUp);
document.removeEventListener("pointercancel", handlePointerUp);
detachListeners();
dragTeardownRef.current = null;
};
}
// FNXC:Terminal 2026-06-22-09:00: Unmount/close-mid-drag teardown cancels the pending rAF and removes the document listeners without persisting a partial drag.
// FNXC:Terminal 2026-06-22-19:50: Unmount/close-mid-drag teardown cancels the pending rAF, releases pointer capture, and detaches the captured-element listeners without persisting a partial drag.
dragTeardownRef.current = () => {
if (frame) cancelAnimationFrame(frame);
document.body.style.userSelect = previousUserSelect;
document.removeEventListener("pointermove", handlePointerMove);
document.removeEventListener("pointerup", handlePointerUp);
document.removeEventListener("pointercancel", handlePointerUp);
detachListeners();
dragTeardownRef.current = null;
};
document.addEventListener("pointermove", handlePointerMove);
document.addEventListener("pointerup", handlePointerUp);
document.addEventListener("pointercancel", handlePointerUp);
captureTarget.addEventListener("pointermove", handlePointerMove);
captureTarget.addEventListener("pointerup", handlePointerUp);
captureTarget.addEventListener("pointercancel", handlePointerUp);
}, [dockedHeight, isDockedMode, projectId]);
/*
FNXC:Terminal 2026-06-22-19:50:
Floating-window move (drag the header grip), smooth on touch + desktop. Pointer capture + captured-element (`captureTarget`) listeners filtered by pointerId, identical to the right-dock pop-out drag. Raw pointer coords are stored in `latest` and applied via one rAF per frame, so a flood of touch-move events coalesces into a single state set and never thrashes layout. State-only updates during the drag; localStorage is persisted once on pointerup (the old per-move persistFloatingPosition wrote localStorage on every move, which janked touch drags). Teardown cancels the rAF, releases capture, and detaches listeners on pointerup/cancel and on unmount.
*/
const handleFloatingDragPointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
if (!isFloatingMode || (event.target as HTMLElement).closest("button")) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
const captureTarget = event.currentTarget;
const pointerId = event.pointerId;
captureTarget.setPointerCapture?.(pointerId);
const startX = event.clientX;
const startY = event.clientY;
const startPosition = floatingPosition;
const currentSize = floatingSize;
const previousUserSelect = document.body.style.userSelect;
document.body.style.userSelect = "none";
let latest = startPosition;
let frame = 0;
const handlePointerMove = (moveEvent: PointerEvent) => {
persistFloatingPosition({ x: startPosition.x + moveEvent.clientX - startX, y: startPosition.y + moveEvent.clientY - startY });
if (moveEvent.pointerId !== pointerId) return;
latest = { x: startPosition.x + moveEvent.clientX - startX, y: startPosition.y + moveEvent.clientY - startY };
if (frame) return;
frame = requestAnimationFrame(() => {
frame = 0;
setFloatingPosition(clampTerminalFloatPosition(latest, currentSize));
});
};
const handlePointerUp = () => {
const detachListeners = () => {
captureTarget.releasePointerCapture?.(pointerId);
captureTarget.removeEventListener("pointermove", handlePointerMove);
captureTarget.removeEventListener("pointerup", handlePointerUp);
captureTarget.removeEventListener("pointercancel", handlePointerUp);
};
function handlePointerUp() {
if (frame) cancelAnimationFrame(frame);
persistFloatingPosition(latest, currentSize);
document.body.style.userSelect = previousUserSelect;
document.removeEventListener("pointermove", handlePointerMove);
document.removeEventListener("pointerup", handlePointerUp);
document.removeEventListener("pointercancel", handlePointerUp);
detachListeners();
dragTeardownRef.current = null;
}
// FNXC:Terminal 2026-06-22-19:50: Unmount/close-mid-drag teardown cancels the rAF, releases capture, and detaches the captured-element listeners without persisting a partial move.
dragTeardownRef.current = () => {
if (frame) cancelAnimationFrame(frame);
document.body.style.userSelect = previousUserSelect;
detachListeners();
dragTeardownRef.current = null;
};
// FNXC:Terminal 2026-06-22-09:00: Unmount/close-mid-drag teardown removes the document listeners so a floating-drag never leaks them.
dragTeardownRef.current = handlePointerUp;
document.addEventListener("pointermove", handlePointerMove);
document.addEventListener("pointerup", handlePointerUp);
document.addEventListener("pointercancel", handlePointerUp);
}, [floatingPosition, isFloatingMode, persistFloatingPosition]);
captureTarget.addEventListener("pointermove", handlePointerMove);
captureTarget.addEventListener("pointerup", handlePointerUp);
captureTarget.addEventListener("pointercancel", handlePointerUp);
}, [floatingPosition, floatingSize, isFloatingMode, persistFloatingPosition]);
/*
FNXC:Terminal 2026-06-22-19:50:
Floating-window edge/corner resize, smooth on touch + desktop. Pointer capture + captured-element listeners filtered by pointerId, rAF-batched size/position updates (west/north handles also shift the origin so the opposite edge stays pinned), persisted once on pointerup — same discipline as the right-dock pop-out resize. The old per-move persistFloatingSize/persistFloatingPosition wrote localStorage on every move; now we set state per frame and persist only on release. Teardown cancels the rAF, releases capture, and detaches listeners on pointerup/cancel and on unmount.
*/
const handleFloatingResizePointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>, direction: TerminalResizeDirection) => {
if (!isFloatingMode) return;
event.preventDefault();
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
const captureTarget = event.currentTarget;
const pointerId = event.pointerId;
captureTarget.setPointerCapture?.(pointerId);
const startX = event.clientX;
const startY = event.clientY;
const startSize = floatingSize;
@@ -577,34 +628,57 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
const previousUserSelect = document.body.style.userSelect;
document.body.style.userSelect = "none";
let latestSize = startSize;
let latestPosition = startPosition;
let frame = 0;
const handlePointerMove = (moveEvent: PointerEvent) => {
if (moveEvent.pointerId !== pointerId) return;
const dx = moveEvent.clientX - startX;
const dy = moveEvent.clientY - startY;
const rawSize = {
const nextSize = clampTerminalFloatSize({
width: startSize.width + (direction.includes("e") ? dx : direction.includes("w") ? -dx : 0),
height: startSize.height + (direction.includes("s") ? dy : direction.includes("n") ? -dy : 0),
};
const nextSize = clampTerminalFloatSize(rawSize);
});
const nextPosition = {
x: startPosition.x + (direction.includes("w") ? startSize.width - nextSize.width : 0),
y: startPosition.y + (direction.includes("n") ? startSize.height - nextSize.height : 0),
};
persistFloatingSize(nextSize);
persistFloatingPosition(nextPosition, nextSize);
latestSize = nextSize;
latestPosition = nextPosition;
if (frame) return;
frame = requestAnimationFrame(() => {
frame = 0;
setFloatingSize(latestSize);
setFloatingPosition(clampTerminalFloatPosition(latestPosition, latestSize));
});
};
const handlePointerUp = () => {
const detachListeners = () => {
captureTarget.releasePointerCapture?.(pointerId);
captureTarget.removeEventListener("pointermove", handlePointerMove);
captureTarget.removeEventListener("pointerup", handlePointerUp);
captureTarget.removeEventListener("pointercancel", handlePointerUp);
};
function handlePointerUp() {
if (frame) cancelAnimationFrame(frame);
persistFloatingSize(latestSize);
persistFloatingPosition(latestPosition, latestSize);
document.body.style.userSelect = previousUserSelect;
document.removeEventListener("pointermove", handlePointerMove);
document.removeEventListener("pointerup", handlePointerUp);
document.removeEventListener("pointercancel", handlePointerUp);
detachListeners();
dragTeardownRef.current = null;
}
// FNXC:Terminal 2026-06-22-19:50: Unmount/close-mid-drag teardown cancels the rAF, releases capture, and detaches the captured-element listeners without persisting a partial resize.
dragTeardownRef.current = () => {
if (frame) cancelAnimationFrame(frame);
document.body.style.userSelect = previousUserSelect;
detachListeners();
dragTeardownRef.current = null;
};
// FNXC:Terminal 2026-06-22-09:00: Unmount/close-mid-drag teardown removes the document listeners so a floating-resize never leaks them.
dragTeardownRef.current = handlePointerUp;
document.addEventListener("pointermove", handlePointerMove);
document.addEventListener("pointerup", handlePointerUp);
document.addEventListener("pointercancel", handlePointerUp);
captureTarget.addEventListener("pointermove", handlePointerMove);
captureTarget.addEventListener("pointerup", handlePointerUp);
captureTarget.addEventListener("pointercancel", handlePointerUp);
}, [floatingPosition, floatingSize, isFloatingMode, persistFloatingPosition, persistFloatingSize]);
/**
@@ -632,6 +706,33 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
return;
}
/*
FNXC:Terminal 2026-06-22-22:00:
On a very narrow folded phone the fold/orientation transition can fire a resize while the xterm container momentarily reports a transient sub-pixel width. We still call fit() (FitAddon no-ops at 0 width, so it can never collapse columns there), but when the container reports a real nonzero width we ALSO schedule one deferred re-fit so the column count re-settles after the fold geometry stabilizes to its final integer box — that deferred pass is what reflows the narrow terminal back to contiguous text instead of the wide-cell "C o p i e d" spaced render. The width probe is read-only and only adds the extra rAF, so jsdom (clientWidth 0) keeps its single synchronous fit and existing tests are unaffected.
*/
const containerWidth = terminalRef.current?.clientWidth ?? 0;
if (containerWidth > 0) {
if (pendingFitRef.current !== null) {
cancelAnimationFrame(pendingFitRef.current);
}
pendingFitRef.current = requestAnimationFrame(() => {
pendingFitRef.current = null;
if (
(!expectedSessionId || xtermInitializedRef.current === expectedSessionId) &&
fitAddonRef.current &&
xtermRef.current &&
(terminalRef.current?.clientWidth ?? 0) > 0
) {
try {
(fitAddonRef.current as InstanceType<typeof import("@xterm/addon-fit").FitAddon>).fit();
resizeRef.current?.(xtermRef.current.cols, xtermRef.current.rows);
} catch {
// Ignore fit errors during viewport transitions
}
}
});
}
try {
const fitAddon = currentFitAddon as InstanceType<typeof import("@xterm/addon-fit").FitAddon>;
fitAddon.fit();
@@ -645,9 +746,11 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
// Bump open generation whenever the modal opens so the initialCommand
// effect re-evaluates after a close/reopen cycle (deps may be identical).
// FNXC:FloatingWindow 2026-06-22-21:30: Each open also claims the front of the shared floating-window stack so a freshly-opened floating terminal sits above other floating modals.
useEffect(() => {
if (isOpen) {
setOpenGeneration((g) => g + 1);
setFloatingZ(nextFloatingZ());
}
}, [isOpen]);
@@ -705,10 +808,16 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
update(); // initial measurement
vv.addEventListener("resize", update);
vv.addEventListener("scroll", update);
/*
FNXC:Terminal 2026-06-22-22:00:
Folding/unfolding a foldable phone (and rotating) changes the terminal's available width without always emitting a visualViewport resize at the settled width. Listen to orientationchange too so xterm re-fits to the new narrow/wide column count after the fold completes; the deferred-fit guard in fitAndResizeForSession ensures the fit only lands once the container has a real width.
*/
window.addEventListener("orientationchange", update);
return () => {
vv.removeEventListener("resize", update);
vv.removeEventListener("scroll", update);
window.removeEventListener("orientationchange", update);
// Cancel any pending deferred fit
if (pendingFitRef.current !== null) {
cancelAnimationFrame(pendingFitRef.current);
@@ -1011,6 +1120,23 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
// Initial fit
setTimeout(() => {
fitAddon.fit();
// FNXC:Terminal 2026-06-22-22:00: After the first synchronous fit, schedule one deferred re-fit so a terminal opened mid-fold (narrow foldable, where the container width has not settled to its final integer box yet) re-measures columns once layout stabilizes — preventing the collapsed-column spaced-glyph render. Guarded by container width and live session so jsdom/tab-teardown paths stay no-ops.
if ((terminalRef.current?.clientWidth ?? 0) > 0) {
requestAnimationFrame(() => {
if (
xtermInitializedRef.current === currentSessionId &&
fitAddonRef.current === fitAddon &&
(terminalRef.current?.clientWidth ?? 0) > 0
) {
try {
fitAddon.fit();
resizeRef.current?.(terminal.cols, terminal.rows);
} catch {
// Ignore fit errors during viewport transitions
}
}
});
}
// Re-focus after fit in case the DOM changed
const textarea = terminalRef.current?.querySelector(
".xterm-helper-textarea",
@@ -1692,11 +1818,14 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
"--terminal-float-y": `${floatingPosition.y}px`,
"--terminal-float-width": `${floatingSize.width}px`,
"--terminal-float-height": `${floatingSize.height}px`,
// FNXC:FloatingWindow 2026-06-22-21:30: Inline z from the shared cross-type stack; only the floating panel participates.
zIndex: floatingZ,
}
: {}),
} as CSSProperties;
return (
// FNXC:FloatingWindow 2026-06-22-22:30: Portaled to document.body so the terminal shares the ONE root stacking context with the other floating modals; the shared cross-type z stack only orders correctly when all panels live at the document root. Docked/floating/mobile are all position:fixed, so portaling does not change their placement.
return createPortal(
<div
className={overlayClassName}
onMouseDown={handleOverlayMouseDown}
@@ -1704,19 +1833,19 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
role="dialog"
aria-modal="true"
data-testid="terminal-modal-overlay"
style={
keyboardOverlap > 0
? {
"--overlay-padding-top": "0px",
} as CSSProperties
: undefined
}
style={{
// FNXC:FloatingWindow 2026-06-22-23:00: In floating mode the z-index lives on the fixed overlay (it owns the stacking context); a panel z is trapped inside it and loses to page stacking contexts like the right dock (position:absolute z-index:20). Docked/mobile keep their CSS z.
...(isFloatingMode ? { zIndex: floatingZ } : {}),
...(keyboardOverlap > 0 ? { "--overlay-padding-top": "0px" } : {}),
} as CSSProperties}
>
<div
ref={modalRef}
className={modalClassName}
data-testid="terminal-modal"
style={modalStyle}
onPointerDownCapture={isFloatingMode ? bringFloatingToFront : undefined}
onFocusCapture={isFloatingMode ? bringFloatingToFront : undefined}
>
{isDockedMode && (
<div
@@ -2166,6 +2295,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
</span>
</div>
</div>
</div>
</div>,
document.body,
);
}

View File

@@ -0,0 +1,94 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { FloatingWindow } from "../FloatingWindow";
/*
FNXC:FloatingWindow 2026-06-22-20:45:
Contract tests for the reusable non-blocking floating window:
- the overlay is click-through (pointer-events:none) so the page and other windows behind it stay interactive,
- the panel re-enables pointer events and carries a header drag handle + resize handles,
- focus-to-front raises this window's z-index above any previously-opened window,
- close removes the window (onClose fires).
JSDOM has no real layout/pointer-capture, so drag math is asserted in the RightDockExpandModal pattern's own suite; here we assert the structural + stacking contract that makes multiple coexisting windows non-blocking.
*/
describe("FloatingWindow", () => {
it("renders a non-blocking, click-through transparent overlay with a pointer-events:auto panel", () => {
render(
<FloatingWindow windowKey="alpha" title="Alpha" onClose={() => {}}>
<div>alpha body</div>
</FloatingWindow>
);
const overlay = screen.getByTestId("floating-window-overlay-alpha");
// styles.css is not loaded here, so assert via the class contract the CSS attaches pointer-events:none to.
expect(overlay.className).toContain("floating-window-overlay");
const panel = screen.getByTestId("floating-window-alpha");
expect(panel.className).toContain("floating-window");
// Panel is positioned/stacked via inline style.
expect(panel.style.position === "" || panel.style.left).toBeDefined();
expect(panel.style.zIndex).not.toBe("");
});
it("exposes a header drag handle and resize handles", () => {
render(
<FloatingWindow windowKey="beta" title="Beta" onClose={() => {}}>
<div>beta body</div>
</FloatingWindow>
);
expect(screen.getByTestId("floating-window-drag-handle-beta")).toBeTruthy();
// 8 edge/corner resize handles.
for (const dir of ["n", "s", "e", "w", "ne", "nw", "se", "sw"]) {
expect(screen.getByTestId(`floating-window-resize-${dir}`)).toBeTruthy();
}
});
it("focus-to-front: interacting with an older window raises its z-index above the newest", () => {
render(
<>
<FloatingWindow windowKey="first" title="First" onClose={() => {}}>
<div>first</div>
</FloatingWindow>
<FloatingWindow windowKey="second" title="Second" onClose={() => {}}>
<div>second</div>
</FloatingWindow>
</>
);
const first = screen.getByTestId("floating-window-first");
const second = screen.getByTestId("floating-window-second");
// Second mounted last → starts on top.
expect(Number(second.style.zIndex)).toBeGreaterThan(Number(first.style.zIndex));
// Clicking the first panel raises it above the second.
fireEvent.pointerDown(first);
expect(Number(first.style.zIndex)).toBeGreaterThan(Number(second.style.zIndex));
});
it("close button removes the window via onClose", () => {
const onClose = vi.fn();
render(
<FloatingWindow windowKey="gamma" title="Gamma" onClose={onClose}>
<div>gamma body</div>
</FloatingWindow>
);
fireEvent.click(screen.getByTestId("floating-window-close-gamma"));
expect(onClose).toHaveBeenCalledTimes(1);
});
it("multiple windows coexist independently (each renders its own panel)", () => {
render(
<>
<FloatingWindow windowKey="w1" title="W1" onClose={() => {}}>
<div>one</div>
</FloatingWindow>
<FloatingWindow windowKey="w2" title="W2" onClose={() => {}}>
<div>two</div>
</FloatingWindow>
<FloatingWindow windowKey="w3" title="W3" onClose={() => {}}>
<div>three</div>
</FloatingWindow>
</>
);
expect(screen.getByTestId("floating-window-w1")).toBeTruthy();
expect(screen.getByTestId("floating-window-w2")).toBeTruthy();
expect(screen.getByTestId("floating-window-w3")).toBeTruthy();
});
});

View File

@@ -0,0 +1,52 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { FloatingWindow } from "../FloatingWindow";
import { RightDockExpandModal } from "../RightDockExpandModal";
import { nextFloatingZ, currentFloatingZ } from "../floatingWindowStack";
/*
FNXC:FloatingWindow 2026-06-22-21:30:
Cross-type shared-stack contract. Every floating modal type (FloatingWindow, the right-dock pop-out, the floating terminal, the floating New Task dialog) must draw its z-index from the SINGLE module-level `floatingWindowStack` counter so tapping ANY of them raises it above ALL the others REGARDLESS of type. Before this, each type owned a private counter and tapping the terminal could not raise it above a popped-out FloatingWindow. This suite proves two different component types interleave in one monotonic stack and that tapping the older one raises it above the newer one across the type boundary. RightDockExpandModal stands in for the three non-FloatingWindow floating modals (terminal + New Task wire the identical claim-on-mount + bring-to-front-on-pointerdown pattern; they are heavier to mount in JSDOM and assert the same inline-zIndex contract).
*/
const renderProps = { addToast: () => {}, projectId: "project-1" } as const;
describe("floatingWindowStack (cross-type)", () => {
it("hands out a strictly increasing, shared z to every claimant", () => {
const a = nextFloatingZ();
const b = nextFloatingZ();
expect(b).toBeGreaterThan(a);
expect(currentFloatingZ()).toBe(b);
});
it("tapping a FloatingWindow raises it above a right-dock pop-out opened after it (and vice versa)", () => {
render(
<>
<FloatingWindow windowKey="fw" title="FW" onClose={() => {}}>
<div>fw body</div>
</FloatingWindow>
<RightDockExpandModal viewKey="files" renderProps={renderProps} onClose={() => {}} />
</>,
);
const fwPanel = screen.getByTestId("floating-window-fw");
const dockPanel = screen
.getByTestId("right-dock-expand-modal")
.querySelector(".right-dock-expand-modal--floating") as HTMLElement;
// Both carry an inline z-index from the shared stack.
expect(fwPanel.style.zIndex).not.toBe("");
expect(dockPanel.style.zIndex).not.toBe("");
// The dock pop-out mounted last → it starts on top of the FloatingWindow, proving one shared stack.
expect(Number(dockPanel.style.zIndex)).toBeGreaterThan(Number(fwPanel.style.zIndex));
// Tapping the older FloatingWindow raises it above the dock pop-out — across the type boundary.
fireEvent.pointerDown(fwPanel);
expect(Number(fwPanel.style.zIndex)).toBeGreaterThan(Number(dockPanel.style.zIndex));
// Tapping the dock pop-out raises it back above the FloatingWindow.
fireEvent.pointerDown(dockPanel);
expect(Number(dockPanel.style.zIndex)).toBeGreaterThan(Number(fwPanel.style.zIndex));
});
});

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
import { act, render, screen, fireEvent, waitFor, within } from "@testing-library/react";
import { GitHubImportModal } from "../GitHubImportModal";
import {
apiFetchGitHubIssues,
@@ -294,6 +294,64 @@ describe("GitHubImportModal", () => {
});
describe("with single remote", () => {
it("loads remotes using the active project id", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" />);
await waitFor(() => {
expect(fetchGitRemotes).toHaveBeenCalledWith("project-1");
});
});
it("ignores stale remote responses after the active project changes", async () => {
const projectARemote: GitRemote[] = [
{ name: "origin", owner: "project-a", repo: "old-repo", url: "https://github.com/project-a/old-repo.git" },
];
const projectBRemote: GitRemote[] = [
{ name: "origin", owner: "project-b", repo: "new-repo", url: "https://github.com/project-b/new-repo.git" },
];
let resolveProjectA!: (value: GitRemote[]) => void;
let resolveProjectB!: (value: GitRemote[]) => void;
vi.mocked(fetchGitRemotes)
.mockImplementationOnce(() => new Promise((resolve) => {
resolveProjectA = resolve;
}))
.mockImplementationOnce(() => new Promise((resolve) => {
resolveProjectB = resolve;
}));
const { rerender } = render(
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-a" />,
);
await waitFor(() => {
expect(fetchGitRemotes).toHaveBeenCalledWith("project-a");
});
rerender(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-b" />);
await waitFor(() => {
expect(fetchGitRemotes).toHaveBeenCalledWith("project-b");
});
await act(async () => {
resolveProjectB(projectBRemote);
});
await waitFor(() => {
expect(screen.getByText("project-b/new-repo")).toBeTruthy();
});
await act(async () => {
resolveProjectA(projectARemote);
});
await waitFor(() => {
expect(screen.getByText("project-b/new-repo")).toBeTruthy();
expect(screen.queryByText("project-a/old-repo")).toBeNull();
});
});
it("auto-selects the remote and shows compact pill", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);

View File

@@ -3417,8 +3417,9 @@ describe("GitManagerModal", () => {
const navItemRules = getRuleBlocks(mobile768, ".gm-nav-item");
expect(navItemRules).toHaveLength(1);
// Mobile tabs are compact ICON-ONLY in one scrolling row: non-shrinking via flex:0 0 auto + intrinsic width:auto (overrides the base .gm-nav-item width:100% that otherwise made one tab fill the row).
expect(navItemRules[0]).toContain("flex: 0 0 auto;");
expect(navItemRules[0]).toContain("min-height: calc(var(--space-xl) + var(--space-sm));");
expect(navItemRules[0]).toContain("width: auto;");
expect(mobile720).not.toContain(".gm-sidebar");
expect(mobile720).not.toContain(".gm-nav-item");

View File

@@ -1884,13 +1884,14 @@ describe("MailboxView", () => {
const resizeHandleBlockMatch = css.match(/\.mailbox-view\s+\.mailbox-split-resize-handle\s*\{([^}]*)\}/);
expect(resizeHandleBlockMatch).toBeTruthy();
const resizeHandleBlock = resizeHandleBlockMatch![1];
expect(resizeHandleBlock).toContain("width: var(--space-xs);");
// FNXC:Mailbox 2026-06-22-18:20: handle mirrors the Chat sidebar divider — hit area var(--space-sm), transparent until hover, centered var(--space-xs) line.
expect(resizeHandleBlock).toContain("width: var(--space-sm);");
expect(resizeHandleBlock).toContain("cursor: col-resize;");
expect(resizeHandleBlock).toContain("background: color-mix(in srgb, var(--border) 70%, transparent);");
expect(resizeHandleBlock).toContain("background: transparent;");
const resizeHandleTargetBlockMatch = css.match(/\.mailbox-view\s+\.mailbox-split-resize-handle::before\s*\{([^}]*)\}/);
expect(resizeHandleTargetBlockMatch).toBeTruthy();
expect(resizeHandleTargetBlockMatch![1]).toContain("width: var(--space-sm);");
expect(resizeHandleTargetBlockMatch![1]).toContain("width: var(--space-xs);");
expect(css).toMatch(/\.mailbox-view\s+\.mailbox-split-resize-handle:hover::before,\s*\n\.mailbox-view\s+\.mailbox-split-resize-handle:active::before\s*\{[^}]*background:\s*color-mix\(in srgb,\s*var\(--todo\)\s*35%,\s*transparent\);[^}]*\}/);
const splitEmptyBlockMatch = css.match(/\.mailbox-view\s+\.mailbox-split-empty\s*\{([^}]*)\}/);

View File

@@ -53,11 +53,13 @@ vi.mock("../../hooks/useMobileKeyboard", () => ({
useMobileKeyboard: (...args: unknown[]) => mockUseMobileKeyboard(...args),
}));
// FNXC:NewTask 2026-06-22-20:30: viewport mode is switchable so we can exercise both the mobile sheet (default) and the desktop floating window. Defaults to mobile to preserve the existing suite's layout assumptions.
let mockViewportMode: "mobile" | "desktop" = "mobile";
vi.mock("../../hooks/useViewportMode", () => ({
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
getViewportMode: () => "mobile",
isMobileViewport: () => true,
useViewportMode: () => "mobile",
getViewportMode: () => mockViewportMode,
isMobileViewport: () => mockViewportMode === "mobile",
useViewportMode: () => mockViewportMode,
}));
function makeTask(id: string): Task {
@@ -92,6 +94,7 @@ function renderNewTaskModal(props: Partial<ComponentProps<typeof NewTaskModal>>
describe("NewTaskModal", () => {
beforeEach(() => {
vi.clearAllMocks();
mockViewportMode = "mobile";
mockConfirm.mockReset();
mockConfirm.mockResolvedValue(true);
mockUseMobileKeyboard.mockReturnValue({
@@ -110,8 +113,9 @@ describe("NewTaskModal", () => {
viewportOffsetTop: 50,
});
const { container } = renderNewTaskModal();
const modal = container.querySelector(".new-task-modal");
renderNewTaskModal();
// FNXC: NewTaskModal portals to document.body, so query the modal from document (not the render container).
const modal = document.querySelector(".new-task-modal");
expect(mockUseMobileKeyboard).toHaveBeenCalledWith({ enabled: true });
expect(modal?.getAttribute("style")).toContain("--keyboard-overlap: 250px");
@@ -131,7 +135,7 @@ describe("NewTaskModal", () => {
renderNewTaskModal();
expect(screen.getByText("New Task")).toBeTruthy();
expect(screen.getByRole('textbox')).toBeTruthy();
expect(screen.getByPlaceholderText("What needs to be done?")).toBeTruthy();
expect(screen.queryByRole("button", { name: "Plan" })).toBeNull();
expect(screen.queryByRole("button", { name: "Subtask" })).toBeNull();
expect(screen.queryByTestId("task-form-description-actions")).toBeNull();
@@ -140,7 +144,6 @@ describe("NewTaskModal", () => {
expect(screen.getByTestId("dep-trigger")).toBeInTheDocument();
expect(screen.getByTestId("new-task-agent-button")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
await waitFor(() => {
expect(screen.getByText(/Model Configuration/i)).toBeTruthy();
@@ -156,7 +159,7 @@ describe("NewTaskModal", () => {
onSubtaskBreakdown: vi.fn(),
});
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Create parity coverage" } });
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Create parity coverage" } });
// Canonical QuickEntryBox action row includes Plan, Subtask, Refine, Deps, Attach, Models, Node, and Agent affordances; the modal maps these to existing TaskForm/quick-field controls instead of duplicating implementations.
expect(screen.getAllByTestId("task-form-plan-button")).toHaveLength(1);
@@ -165,7 +168,6 @@ describe("NewTaskModal", () => {
expect(screen.getByTestId("dep-trigger")).toBeInTheDocument();
expect(screen.getByTestId("new-task-agent-button")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
expect(screen.getByTestId("task-form-execution-mode-select")).toBeInTheDocument();
expect(screen.getByTestId("task-form-github-tracking")).toBeInTheDocument();
@@ -177,7 +179,6 @@ describe("NewTaskModal", () => {
it("renders the Fast and standard execution-mode affordance inside More options", () => {
renderNewTaskModal();
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
const select = screen.getByTestId("task-form-execution-mode-select") as HTMLSelectElement;
expect(select).toBeInTheDocument();
@@ -188,7 +189,6 @@ describe("NewTaskModal", () => {
it("includes executionMode fast in the create payload when Fast is selected", async () => {
const { props } = renderNewTaskModal();
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
fireEvent.change(screen.getByTestId("task-form-execution-mode-select"), { target: { value: "fast" } });
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Fast parity task" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
@@ -206,7 +206,7 @@ describe("NewTaskModal", () => {
it("omits executionMode from the create payload when Standard is selected", async () => {
const { props } = renderNewTaskModal();
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Standard parity task" } });
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Standard parity task" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
@@ -219,7 +219,6 @@ describe("NewTaskModal", () => {
it("resets executionMode to standard after canceling and discarding changes", async () => {
const { props, rerender } = renderNewTaskModal();
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
fireEvent.change(screen.getByTestId("task-form-execution-mode-select"), { target: { value: "fast" } });
await waitFor(() => {
@@ -237,7 +236,6 @@ describe("NewTaskModal", () => {
rerender(<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 +248,7 @@ describe("NewTaskModal", () => {
onSubtaskBreakdown,
});
fireEvent.change(screen.getByRole("textbox"), { target: { value: " Break this down " } });
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: " Break this down " } });
fireEvent.click(screen.getByTestId("task-form-plan-button"));
expect(props.onClose).toHaveBeenCalledTimes(1);
@@ -264,7 +262,7 @@ describe("NewTaskModal", () => {
onSubtaskBreakdown,
});
fireEvent.change(screen.getByRole("textbox"), { target: { value: " Split into subtasks " } });
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: " Split into subtasks " } });
fireEvent.click(screen.getByTestId("task-form-subtask-button"));
expect(onSubtaskBreakdown).toHaveBeenCalledWith("Split into subtasks");
@@ -283,52 +281,45 @@ describe("NewTaskModal", () => {
expect(planButton).toBeDisabled();
expect(subtaskButton).toBeDisabled();
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Ready to plan" } });
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Ready to plan" } });
expect(planButton).not.toBeDisabled();
expect(subtaskButton).not.toBeDisabled();
});
it("shows More options toggle and reveals advanced fields when clicked", async () => {
// FNXC:NewTask 2026-06-22-20:30: The New Task dialog force-opens TaskForm's advanced controls (forceMoreOptionsOpen), so every quick-add control is visible by default with NO disclosure toggle and nothing hidden.
it("shows all advanced fields by default without a More options toggle", () => {
renderNewTaskModal();
const toggle = screen.getByTestId("task-form-more-options-toggle");
const moreOptions = screen.getByTestId("task-form-more-options");
expect(toggle).toHaveAttribute("aria-expanded", "false");
expect(moreOptions).toHaveAttribute("hidden");
// Dependencies are now in quick-fields (visible by default), so the dep-trigger is present
// No collapse toggle is rendered in the force-open New Task context.
expect(screen.queryByTestId("task-form-more-options-toggle")).toBeNull();
// The advanced section is open (not hidden) from the start.
expect(moreOptions).not.toHaveAttribute("hidden");
expect(screen.getByTestId("dep-trigger")).toBeInTheDocument();
fireEvent.click(toggle);
await waitFor(() => {
expect(toggle).toHaveAttribute("aria-expanded", "true");
expect(moreOptions).not.toHaveAttribute("hidden");
});
// Model Configuration, Attachments, and the Workflow picker are revealed
// Model Configuration, Attachments, and the Workflow picker are all visible immediately.
expect(screen.getByText(/Model Configuration/i)).toBeTruthy();
expect(screen.getByText(/Attachments/i)).toBeTruthy();
expect(screen.getByText("Workflow")).toBeTruthy();
});
it("shows dependencies and agent picker by default without expanding More options", () => {
it("shows dependencies and agent picker by default", () => {
renderNewTaskModal();
// Both dep-trigger and agent button should be visible by default
expect(screen.getByTestId("dep-trigger")).toBeInTheDocument();
expect(screen.getByTestId("new-task-agent-button")).toBeInTheDocument();
// More options should be collapsed
expect(screen.getByTestId("task-form-more-options-toggle")).toHaveAttribute("aria-expanded", "false");
// Advanced options are force-open: no collapse toggle exists.
expect(screen.queryByTestId("task-form-more-options-toggle")).toBeNull();
expect(screen.getByTestId("task-form-more-options")).not.toHaveAttribute("hidden");
});
it("renders dependencies before attachments in form order (quick-fields before More options)", () => {
renderNewTaskModal();
const dependenciesLabel = screen.getByText("Dependencies");
// Attachments is inside the collapsed "More options" section, so we need to expand first
const toggle = screen.getByTestId("task-form-more-options-toggle");
fireEvent.click(toggle);
// Attachments is in the always-visible advanced section.
const attachmentsLabel = screen.getByText("Attachments");
// Dependencies (in quick-fields) appears before Attachments (in More options)
@@ -340,7 +331,7 @@ describe("NewTaskModal", () => {
it("focuses description textarea when modal opens", async () => {
renderNewTaskModal();
const textarea = screen.getByRole('textbox');
const textarea = screen.getByPlaceholderText("What needs to be done?");
await waitFor(() => {
expect(document.activeElement).toBe(textarea);
});
@@ -349,24 +340,24 @@ describe("NewTaskModal", () => {
it("seeds the description when opened with an initial description", () => {
renderNewTaskModal({ initialDescription: "File: README.md\n\nComment:\nFollow up" });
expect(screen.getByRole("textbox")).toHaveValue("File: README.md\n\nComment:\nFollow up");
expect(screen.getByPlaceholderText("What needs to be done?")).toHaveValue("File: README.md\n\nComment:\nFollow up");
expect(screen.getByRole("button", { name: "Create Task" })).not.toBeDisabled();
});
it("does not clobber user edits when initialDescription changes while open", () => {
const { rerender, props } = renderNewTaskModal({ initialDescription: "Seeded description" });
const descTextarea = screen.getByRole("textbox");
const descTextarea = screen.getByPlaceholderText("What needs to be done?");
fireEvent.change(descTextarea, { target: { value: "User edited text" } });
rerender(<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 +473,6 @@ describe("NewTaskModal", () => {
const { props } = renderNewTaskModal();
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task with branches" } });
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
fireEvent.change(screen.getByLabelText("Branch strategy"), { target: { value: "existing" } });
fireEvent.change(screen.getByLabelText("Branch name"), { target: { value: " feature/fn-3422 " } });
fireEvent.change(screen.getByLabelText("Merge target / base branch"), { target: { value: " main " } });
@@ -506,7 +496,6 @@ describe("NewTaskModal", () => {
const { props } = renderNewTaskModal();
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task with auto new" } });
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
fireEvent.change(screen.getByLabelText("Branch strategy"), { target: { value: "auto-new" } });
fireEvent.change(screen.getByLabelText("Merge target / base branch"), { target: { value: " main " } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
@@ -527,7 +516,6 @@ describe("NewTaskModal", () => {
const { props } = renderNewTaskModal();
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task with branches" } });
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
fireEvent.change(screen.getByLabelText("Branch strategy"), { target: { value: "custom-new" } });
expect(screen.getByRole("button", { name: "Create Task" })).toBeDisabled();
@@ -541,7 +529,6 @@ describe("NewTaskModal", () => {
const { props } = renderNewTaskModal();
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task with custom new" } });
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
fireEvent.change(screen.getByLabelText("Branch strategy"), { target: { value: "custom-new" } });
fireEvent.change(screen.getByLabelText("Branch name"), { target: { value: " feature/custom " } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
@@ -562,7 +549,6 @@ describe("NewTaskModal", () => {
const { props } = renderNewTaskModal();
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task with shared group" } });
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
fireEvent.change(screen.getByLabelText("Branch strategy"), { target: { value: "shared-group" } });
expect(screen.getByRole("button", { name: "Create Task" })).toBeDisabled();
@@ -576,7 +562,6 @@ describe("NewTaskModal", () => {
const { props } = renderNewTaskModal();
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task with shared group" } });
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
fireEvent.change(screen.getByLabelText("Branch strategy"), { target: { value: "shared-group" } });
fireEvent.change(screen.getByLabelText("Shared feature branch"), { target: { value: " feature/shared " } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
@@ -606,7 +591,7 @@ describe("NewTaskModal", () => {
expect(screen.getByText("GitHub not connected")).toBeTruthy();
});
const descTextarea = screen.getByRole("textbox");
const descTextarea = screen.getByPlaceholderText("What needs to be done?");
fireEvent.change(descTextarea, { target: { value: "Submit despite warning" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
@@ -622,7 +607,7 @@ describe("NewTaskModal", () => {
it("closes modal after successful creation", async () => {
const { props } = renderNewTaskModal();
const descTextarea = screen.getByRole('textbox');
const descTextarea = screen.getByPlaceholderText("What needs to be done?");
fireEvent.change(descTextarea, { target: { value: "Test" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
@@ -638,7 +623,7 @@ describe("NewTaskModal", () => {
onCreateTask: vi.fn().mockResolvedValue({ id: "FN-042" }),
});
const descTextarea = screen.getByRole('textbox');
const descTextarea = screen.getByPlaceholderText("What needs to be done?");
fireEvent.change(descTextarea, { target: { value: "Test description" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
@@ -651,7 +636,7 @@ describe("NewTaskModal", () => {
it("confirms before closing with dirty state", async () => {
const { props } = renderNewTaskModal();
const descTextarea = screen.getByRole('textbox');
const descTextarea = screen.getByPlaceholderText("What needs to be done?");
fireEvent.change(descTextarea, { target: { value: "Test description" } });
mockConfirm.mockResolvedValueOnce(false);
@@ -678,7 +663,7 @@ describe("NewTaskModal", () => {
it("creates task with title undefined by default", async () => {
const { props } = renderNewTaskModal();
const descTextarea = screen.getByRole('textbox');
const descTextarea = screen.getByPlaceholderText("What needs to be done?");
fireEvent.change(descTextarea, { target: { value: "Only description" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
@@ -696,7 +681,7 @@ describe("NewTaskModal", () => {
it("calls onCreateTask when form is submitted", async () => {
const { props } = renderNewTaskModal();
const descTextarea = screen.getByRole('textbox');
const descTextarea = screen.getByPlaceholderText("What needs to be done?");
fireEvent.change(descTextarea, { target: { value: "Normal task" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
@@ -721,7 +706,7 @@ describe("NewTaskModal", () => {
it("enables Create Task when description has content", () => {
renderNewTaskModal();
const descTextarea = screen.getByRole('textbox');
const descTextarea = screen.getByPlaceholderText("What needs to be done?");
fireEvent.change(descTextarea, { target: { value: "Some text" } });
const createButton = screen.getByRole("button", { name: "Create Task" });
@@ -733,7 +718,7 @@ describe("NewTaskModal", () => {
it("omits modelPresetId from payload when in default mode", async () => {
const { props } = renderNewTaskModal();
const descTextarea = screen.getByRole('textbox');
const descTextarea = screen.getByPlaceholderText("What needs to be done?");
fireEvent.change(descTextarea, { target: { value: "Default mode task" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
@@ -767,7 +752,7 @@ describe("NewTaskModal", () => {
});
// Type a description
fireEvent.change(screen.getByRole('textbox'), { target: { value: "Preset task" } });
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Preset task" } });
// Select the preset
const select = document.getElementById("model-preset") as HTMLSelectElement;
@@ -808,7 +793,7 @@ describe("NewTaskModal", () => {
});
// Type a description
fireEvent.change(screen.getByRole('textbox'), { target: { value: "Custom task" } });
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Custom task" } });
// Select a preset first
const select = document.getElementById("model-preset") as HTMLSelectElement;
@@ -857,7 +842,7 @@ describe("NewTaskModal", () => {
expect(screen.getByTestId("task-workflow-select")).toBeTruthy();
});
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Inherit default" } });
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Inherit default" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
@@ -876,7 +861,7 @@ describe("NewTaskModal", () => {
});
fireEvent.change(screen.getByTestId("task-workflow-select"), { target: { value: "WF-1" } });
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Pick a workflow" } });
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Pick a workflow" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
@@ -897,7 +882,7 @@ describe("NewTaskModal", () => {
// Pick a workflow, then switch to "No workflow" to register an explicit null.
fireEvent.change(screen.getByTestId("task-workflow-select"), { target: { value: "WF-1" } });
fireEvent.change(screen.getByTestId("task-workflow-select"), { target: { value: "__none__" } });
fireEvent.change(screen.getByRole("textbox"), { target: { value: "No workflow task" } });
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "No workflow task" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
@@ -924,7 +909,7 @@ describe("NewTaskModal", () => {
it("omits reviewLevel from payload when not selected", async () => {
const { props } = renderNewTaskModal();
const descTextarea = screen.getByRole('textbox');
const descTextarea = screen.getByPlaceholderText("What needs to be done?");
fireEvent.change(descTextarea, { target: { value: "Task without review level" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
@@ -942,7 +927,6 @@ describe("NewTaskModal", () => {
const { props } = renderNewTaskModal();
// Open more options to access the review level selector
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
await waitFor(() => {
expect(screen.getByLabelText("Review")).toBeTruthy();
@@ -970,7 +954,6 @@ describe("NewTaskModal", () => {
const { props } = renderNewTaskModal();
// Open more options to access the review level selector
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
await waitFor(() => {
expect(screen.getByLabelText("Review")).toBeTruthy();
@@ -999,7 +982,7 @@ describe("NewTaskModal", () => {
it("omits autoMerge from payload when default is selected", async () => {
const { props } = renderNewTaskModal();
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Task default auto-merge" } });
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task default auto-merge" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
@@ -1012,7 +995,6 @@ describe("NewTaskModal", () => {
it("includes autoMerge true when Enabled is selected", async () => {
const { props } = renderNewTaskModal();
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
await waitFor(() => {
expect(screen.getByTestId("task-automerge-select")).toBeTruthy();
});
@@ -1030,7 +1012,6 @@ describe("NewTaskModal", () => {
it("includes autoMerge false when Disabled is selected", async () => {
const { props } = renderNewTaskModal();
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
await waitFor(() => {
expect(screen.getByTestId("task-automerge-select")).toBeTruthy();
});
@@ -1050,7 +1031,7 @@ describe("NewTaskModal", () => {
it("includes default normal priority in create payload", async () => {
const { props } = renderNewTaskModal();
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Task with default priority" } });
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task with default priority" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
@@ -1065,7 +1046,6 @@ describe("NewTaskModal", () => {
it("includes selected priority and resets back to normal after submit", async () => {
const { props } = renderNewTaskModal();
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
fireEvent.change(screen.getByTestId("task-priority-select"), { target: { value: "urgent" } });
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task with urgent priority" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
@@ -1086,7 +1066,6 @@ describe("NewTaskModal", () => {
it("treats non-default priority as dirty state on cancel", async () => {
renderNewTaskModal();
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
fireEvent.change(screen.getByTestId("task-priority-select"), { target: { value: "high" } });
mockConfirm.mockResolvedValueOnce(false);
@@ -1156,7 +1135,7 @@ describe("NewTaskModal", () => {
const { props } = renderNewTaskModal();
// Type description
fireEvent.change(screen.getByRole('textbox'), { target: { value: "Task with agent" } });
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task with agent" } });
// Open agent picker and select agent
fireEvent.click(screen.getByTestId("new-task-agent-button"));
@@ -1182,7 +1161,7 @@ describe("NewTaskModal", () => {
it("omits assignedAgentId from payload when no agent is selected", async () => {
const { props } = renderNewTaskModal();
fireEvent.change(screen.getByRole('textbox'), { target: { value: "Task without agent" } });
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task without agent" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
@@ -1204,7 +1183,7 @@ describe("NewTaskModal", () => {
const { props } = renderNewTaskModal();
// Type description
fireEvent.change(screen.getByRole('textbox'), { target: { value: "Task with agent" } });
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task with agent" } });
// Open agent picker and select agent
fireEvent.click(screen.getByTestId("new-task-agent-button"));
@@ -1274,7 +1253,7 @@ describe("NewTaskModal", () => {
renderNewTaskModal();
// Type description
fireEvent.change(screen.getByRole('textbox'), { target: { value: "Task with agent" } });
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task with agent" } });
// Open agent picker and select agent
fireEvent.click(screen.getByTestId("new-task-agent-button"));
@@ -1298,7 +1277,6 @@ describe("NewTaskModal", () => {
it("renders GitHub tracking after the Workflow picker in more options", async () => {
renderNewTaskModal();
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
const workflowLabel = await screen.findByText("Workflow");
const githubTrackingSection = screen.getByTestId("task-form-github-tracking");
@@ -1318,7 +1296,7 @@ describe("NewTaskModal", () => {
});
const { props } = renderNewTaskModal();
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Task with tracking" } });
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task with tracking" } });
const toggle = await screen.findByLabelText("Enable GitHub issue tracking for this task");
fireEvent.click(toggle);
@@ -1331,4 +1309,49 @@ describe("NewTaskModal", () => {
});
});
});
/*
FNXC:NewTask 2026-06-22-20:30:
On desktop the New Task dialog is a floating, draggable, resizable, NON-BLOCKING window: the overlay is `pointer-events: none` and aria-modal="false" so behind-clicks pass through and never close the dialog (only the header X / Cancel / Escape dismiss). It carries a draggable header handle and resize handles.
*/
describe("desktop floating window", () => {
beforeEach(() => {
mockViewportMode = "desktop";
});
it("renders a non-blocking (pointer-events: none, aria-modal=false) overlay that does not dismiss on click", () => {
const onClose = vi.fn();
renderNewTaskModal({ onClose });
const overlay = screen.getByTestId("new-task-modal-overlay");
// Non-blocking: click-through overlay, not a modal.
expect(overlay).toHaveClass("new-task-modal-overlay");
expect(overlay).toHaveAttribute("aria-modal", "false");
// A behind-click on the overlay must NOT close the dialog (no overlay click-to-dismiss).
fireEvent.click(overlay);
expect(onClose).not.toHaveBeenCalled();
});
it("exposes a draggable header handle and resize handles", () => {
renderNewTaskModal();
expect(screen.getByTestId("new-task-drag-handle")).toHaveClass("new-task-modal__header--draggable");
// All eight corner/edge resize handles are present.
for (const dir of ["n", "s", "e", "w", "ne", "nw", "se", "sw"]) {
expect(screen.getByTestId(`new-task-resize-${dir}`)).toBeInTheDocument();
}
// The floating panel is the fixed-positioned window.
const panel = document.querySelector(".new-task-modal--floating");
expect(panel).not.toBeNull();
});
it("still closes via the header close button (X)", async () => {
const onClose = vi.fn();
renderNewTaskModal({ onClose });
fireEvent.click(screen.getByLabelText("Close"));
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
});
});
});

View File

@@ -347,16 +347,18 @@ describe("RightDock", () => {
render(<Harness />);
// Pop out the currently selected (Files) view from the open dock.
// Pop out the currently selected (Files) view: the floating modal appears AND
// popping out closes the dock (pop-out dismisses the dock so the full-width app
// sits behind the movable modal). The dock unmounts; the floating modal survives.
fireEvent.click(screen.getByTestId("right-dock-expand"));
expect(screen.getByTestId("right-dock-expand-modal")).toBeInTheDocument();
// Toggle the dock closed: the dock itself unmounts, the floating modal MUST survive.
fireEvent.click(screen.getByTestId("harness-toggle-dock"));
expect(screen.queryByTestId("right-dock")).toBeNull();
expect(screen.getByTestId("right-dock-expand-modal")).toBeInTheDocument();
expect(screen.getByTestId("right-dock-expand-body")).toBeInTheDocument();
// Re-opening the dock does not disturb the independent floating modal.
fireEvent.click(screen.getByTestId("harness-toggle-dock"));
expect(screen.getByTestId("right-dock-expand-modal")).toBeInTheDocument();
// Its own close button still dismisses it.
fireEvent.click(screen.getByTestId("right-dock-expand-close"));
expect(screen.queryByTestId("right-dock-expand-modal")).toBeNull();

View File

@@ -168,11 +168,9 @@ function expectTranscriptTextOrder(...texts: string[]) {
}
function expectIdleSessionHint() {
const idleHint = screen.getByTestId("task-chat-idle-hint");
expect(idleHint).toBeVisible();
expect(idleHint).toHaveTextContent(/no agent is working on this task right now/i);
expect(idleHint).toHaveTextContent(/saved as guidance/i);
expect(idleHint).toHaveTextContent(/next time this task runs/i);
// FNXC:TaskDetailChat 2026-06-22-21:20: The idle "No agent is working…" banner was removed per user request — idle chats stay sendable with no hint shown.
expect(screen.queryByTestId("task-chat-idle-hint")).not.toBeInTheDocument();
expect(screen.queryByText(/no agent is working on this task right now/i)).not.toBeInTheDocument();
expect(screen.getByPlaceholderText("Steer the currently executing agent")).toBeInTheDocument();
}
@@ -672,7 +670,7 @@ describe("TaskChatTab", () => {
expect(toolGroup).toHaveAttribute("open");
const invocation = screen.getByTestId("task-chat-tool-invocation");
const kicker = screen.getByText("Tool call → result");
const kicker = screen.getByText("Tool call → Result");
expect(invocation).toHaveClass("task-chat-tool-entry", "task-chat-tool-invocation");
expect(kicker).toHaveClass("task-chat-entry-kicker");
expect(kicker).toBeVisible();
@@ -729,7 +727,7 @@ describe("TaskChatTab", () => {
await user.click(within(summary as HTMLElement).getByText("1 tool call"));
expect(screen.getByText("Tool call → error")).toBeVisible();
expect(screen.getByText("Tool call → Error")).toBeVisible();
expect(screen.getByText("Error")).toBeVisible();
expect(screen.getByText("stderr")).toBeVisible();
});

View File

@@ -261,12 +261,14 @@ describe("TerminalModal", () => {
expect(modal).not.toHaveClass("terminal-modal--floating");
const fitCallBaseline = mockFitAddonFit.mock.calls.length;
const handle = screen.getByTestId("terminal-docked-resize-handle") as HTMLElement & { setPointerCapture: (pointerId: number) => void };
// FNXC:Terminal 2026-06-22-19:50: The resize handlers now capture the pointer and listen on the CAPTURED handle element (not document), so move/up are fired on the handle with the matching pointerId; stub setPointerCapture/releasePointerCapture (jsdom no-ops).
const handle = screen.getByTestId("terminal-docked-resize-handle") as HTMLElement & { setPointerCapture: (pointerId: number) => void; releasePointerCapture: (pointerId: number) => void };
handle.setPointerCapture = vi.fn();
handle.releasePointerCapture = vi.fn();
fireEvent.pointerDown(handle, { pointerId: 1, clientY: 500 });
fireEvent.pointerMove(document, { clientY: 420 });
fireEvent.pointerUp(document, { pointerId: 1 });
fireEvent.pointerMove(handle, { pointerId: 1, clientY: 420 });
fireEvent.pointerUp(handle, { pointerId: 1 });
await waitFor(() => {
expect(window.localStorage.getItem(`fusion:terminal-docked-height-${projectId}`)).toBe("440");
@@ -312,23 +314,26 @@ describe("TerminalModal", () => {
expect(screen.getByTestId("terminal-floating-resize-se")).toBeInTheDocument();
const fitCallBaseline = mockFitAddonFit.mock.calls.length;
const resizeHandle = screen.getByTestId("terminal-floating-resize-se") as HTMLElement & { setPointerCapture: (pointerId: number) => void };
// FNXC:Terminal 2026-06-22-19:50: Floating resize/drag now capture the pointer and listen on the CAPTURED element (not document); fire move/up on that element with the matching pointerId and stub set/releasePointerCapture.
const resizeHandle = screen.getByTestId("terminal-floating-resize-se") as HTMLElement & { setPointerCapture: (pointerId: number) => void; releasePointerCapture: (pointerId: number) => void };
resizeHandle.setPointerCapture = vi.fn();
resizeHandle.releasePointerCapture = vi.fn();
fireEvent.pointerDown(resizeHandle, { pointerId: 1, clientX: 100, clientY: 100 });
fireEvent.pointerMove(document, { clientX: 140, clientY: 130 });
fireEvent.pointerUp(document, { pointerId: 1 });
fireEvent.pointerMove(resizeHandle, { pointerId: 1, clientX: 140, clientY: 130 });
fireEvent.pointerUp(resizeHandle, { pointerId: 1 });
await waitFor(() => {
expect(window.localStorage.getItem(`fusion:terminal-modal-size-${projectId}`)).toBe(JSON.stringify({ width: 992, height: 590 }));
expect(mockFitAddonFit.mock.calls.length).toBeGreaterThan(fitCallBaseline);
});
const header = modal.querySelector(".terminal-header") as HTMLElement & { setPointerCapture: (pointerId: number) => void };
const header = modal.querySelector(".terminal-header") as HTMLElement & { setPointerCapture: (pointerId: number) => void; releasePointerCapture: (pointerId: number) => void };
header.setPointerCapture = vi.fn();
header.releasePointerCapture = vi.fn();
fireEvent.pointerDown(header, { pointerId: 2, clientX: 100, clientY: 100 });
fireEvent.pointerMove(document, { clientX: 125, clientY: 135 });
fireEvent.pointerUp(document, { pointerId: 2 });
fireEvent.pointerMove(header, { pointerId: 2, clientX: 125, clientY: 135 });
fireEvent.pointerUp(header, { pointerId: 2 });
await waitFor(() => {
expect(window.localStorage.getItem(`fusion:terminal-float-pos-${projectId}`)).toBeTruthy();

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { AlertCircle, Cpu, Gauge } from "lucide-react";
import { AlertCircle, Gauge } from "lucide-react";
import type { ActivityAnalytics, ColorTheme, LiveSnapshot, SignalsAnalytics, ThemeMode, TokenAnalytics, ToolAnalytics } from "@fusion/core";
import { api } from "../../api/legacy";
import { DateRangePicker, defaultPresets, rangeFromPreset, type DateRange } from "./DateRangePicker";
@@ -268,45 +268,9 @@ function OverviewTab({
// its own empty state, so it renders even when the stat-card aggregates have no
// data yet.
/*
FNXC:CommandCenter 2026-06-22-18:00:
The "AI Engine" panel hosts the "View Board"/"View Agents" navigation shortcuts and lives inside controlsSection, which renders in EVERY Overview branch (loading/error/empty/populated), so the panel is always visible regardless of data state. It previously rendered only inside the populated return as the .cc-overview-engine-nav row, leaving loading/empty/error states with no shortcuts. The optional status line reuses already-fetched live-snapshot (inProgressTasks) and activity (activeAgents) data — no new endpoint — and is skipped while the live snapshot is still loading. Navigation is owned by App (onChangeView), so the button row only renders when wired up.
FNXC:CommandCenter 2026-06-22-20:55:
The Overview's AI-engine controls are a SINGLE instance: the CommandCenterControls "AI engine" card (Stop AI Engine) now also hosts the "View Board"/"View Agents" shortcuts (threaded onChangeView). The earlier duplicate `.cc-overview-engine-panel` (a second AI Engine row) was removed — the buttons moved into the first instance.
*/
const enginePanel = (
<div className="cc-overview-engine-panel" data-testid="command-center-engine-panel">
<div className="cc-overview-engine-panel-header">
<Cpu size={18} aria-hidden="true" />
<span className="cc-overview-engine-panel-title">
{t("commandCenter.overview.aiEngine", "AI Engine")}
</span>
</div>
{!liveSnapshotLoading ? (
<p className="cc-overview-engine-panel-status" data-testid="command-center-engine-panel-status">
{t("commandCenter.overview.aiEngineStatus", "{{agents}} agents working · {{tasks}} tasks in progress", {
agents: formatCount(activeAgents),
tasks: formatCount(inProgressTasks),
})}
</p>
) : null}
{onChangeView ? (
<div className="cc-overview-engine-nav">
<button
type="button"
className="btn btn-sm cc-overview-engine-nav-btn"
onClick={() => onChangeView("board")}
>
{t("commandCenter.controls.engine.viewBoard", "View Board")}
</button>
<button
type="button"
className="btn btn-sm cc-overview-engine-nav-btn"
onClick={() => onChangeView("agents")}
>
{t("commandCenter.controls.engine.viewAgents", "View Agents")}
</button>
</div>
) : null}
</div>
);
const controlsSection = (
<>
<CommandCenterControls
@@ -318,8 +282,8 @@ function OverviewTab({
onColorThemeChange={onColorThemeChange}
onThemeModeChange={onThemeModeChange}
onShadcnCustomColorsChange={onShadcnCustomColorsChange}
onChangeView={onChangeView}
/>
{enginePanel}
</>
);
const throughputSection = (

View File

@@ -5,6 +5,7 @@ import { DEFAULT_PROJECT_SETTINGS, type ColorTheme, type ThemeMode } from "@fusi
import { fetchConfig, fetchSettings, updateSettings } from "../../api/legacy";
import { useAppSettings } from "../../hooks/useAppSettings";
import { ThemeDropdown } from "../ThemeDropdown";
import type { TaskView } from "../../hooks/useViewState";
import "./CommandCenterControls.css";
export interface CommandCenterControlsProps {
@@ -16,6 +17,8 @@ export interface CommandCenterControlsProps {
onColorThemeChange: (theme: ColorTheme) => void;
onThemeModeChange: (mode: ThemeMode) => void;
onShadcnCustomColorsChange?: (colors: Record<string, string>) => void;
/* FNXC:CommandCenter 2026-06-22-20:55: View Board / View Agents shortcuts live in the AI engine card (under Stop AI Engine), so this is the single AI-engine instance on Overview — the duplicate cc-overview-engine-panel was removed. */
onChangeView?: (view: TaskView) => void;
}
type AsyncState<T> =
@@ -66,7 +69,7 @@ function StatusPill({ paused, label }: { paused: boolean; label: string }) {
);
}
export function CommandCenterControls({ projectId, colorTheme, themeMode, shadcnCustomColors = {}, resolvedThemeMode = themeMode === "light" ? "light" : "dark", onColorThemeChange, onThemeModeChange, onShadcnCustomColorsChange = () => {} }: CommandCenterControlsProps) {
export function CommandCenterControls({ projectId, colorTheme, themeMode, shadcnCustomColors = {}, resolvedThemeMode = themeMode === "light" ? "light" : "dark", onColorThemeChange, onThemeModeChange, onShadcnCustomColorsChange = () => {}, onChangeView }: CommandCenterControlsProps) {
const { t } = useTranslation("app");
const {
globalPaused,
@@ -179,6 +182,24 @@ export function CommandCenterControls({ projectId, colorTheme, themeMode, shadcn
: t("header.stopAiEngine", "Stop AI Engine")}
</span>
</button>
{onChangeView ? (
<div className="cc-overview-engine-nav" data-testid="command-center-engine-panel">
<button
type="button"
className="btn btn-sm cc-overview-engine-nav-btn"
onClick={() => onChangeView("board")}
>
{t("commandCenter.controls.engine.viewBoard", "View Board")}
</button>
<button
type="button"
className="btn btn-sm cc-overview-engine-nav-btn"
onClick={() => onChangeView("agents")}
>
{t("commandCenter.controls.engine.viewAgents", "View Agents")}
</button>
</div>
) : null}
</section>
<section className="card cc-controls-card" data-testid="cc-controls-theme">

View File

@@ -0,0 +1,18 @@
/*
FNXC:FloatingWindow 2026-06-22-21:30:
SHARED floating-window z-index stack. This is the ONE source of z-index for every floating modal in the dashboard (FloatingWindow, the right-dock pop-out, the floating terminal, the floating New Task dialog) so they interoperate in a SINGLE stack instead of each type owning a private counter. Previously each modal type managed z-index independently, so tapping e.g. the terminal could not raise it above a popped-out task-detail FloatingWindow. Now every floating modal claims `nextFloatingZ()` on mount/open and again on every panel pointerdown/focus, so the most-recently-interacted window is always on top REGARDLESS of type.
FNXC:FloatingWindow 2026-06-22-22:30:
Base band sits at 10100+ — ABOVE the page overlay/popover band (log viewer, workflow-editor modal, selection popover, fullscreen overlay at z 10000-10001) so a floating window the user is dragging is never painted over by those. Transient top-right toasts are bumped to 10500 (styles.css) so system feedback still shows above a dragged window. The counter is module-level and intentionally monotonic: it only ever climbs, which is fine for a session-length dashboard. All floating overlays are `pointer-events: none` (click-through) so raising panels into this shared band never traps clicks on the page behind them. CRITICAL: every floating modal must be portaled to document.body so this shared z is compared in ONE root stacking context (an inline panel cannot beat siblings outside its own context no matter its z).
*/
let topZ = 10100;
/** Claim the front of the shared floating-window stack. Monotonic, session-length. */
export function nextFloatingZ(): number {
return ++topZ;
}
/** Current top of the stack (read-only). Lets a window skip a needless bump when already on top. */
export function currentFloatingZ(): number {
return topZ;
}

View File

@@ -424,6 +424,7 @@ export default defineConfig({
"@fusion/plugin-sdk": resolve(__dirname, "../plugin-sdk/src/index.ts"),
"@fusion/test-utils": resolve(__dirname, "../core/src/__test-utils__/workspace.ts"),
"@fusion/dashboard/app/components/TaskCard": resolve(__dirname, "app/components/TaskCard.tsx"),
"@fusion/dashboard/app/components/ViewHeader": resolve(__dirname, "app/components/ViewHeader.tsx"),
"@fusion/dashboard/app/plugins/types": resolve(__dirname, "app/plugins/types.ts"),
"@fusion/dashboard/app/utils/projectStorage": resolve(__dirname, "app/utils/projectStorage.ts"),
"@fusion/dashboard/app/utils/taskStuck": resolve(__dirname, "app/utils/taskStuck.ts"),

View File

@@ -16,7 +16,29 @@ await i18next.use(initReactI18next).init({
// Each namespace present (empty) so hasLoadedNamespace() is true — an
// unloaded namespace makes useTranslation() suspend (no Suspense boundary
// in component tests) even with useSuspense disabled belt-and-braces below.
resources: { en: { common: {}, app: {}, errors: {} } },
//
// FNXC:TestI18n 2026-06-22-21:40:
// Pluralized count keys must resolve from resources, not the singular inline
// default. t("taskChat.entryCount", "{{count}} entry", { count }) renders the
// singular default for ALL counts when the key is absent — so count=2 became
// "2 entry". Provide the _one/_other forms (as the real en locale does) so the
// correct plural ("2 entries", "7 tool calls") renders in tests too. Only these
// keys resolve from the bundle; every other key still falls back to its inline
// default, preserving existing assertions.
resources: {
en: {
common: {},
app: {
taskChat: {
entryCount_one: "{{count}} entry",
entryCount_other: "{{count}} entries",
toolCallCount_one: "{{count}} tool call",
toolCallCount_other: "{{count}} tool calls",
},
},
errors: {},
},
},
ns: ["common", "app", "errors"],
defaultNS: "common",
interpolation: { escapeValue: false },