fix(dashboard): resizers, overlaps, dock pop-out, dedup refactors, live tweaks

Resize fixes (root causes):
- List view: clamp ResizeObserver collapsed the pane to min when container measured 0; harden + rewrite drag to pointer events with teardown.
- Mailbox: split was a CSS grid whose auto track ignored the inline width; switch to flex (pane flex:0 0 auto) + pointer-event drag. List + mailbox panes narrower mins.

Layout fixes:
- Memory Working-Memory overlap: real cause was a cascade collision (MemoryView imports SettingsModal.css, whose .memory-editor-section flex:1 won, overflowing the fixed-height editor onto siblings). Scope MemoryView rules under .memory-working-tab + intrinsic height.
- Agent detail header no longer overlaps (flex-wrap; identity flex:1 1 auto).
- Git Manager dock tabs wrap so all sections are visible (no single-tab swipe).
- Terminal shortcuts+arrows on one line; Skills full width; Skills refresh + Add Goal button heights matched; mailbox divider matches chat divider; Todos in-view header removed.

Features:
- Right-dock pop-out is now a floating, draggable, smoothly resizable, non-blocking window (transparent overlay, interact behind it).
- Command Center Overview gains an always-visible 'AI Engine' panel with View Board / View Agents.
- Default load lands on board, never the Dashboard.

Refactors (behavior-preserving):
- Extract useBoardWorkflows hook (Board + Planning dedup).
- Shared useEmbeddedPresentation hook collapses the 7-way embedded copy-paste; add embedded-presentation test coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-22 04:18:35 -07:00
parent 4850e4309e
commit cc2753c8a2
33 changed files with 1292 additions and 363 deletions

View File

@@ -9,6 +9,7 @@ import type { TFunction } from "i18next";
import { X, History, Trash2, Filter, RefreshCw, CheckCircle, XCircle, ArrowRight, Plus, Settings, AlertCircle, Loader2, Folder } from "lucide-react";
import { clearActivityLog, type ActivityLogEntry, type ActivityEventType, type ActivityFeedEntry } from "../api";
import { useActivityLog } from "../hooks/useActivityLog";
import { useEmbeddedPresentation, type ModalPresentation } from "../hooks/useEmbeddedPresentation";
import type { Task, ProjectInfo } from "@fusion/core";
import { linkifyFilePaths } from "../utils/filePathLinkify";
import { getRelativeTimeBucket } from "../utils/relativeTimeAgo";
@@ -30,7 +31,7 @@ interface ActivityLogModalProps {
FNXC:RightDockEmbedded 2026-06-22-00:00:
Right-dock redesign renders dock items inline (not as fixed popup overlays). When presentation="embedded" the component drops the .modal-overlay fixed full-screen host and the modal close button (the dock owns its own header/close), and disables modal-only Escape-to-close. presentation="modal" (default) stays byte-identical to preserve existing modal behavior.
*/
presentation?: "modal" | "embedded";
presentation?: ModalPresentation;
}
function getEventTypeLabels(t: TFunction<"app">): Record<ActivityEventType, string> {
@@ -131,7 +132,7 @@ export function ActivityLogModal({
currentProject,
presentation = "modal",
}: ActivityLogModalProps) {
const isEmbedded = presentation === "embedded";
const { isEmbedded, escapeEnabled } = useEmbeddedPresentation(presentation);
const { t } = useTranslation("app");
const EVENT_TYPE_LABELS = getEventTypeLabels(t);
const [filteredType, setFilteredType] = useState<ActivityEventType | "all">("all");
@@ -209,7 +210,7 @@ export function ActivityLogModal({
// Handle escape key to close.
// FNXC:RightDockEmbedded 2026-06-22-00:00: Embedded presentation must not auto-close on Escape; the dock owns lifecycle.
useEffect(() => {
if (!isOpen || isEmbedded) return;
if (!isOpen || !escapeEnabled) return;
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
if (showConfirmClear) {
@@ -221,7 +222,7 @@ export function ActivityLogModal({
};
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}, [isOpen, isEmbedded, onClose, showConfirmClear]);
}, [isOpen, escapeEnabled, onClose, showConfirmClear]);
// Determine if any filter is active
const isFilterActive = filteredType !== "all" || filteredProjectId !== "all";

View File

@@ -75,23 +75,30 @@
color: var(--text-muted);
}
/*
FNXC:Agents 2026-06-22-18:00:
The agent-detail header lays out the identity block (avatar + name + active/Healthy badges) and the action cluster (Pause/Stop/Run Now + kebab + refresh + close) on one row.
Previously both sides were `flex-shrink: 0` with no `flex-wrap`, so when a long agent name plus the full button cluster exceeded the modal width neither side shrank and the actions overflowed ON TOP OF the title/badges (visual overlap).
Fix: allow the header to wrap, let the identity block shrink (`flex: 1 1 auto; min-width: 0`) so the name ellipsizes, and let the action cluster wrap below the identity at narrow widths instead of overlaying it. Buttons stay reachable; title/badges stay fully visible at every width.
*/
.agent-detail-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
flex-wrap: wrap;
gap: var(--space-sm) var(--space-md);
padding: var(--space-md) calc(var(--space-lg) + var(--space-xs));
border-bottom: 1px solid var(--border);
background: var(--bg-secondary);
flex-shrink: 0;
}
/* Identity area: icon + name + badges */
/* Identity area: icon + name + badges. Shrinks (name ellipsizes) so it never collides with the actions. */
.agent-detail-identity {
display: flex;
align-items: center;
gap: var(--space-md);
flex-shrink: 0;
flex: 1 1 auto;
min-width: 0;
}
@@ -133,13 +140,17 @@
margin-top: calc(var(--space-xs) * 0.5);
}
/* Unified right-side header action cluster */
/*
FNXC:Agents 2026-06-22-18:00:
The action cluster sits beside the identity block and wraps below it (as a whole) when the row runs out of room, rather than growing to overlay the title. `flex-wrap` lets its own buttons reflow on extremely narrow widths so every control stays reachable.
*/
.agent-detail-header-actions {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: var(--space-sm);
flex: 1 1 auto;
flex: 0 1 auto;
min-width: 0;
}
@@ -147,6 +158,7 @@
.agent-detail-controls {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: calc(var(--space-xs) + var(--space-sm) * 0.25);
flex-shrink: 0;
}

View File

@@ -7,15 +7,15 @@ import "./Board.css";
import type { ToastType } from "../hooks/useToast";
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
import { createPortal } from "react-dom";
import { fetchWorkflowSteps, fetchBoardWorkflows, promoteTask, type ModelInfo, type BoardWorkflowDefinition, type BoardWorkflowsPayload } from "../api";
import { fetchWorkflowSteps, promoteTask, type ModelInfo, type BoardWorkflowsPayload } from "../api";
import { useBlockerFanout } from "../hooks/useBlockerFanout";
import { MOBILE_MEDIA_QUERY } from "../hooks/useViewportMode";
import { recordResumeEvent } from "../utils/resumeInstrumentation";
import { subscribeSse } from "../sse-bus";
import { getBoardCanDropTaskRejection } from "./boardCanDropTask";
import { WorkflowSwitcher } from "./WorkflowSwitcher";
import { computeWorkflowStatusCounts } from "./workflowStatusCounts";
import { readBoardWorkflowsCache, writeBoardWorkflowsCache } from "../utils/boardWorkflowsCache";
import { writeBoardWorkflowsCache } from "../utils/boardWorkflowsCache";
import { useBoardWorkflows } from "../hooks/useBoardWorkflows";
interface BoardProps {
tasks: Task[];
@@ -365,76 +365,23 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
/*
FNXC:BoardWorkflows 2026-06-20-08:58:
Workflow-columns-enabled users must never see the legacy single-lane board while board-workflows metadata is still loading. Hydrate metadata from the project-scoped session cache, reset it on project switches, and show a neutral skeleton while settings or uncached workflow metadata are unknown.
FNXC:Workflows 2026-06-22-17:00:
The board-workflows fetch/cache/SSE/selection loop now lives in `useBoardWorkflows`, shared verbatim with the Planning header slot. Board gates cache hydration on `workflowColumnsEnabled === true || settingsLoaded === false` so workflow-columns users never flash the legacy board, and consumes the exposed raw state setter for optimistic task→workflow assignment. When the flag is OFF the server returns `{ flagEnabled: false }` and we render the legacy single-lane board below.
*/
// Fetch board-workflows metadata. When the flag is OFF the server returns
// { flagEnabled: false } and we render the legacy single-lane board below.
const shouldHydrateBoardWorkflowsCache = workflowColumnsEnabled === true || settingsLoaded === false;
const [boardWorkflowsState, setBoardWorkflowsState] = useState<{ projectId?: string; payload: BoardWorkflowsPayload } | null>(() => {
const cached = shouldHydrateBoardWorkflowsCache ? readBoardWorkflowsCache(projectId) : null;
return cached ? { projectId, payload: cached } : null;
});
const boardWorkflows = boardWorkflowsState?.projectId === projectId && boardWorkflowsState ? boardWorkflowsState.payload : null;
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null>(null);
const {
boardWorkflows,
workflowMode,
workflowOptions,
selectedWorkflow,
selectedWorkflowId,
setSelectedWorkflowId,
refreshBoardWorkflows,
setBoardWorkflowsState,
} = useBoardWorkflows({ projectId, shouldHydrateCache: shouldHydrateBoardWorkflowsCache });
const draggingTaskIdRef = useRef<string | null>(null);
// Fetch board workflow lanes for the project. Deliberately NOT keyed on
// `tasks` — that refetched on every SSE tick. Instead we refetch on project
// change and when the tab regains visibility/focus. A stale-response guard
// (monotonic sequence ref) drops out-of-order responses.
// A `workflow:updated` (and create/delete) SSE event now drives invalidation
// when a definition's lanes / column traits change. The visibility/focus
// refetch below is retained as a stopgap for missed events / reconnects.
const boardWorkflowsFetchSeqRef = useRef(0);
useEffect(() => {
const cached = shouldHydrateBoardWorkflowsCache ? readBoardWorkflowsCache(projectId) : null;
setBoardWorkflowsState(cached ? { projectId, payload: cached } : null);
}, [projectId, shouldHydrateBoardWorkflowsCache]);
/*
FNXC:WorkflowControls 2026-06-21-00:00:
Opening the workflow switcher must refresh the board-workflows payload because task workflow assignment changes do not emit workflow definition SSE events.
Share this path with mount, visibility/focus, and workflow-definition SSE refetches so the stale-response guard and cache writes remain identical.
*/
const refreshBoardWorkflows = useCallback(() => {
const seq = ++boardWorkflowsFetchSeqRef.current;
fetchBoardWorkflows(projectId)
.then((payload) => {
if (seq === boardWorkflowsFetchSeqRef.current) {
setBoardWorkflowsState({ projectId, payload });
writeBoardWorkflowsCache(projectId, payload);
}
})
.catch(() => {
if (seq === boardWorkflowsFetchSeqRef.current) {
setBoardWorkflowsState({ projectId, payload: { flagEnabled: false, defaultWorkflowId: "builtin:coding", workflows: [], taskWorkflowIds: {} } });
}
});
}, [projectId]);
useEffect(() => {
refreshBoardWorkflows();
const onVisible = () => {
if (typeof document === "undefined" || document.visibilityState === "visible") refreshBoardWorkflows();
};
if (typeof document !== "undefined") document.addEventListener("visibilitychange", onVisible);
if (typeof window !== "undefined") window.addEventListener("focus", onVisible);
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
const unsubscribe = subscribeSse(`/api/events${query}`, {
events: {
"workflow:created": refreshBoardWorkflows,
"workflow:updated": refreshBoardWorkflows,
"workflow:deleted": refreshBoardWorkflows,
},
});
return () => {
// Advance the seq so any in-flight response is dropped on cleanup.
boardWorkflowsFetchSeqRef.current++;
if (typeof document !== "undefined") document.removeEventListener("visibilitychange", onVisible);
if (typeof window !== "undefined") window.removeEventListener("focus", onVisible);
unsubscribe();
};
}, [projectId, refreshBoardWorkflows]);
const handlePromote = useCallback(async (taskId: string) => {
await promoteTask(taskId, projectId);
}, [projectId]);
@@ -448,41 +395,11 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
const getDraggingTaskId = useCallback(() => draggingTaskIdRef.current, []);
const flagOn = boardWorkflows?.flagEnabled === true;
const workflowMode = flagOn && Boolean(boardWorkflows?.workflows.length);
const workflowOptions = useMemo<BoardWorkflowDefinition[]>(() => {
if (!workflowMode || !boardWorkflows) return [];
return [...boardWorkflows.workflows].sort((a, b) => {
if (a.id === boardWorkflows.defaultWorkflowId) return -1;
if (b.id === boardWorkflows.defaultWorkflowId) return 1;
return a.name.localeCompare(b.name);
});
}, [boardWorkflows, workflowMode]);
const selectedWorkflow = useMemo<BoardWorkflowDefinition | null>(() => {
if (!workflowMode) return null;
return workflowOptions.find((workflow) => workflow.id === selectedWorkflowId)
?? workflowOptions.find((workflow) => workflow.id === boardWorkflows?.defaultWorkflowId)
?? workflowOptions[0]
?? null;
}, [boardWorkflows?.defaultWorkflowId, selectedWorkflowId, workflowMode, workflowOptions]);
const workflowStatusCounts = useMemo(
() => computeWorkflowStatusCounts(tasks, boardWorkflows),
[boardWorkflows, tasks],
);
useEffect(() => {
if (!workflowMode) {
setSelectedWorkflowId(null);
return;
}
if (selectedWorkflow && selectedWorkflow.id !== selectedWorkflowId) {
setSelectedWorkflowId(selectedWorkflow.id);
}
}, [selectedWorkflow, selectedWorkflowId, workflowMode]);
const selectedWorkflowTasks = useMemo(() => {
if (!workflowMode || !boardWorkflows || !selectedWorkflow) return [];
return tasks.filter((task) => {

View File

@@ -18,6 +18,7 @@ import { GithubIcon } from "./GithubIcon";
import { useModalResizePersist } from "../hooks/useModalResizePersist";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
import { useEmbeddedPresentation, type ModalPresentation } from "../hooks/useEmbeddedPresentation";
interface GitHubImportModalProps {
isOpen: boolean;
@@ -30,7 +31,7 @@ interface GitHubImportModalProps {
Right-dock redesign renders the GitHub import surface inline inside the main content area instead of as a fixed popup overlay.
"embedded" drops the modal overlay/close button and disables modal-only chrome (scroll lock, resize persistence, escape/overlay dismiss); "modal" (default) keeps the original byte-identical overlay behavior.
*/
presentation?: "modal" | "embedded";
presentation?: ModalPresentation;
}
// Mobile and two-pane breakpoints in pixels
@@ -58,8 +59,8 @@ function formatPreviewBody(body: string | null | undefined, isMobile: boolean) {
}
export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, presentation = "modal" }: GitHubImportModalProps) {
const isEmbedded = presentation === "embedded";
useMobileScrollLock(isOpen && !isEmbedded);
const { isEmbedded, scrollLockEnabled, resizePersistEnabled, escapeEnabled } = useEmbeddedPresentation(presentation);
useMobileScrollLock(isOpen && scrollLockEnabled);
const { t } = useTranslation("app");
const [owner, setOwner] = useState("");
const [repo, setRepo] = useState("");
@@ -88,7 +89,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
const [selectedRemoteName, setSelectedRemoteName] = useState<string>("");
const mountedRef = useRef(false);
const modalRef = useRef<HTMLDivElement>(null);
useModalResizePersist(modalRef, isOpen && !isEmbedded, "fusion:github-modal-size");
useModalResizePersist(modalRef, isOpen && resizePersistEnabled, "fusion:github-modal-size");
const overlayDismissProps = useOverlayDismiss(onClose);
// Responsive view state
@@ -291,13 +292,13 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
// Handle escape key
// FNXC:RightDockEmbedding 2026-06-22-00:00: Escape-to-close is a modal-only affordance; embedded mode has no dismiss.
useEffect(() => {
if (!isOpen || isEmbedded) return;
if (!isOpen || !escapeEnabled) return;
const handleKey = (e: globalThis.KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}, [isOpen, isEmbedded, onClose]);
}, [isOpen, escapeEnabled, onClose]);
// Detect responsive viewport bands
useEffect(() => {

View File

@@ -10,6 +10,7 @@ import { useModalResizePersist } from "../hooks/useModalResizePersist";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { useEmbeddedPresentation, type ModalPresentation } from "../hooks/useEmbeddedPresentation";
import { useViewportMode } from "../hooks/useViewportMode";
import type {
GitStatus,
@@ -206,7 +207,7 @@ interface GitManagerModalProps {
Default stays "modal" so all existing overlay call sites keep byte-identical behavior.
Embedded mode must disable modal-only behaviors (scroll lock, resize persistence, Escape-to-close, overlay click dismiss) since they break the host page.
*/
presentation?: "modal" | "embedded";
presentation?: ModalPresentation;
}
// ── Main Component ────────────────────────────────────────────────
@@ -215,9 +216,9 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
const { t } = useTranslation("app");
const confirmContext = useConfirm();
const viewportMode = useViewportMode();
// FNXC:RightDockEmbedding 2026-06-22-00:00: embedded mode gates modal-only behaviors below.
const isEmbedded = presentation === "embedded";
useMobileScrollLock(isOpen && !isEmbedded);
// FNXC:RightDockEmbedding 2026-06-22-00:00: embedded mode gates modal-only behaviors below (shared hook).
const { isEmbedded, scrollLockEnabled, resizePersistEnabled, escapeEnabled } = useEmbeddedPresentation(presentation);
useMobileScrollLock(isOpen && scrollLockEnabled);
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } = useMobileKeyboard({
enabled: viewportMode === "mobile",
});
@@ -246,7 +247,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
const [sectionError, setSectionError] = useState<string | null>(null);
const modalRef = useRef<HTMLDivElement>(null);
// FNXC:RightDockEmbedding 2026-06-22-00:00: skip modal resize persist/restore when embedded inline.
useModalResizePersist(modalRef, isOpen && !isEmbedded, "fusion:git-modal-size");
useModalResizePersist(modalRef, isOpen && resizePersistEnabled, "fusion:git-modal-size");
const overlayDismissProps = useOverlayDismiss(handleClose);
const copyToClipboard = useCopyToClipboard(addToast);
@@ -379,7 +380,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
useEffect(() => {
// FNXC:RightDockEmbedding 2026-06-22-00:00: embedded mode has no overlay to dismiss; a global Escape listener would hijack page keys.
if (!isOpen || isEmbedded) return;
if (!isOpen || !escapeEnabled) return;
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
handleClose();
@@ -398,7 +399,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
};
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}, [isOpen, isEmbedded, handleClose, activeSection]);
}, [isOpen, escapeEnabled, handleClose, activeSection]);
// ── Changes Handlers ────────────────────────────────────────────

View File

@@ -405,6 +405,8 @@ export function ListView({
const [sidebarWidth, setSidebarWidth] = useState<number>(() => readSidebarWidth(projectId));
const splitLayoutRef = useRef<HTMLDivElement>(null);
const splitSidebarRef = useRef<HTMLDivElement>(null);
// FNXC:ListView 2026-06-22-18:00: Holds the active pointer-drag teardown so move/up/cancel/unmount all detach the same listeners — prevents the "window mousemove with no cleanup" leak called out by the frontend-races review.
const splitResizeTeardownRef = useRef<(() => void) | null>(null);
const previousStorageProjectIdRef = useRef(projectId);
const boardWorkflowsFetchSeqRef = useRef(0);
@@ -515,8 +517,16 @@ export function ListView({
if (!container) return;
const applyClamp = () => {
/*
FNXC:ListView 2026-06-22-18:00:
A zero/unmeasurable container width must NOT clamp the persisted sidebar width down to the 64px
min — that collapse made the resize handle appear broken (drag snapped the pane to the minimum
and refused to widen). Only re-clamp when the container reports a real width.
*/
const containerWidth = container.clientWidth;
if (containerWidth <= 0) return;
// Keep width valid when viewport/container size changes.
const clamped = clampSidebarWidth(sidebarWidth, container.clientWidth);
const clamped = clampSidebarWidth(sidebarWidth, containerWidth);
if (clamped !== sidebarWidth) {
setSidebarWidth(clamped);
}
@@ -1529,27 +1539,61 @@ export function ListView({
setDragOverColumn(null);
}, []);
const handleSplitResizeStart = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
/*
FNXC:ListView 2026-06-22-18:00:
Pointer-based split resize. setPointerCapture keeps move/up events flowing to the handle even when
the cursor leaves it, and a single teardown ref (cleared on pointerup/pointercancel/unmount) detaches
every listener exactly once. Width is measured from a live rect per move (re-reading rect.left/width
each frame) and clamped between LIST_SIDEBAR_MIN_WIDTH (64) and 65% of the container so the inline
style={{ width }} — which wins over the grid `auto` track — updates live and persists.
*/
const handleSplitResizeStart = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
if (isMobile) return;
event.preventDefault();
const container = splitLayoutRef.current;
if (!container) return;
event.preventDefault();
const rect = container.getBoundingClientRect();
const onMouseMove = (moveEvent: MouseEvent) => {
// Detach any prior drag (defensive against a missed pointerup).
splitResizeTeardownRef.current?.();
const handle = event.currentTarget;
const pointerId = event.pointerId;
try {
handle.setPointerCapture(pointerId);
} catch {
// setPointerCapture is best-effort (e.g. synthetic events in tests).
}
const onPointerMove = (moveEvent: PointerEvent) => {
const rect = container.getBoundingClientRect();
// Guard against an unmeasurable container so a drag never collapses the pane to the min.
const containerWidth = rect.width > 0 ? rect.width : container.clientWidth;
if (containerWidth <= 0) return;
const proposedWidth = moveEvent.clientX - rect.left;
setSidebarWidth(clampSidebarWidth(proposedWidth, rect.width));
setSidebarWidth(clampSidebarWidth(proposedWidth, containerWidth));
};
const onMouseUp = () => {
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
const teardown = () => {
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", teardown);
window.removeEventListener("pointercancel", teardown);
try {
handle.releasePointerCapture(pointerId);
} catch {
// Capture may already be released.
}
splitResizeTeardownRef.current = null;
};
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
splitResizeTeardownRef.current = teardown;
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", teardown);
window.addEventListener("pointercancel", teardown);
}, [isMobile]);
// FNXC:ListView 2026-06-22-18:00: Tear down any in-flight resize drag on unmount so window pointer listeners never leak.
useEffect(() => () => splitResizeTeardownRef.current?.(), []);
const handleSplitResizeKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
if (isMobile) return;
const measuredWidth = splitLayoutRef.current?.clientWidth ?? 0;
@@ -2394,7 +2438,7 @@ export function ListView({
<div
className="list-split-resize-handle"
data-testid="list-split-resize-handle"
onMouseDown={handleSplitResizeStart}
onPointerDown={handleSplitResizeStart}
onKeyDown={handleSplitResizeKeyDown}
role="separator"
tabIndex={0}

View File

@@ -662,9 +662,13 @@
max-height: none;
}
/*
FNXC:Mailbox 2026-06-22-18:05:
The full-page Messages split layout must let the user drag the divider to resize the left message-list pane. The previous `display: grid` with `grid-template-columns: auto auto minmax(0, 1fr)` sized the list-pane track to its content (`auto` track) and ignored the inline `style={{ width }}` set by the drag handler, so dragging the handle updated state but never changed the rendered pane width. Use a flex row so the list pane's inline `width` is authoritative: the pane is `flex: 0 0 auto` (honor its `width`, never grow/shrink) and the detail pane is `flex: 1 1 auto; min-width: 0` (fill the remainder, allow shrinking below content). The resize handle stays `flex-shrink: 0` with a real `col-resize` hit area.
*/
.mailbox-view .mailbox-split-layout {
display: grid;
grid-template-columns: auto auto minmax(0, 1fr);
display: flex;
flex-direction: row;
gap: 0;
height: 100%;
min-height: 0;
@@ -674,12 +678,17 @@
FNXC:DashboardStyling 2026-06-21-23:40:
FN-6912 requires the full-page Messages divider to read thinner between the message list and detail panes while preserving resize discoverability. Keep the visible handle narrow, but leave the hover/active pseudo-element wider so the drag and focus affordances do not become an un-grabbable sliver.
*/
/*
FNXC:Mailbox 2026-06-22-18:20:
The mailbox resize divider mirrors the Chat sidebar divider exactly — handle hit area var(--space-sm), centered visible line var(--space-xs), transparent until hover — so the two views' dividers read identically (the widths were previously swapped + the mailbox handle had an always-on background).
*/
.mailbox-view .mailbox-split-resize-handle {
position: relative;
width: var(--space-xs);
width: var(--space-sm);
flex-shrink: 0;
cursor: col-resize;
background: color-mix(in srgb, var(--border) 70%, transparent);
pointer-events: auto;
background: transparent;
touch-action: none;
transition: background var(--transition-fast);
}
@@ -690,7 +699,7 @@ FN-6912 requires the full-page Messages divider to read thinner between the mess
top: 0;
bottom: 0;
left: 50%;
width: var(--space-sm);
width: var(--space-xs);
transform: translateX(-50%);
}
@@ -714,8 +723,18 @@ FN-6912 requires the full-page Messages divider to read thinner between the mess
padding: var(--space-md);
}
/*
FNXC:Mailbox 2026-06-22-18:05:
The list pane is fixed to its inline `width` (`flex: 0 0 auto`) so the divider drag is the single source of truth for its size; the detail pane fills the rest (`flex: 1 1 auto`) and `min-width: 0` lets it shrink below its content's intrinsic width so the list pane can grow to the clamped max ratio.
*/
.mailbox-view .mailbox-split-list-pane {
flex: 0 0 auto;
}
.mailbox-view .mailbox-split-detail-pane {
display: flex;
flex: 1 1 auto;
min-width: 0;
flex-direction: column;
gap: var(--space-md);
}

View File

@@ -250,6 +250,11 @@ export function MailboxView({
const [sidebarWidth, setSidebarWidth] = useState<number>(() => readMailboxSidebarWidth(projectId));
const splitLayoutRef = useRef<HTMLDivElement>(null);
const mailboxContentRef = useRef<HTMLDivElement>(null);
/*
FNXC:Mailbox 2026-06-22-18:05:
Teardown ref for the pointer-driven divider drag. The pointer move/up/cancel listeners and the captured pointer must be released exactly once on pointerup, pointercancel, or unmount; storing the cleanup here guarantees we never leak a global listener or a stuck pointer capture if the component unmounts mid-drag.
*/
const splitResizeTeardownRef = useRef<(() => void) | null>(null);
const pendingScrollTopRef = useRef<number | null>(null);
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } = useMobileKeyboard({ enabled: isMobile });
const containerKeyboardStyle = useMemo<CSSProperties | undefined>(() => {
@@ -285,27 +290,56 @@ export function MailboxView({
}
}, [isSplitPane, projectId, sidebarWidth]);
const handleSplitResizeStart = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
/*
FNXC:Mailbox 2026-06-22-18:05:
Divider drag uses pointer events + setPointerCapture so the drag keeps tracking even when the cursor leaves the thin handle. Each move maps the pointer's X to a list-pane width relative to the split-layout left edge, clamped to [MIN, container * MAX_RATIO]. setSidebarWidth feeds the pane's inline `width`, which the flex row now honors, so the resize is live; the existing persistence effect writes the final width to scoped storage. The teardown (release capture + remove listeners) runs once on pointerup/pointercancel and is parked in splitResizeTeardownRef for unmount safety.
*/
const handleSplitResizeStart = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
if (!isSplitPane) return;
event.preventDefault();
const container = splitLayoutRef.current;
if (!container) return;
splitResizeTeardownRef.current?.();
const handle = event.currentTarget;
const rect = container.getBoundingClientRect();
const onMouseMove = (moveEvent: MouseEvent) => {
const pointerId = event.pointerId;
const onPointerMove = (moveEvent: PointerEvent) => {
if (moveEvent.pointerId !== pointerId) return;
const proposedWidth = moveEvent.clientX - rect.left;
setSidebarWidth(clampMailboxSidebarWidth(proposedWidth, rect.width));
};
const onMouseUp = () => {
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
const teardown = () => {
handle.removeEventListener("pointermove", onPointerMove);
handle.removeEventListener("pointerup", teardown);
handle.removeEventListener("pointercancel", teardown);
try {
handle.releasePointerCapture(pointerId);
} catch {
// Pointer capture may already be released; ignore.
}
splitResizeTeardownRef.current = null;
};
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
splitResizeTeardownRef.current = teardown;
try {
handle.setPointerCapture(pointerId);
} catch {
// setPointerCapture can throw in non-DOM test environments; drag still works via listeners.
}
handle.addEventListener("pointermove", onPointerMove);
handle.addEventListener("pointerup", teardown);
handle.addEventListener("pointercancel", teardown);
}, [isSplitPane]);
useEffect(() => () => {
splitResizeTeardownRef.current?.();
}, []);
const handleSplitResizeKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
if (!isSplitPane) return;
const measuredWidth = splitLayoutRef.current?.clientWidth ?? 0;
@@ -1305,7 +1339,7 @@ export function MailboxView({
aria-valuemin={MAILBOX_SIDEBAR_MIN_WIDTH}
aria-valuemax={Math.round(getMailboxSidebarMaxWidth(splitLayoutRef.current?.clientWidth ?? sidebarWidth / MAILBOX_SIDEBAR_MAX_RATIO))}
aria-valuenow={Math.round(sidebarWidth)}
onMouseDown={handleSplitResizeStart}
onPointerDown={handleSplitResizeStart}
onKeyDown={handleSplitResizeKeyDown}
/>
<div className="mailbox-split-detail-pane" data-testid="mailbox-split-detail-pane">

View File

@@ -78,35 +78,39 @@ After the header migrated to the shared .view-header (which is flex-shrink:0), t
}
/*
FNXC:MemoryView 2026-06-22-16:15:
The Working Memory tab is BOTH the scroll owner (overflow-y:auto) and a flex column. Its non-editor siblings — the label/char-count action bar (.memory-action-bar) and the settings stack (.memory-config-section) — must never be flex-compressed by the greedy editor section. Flex children default to flex-shrink:1, so when .memory-editor-section claims flex:1 of the tab height, the siblings shrank below their natural height and their content overran into the next block: the "{n} characters" count overlapped the MEMORY FILE label, the file <select> struck through its divider, and the "Project Memory (Compressed)" header overlapped the card beneath it. Pinning the siblings to flex-shrink:0 keeps each block at its intrinsic height so they stack vertically and the tab scrolls instead of overlapping.
FNXC:Memory 2026-06-22-18:10:
REAL ROOT CAUSE of the "Working Memory" overlap (char-count over the MEMORY FILE label, a line struck through the <select>, the section header on top of its card): a CSS cascade collision, NOT vertical flex compression.
`.memory-editor-section`, `.memory-editor-form-group`, and `.memory-file-summary` are defined in THREE stylesheets — styles.css, SettingsModal.css, and this file — because the SettingsModal MemorySection reuses the same class names. MemoryView.tsx imports BOTH ./MemoryView.css AND ./SettingsModal.css (in that order), so SettingsModal.css's copies (single-class, equal specificity) are injected LAST and WIN. Its `.memory-editor-section { flex: 1 1 auto }` made the editor section greedily claim the tab height while its child `.memory-editor-container` carried a large fixed `min-height` (the CodeMirror floor). On a constrained viewport the section box shrank to its flex allotment but the fixed-min-height editor frame could NOT, so the frame overflowed the (overflow:visible) section and BLED downward, painting on top of the next siblings — that bleed is the overlap, not shrunken siblings. The earlier flex-shrink:0 patch failed because the siblings were never the ones shrinking; the editor frame was overflowing onto them.
Fix: scope the working-tab layout under `.memory-working-tab` so these rules out-specify the SettingsModal.css copies regardless of import order, and let the editor block size to its content (flex:0 0 auto). The tab itself (`.memory-working-tab`, overflow-y:auto) is the sole scroll owner, so every block flows in a clean intrinsic-height vertical stack and the tab scrolls instead of any box overflowing onto the next.
*/
.memory-action-bar,
.memory-config-section {
flex-shrink: 0;
}
.memory-editor-section {
.memory-working-tab .memory-editor-section {
display: flex;
flex-direction: column;
flex: 0 0 auto;
min-height: 0;
flex: 1;
}
/*
FNXC:MemoryView 2026-06-22-16:15:
Inside the editor section only .memory-editor-form-group is allowed to grow/shrink (it hosts the CodeMirror editor). The file-picker form-group and the layer summary must hold their natural height; otherwise the <select> and its hint collapse onto the divider line and look struck-through. Direct-child form-groups other than the editor one are pinned to flex-shrink:0.
FNXC:Memory 2026-06-22-18:10:
Inside the working-tab editor section every block keeps its intrinsic height (flex:0 0 auto) so the file <select>, its hint, the layer summary, and the editor each occupy their own row with no overlap. The CodeMirror frame holds a fixed visible floor via .memory-editor-container's min-height; the surrounding tab scrolls.
*/
.memory-editor-section > .form-group:not(.memory-editor-form-group),
.memory-editor-section > .memory-file-summary {
flex-shrink: 0;
.memory-working-tab .memory-editor-section > .form-group,
.memory-working-tab .memory-editor-section > .memory-file-summary {
flex: 0 0 auto;
}
.memory-editor-form-group {
flex: 1;
min-height: 0;
.memory-working-tab .memory-editor-form-group {
display: flex;
flex-direction: column;
flex: 0 0 auto;
min-height: 0;
}
.memory-editor-container {

View File

@@ -37,6 +37,7 @@ import {
} from "../api";
import { subscribeSse } from "../sse-bus";
import { useModalResizePersist } from "../hooks/useModalResizePersist";
import { useEmbeddedPresentation, type ModalPresentation } from "../hooks/useEmbeddedPresentation";
import {
savePlanningDescription,
getPlanningDescription,
@@ -72,7 +73,7 @@ interface PlanningModeModalProps {
/** When set, reconnect to a persisted background session instead of starting fresh */
resumeSessionId?: string;
/** Render without the full-screen modal chrome when Planning Mode is mounted as a top-level app view. */
presentation?: "modal" | "embedded";
presentation?: ModalPresentation;
}
interface QuestionResponse {
@@ -197,7 +198,10 @@ function parseModelSelection(value: string): { provider?: string; modelId?: stri
export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreated, tasks, initialPlan: initialPlanProp, projectId, workflowId, resumeSessionId, presentation = "modal" }: PlanningModeModalProps) {
const { t } = useTranslation("app");
const isEmbedded = presentation === "embedded";
// FNXC:EmbeddedPresentation 2026-06-22-12:00: shared hook supplies isEmbedded (DOM branching) plus the modal-only gates.
// Note: the Escape handler intentionally does NOT gate on embedded here — embedded planning preserves its historical
// Escape-to-close behavior (the back-stack/onClose path), so escapeEnabled is deliberately not wired below.
const { isEmbedded, scrollLockEnabled, resizePersistEnabled } = useEmbeddedPresentation(presentation);
const [initialPlan, setInitialPlan] = useState("");
const [view, setView] = useState<ViewState>({ type: "initial" });
const [error, setError] = useState<string | null>(null);
@@ -301,7 +305,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
modelId?: string;
} | null>(null);
useModalResizePersist(modalRef, isOpen && !isEmbedded, "fusion:planning-modal-size");
useModalResizePersist(modalRef, isOpen && resizePersistEnabled, "fusion:planning-modal-size");
const viewportMode = useViewportMode();
const isMobile = viewportMode === "mobile";
const { addToast } = useToast();
@@ -309,7 +313,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } =
useMobileKeyboard({ enabled: viewportMode === "mobile" });
useMobileScrollLock(viewportMode === "mobile" && isOpen && !isEmbedded);
useMobileScrollLock(viewportMode === "mobile" && isOpen && scrollLockEnabled);
// Drive --vv-height / --keyboard-overlap / --vv-offset-top imperatively
// rather than via React's style prop. Reason: when React removes a CSS

View File

@@ -1,16 +1,15 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { fetchBoardWorkflows, type BoardWorkflowDefinition, type BoardWorkflowsPayload } from "../api";
import { subscribeSse } from "../sse-bus";
import { WorkflowSwitcher } from "./WorkflowSwitcher";
import type { WorkflowStatusCounts } from "./workflowStatusCounts";
import { readBoardWorkflowsCache, writeBoardWorkflowsCache } from "../utils/boardWorkflowsCache";
import { useBoardWorkflows } from "../hooks/useBoardWorkflows";
/*
FNXC:PlanningWorkflowSwitcher 2026-06-22-00:00:
The Planning view must surface the SAME workflow dropdown as the Board, in the SAME location (the Header `#header-workflow-slot`). Board owns its own switcher only while the board is active, so Planning needs a self-contained mirror that fetches/caches board-workflows, tracks local selection, and portals the identical `board-workflow-toolbar > board-workflow-selector > WorkflowSwitcher` markup into the header slot. We intentionally do NOT import Board (the board switcher is tied to board lifecycle/state).
The Planning view must surface the SAME workflow dropdown as the Board, in the SAME location (the Header `#header-workflow-slot`). Board owns its own switcher only while the board is active, so Planning needs a self-contained mirror that tracks local selection and portals the identical `board-workflow-toolbar > board-workflow-selector > WorkflowSwitcher` markup into the header slot. We intentionally do NOT import Board (the board switcher is tied to board lifecycle/state).
Self-contained replication of Board's board-workflows fetch/cache/SSE-refresh path (Board.tsx ~370-470, ~607-637): refresh on mount, visibility/focus, and `workflow:created|updated|deleted` SSE, guarded by a monotonic sequence ref and persisted via the shared session cache. Gate render exactly like Board: only show when there is something to switch (workflow mode on AND >= 2 workflow options).
FNXC:Workflows 2026-06-22-17:00:
The board-workflows fetch/cache/SSE-refresh path (refresh on mount, visibility/focus, and `workflow:created|updated|deleted` SSE, sequence-guarded and session-cached) now lives in the shared `useBoardWorkflows` hook used by Board too. This slot keeps only its header-portal poll and the render gate: only show when there is something to switch (workflow mode on AND >= 2 workflow options).
*/
interface PlanningWorkflowSwitcherSlotProps {
@@ -25,12 +24,13 @@ interface PlanningWorkflowSwitcherSlotProps {
const EMPTY_COUNTS: Map<string, WorkflowStatusCounts> = new Map();
export function PlanningWorkflowSwitcherSlot({ projectId, onOpenWorkflowEditor, onCreateWorkflow }: PlanningWorkflowSwitcherSlotProps) {
const [boardWorkflowsState, setBoardWorkflowsState] = useState<{ projectId?: string; payload: BoardWorkflowsPayload } | null>(() => {
const cached = readBoardWorkflowsCache(projectId);
return cached ? { projectId, payload: cached } : null;
});
const boardWorkflows = boardWorkflowsState?.projectId === projectId && boardWorkflowsState ? boardWorkflowsState.payload : null;
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null>(null);
const {
workflowMode,
workflowOptions,
selectedWorkflow,
setSelectedWorkflowId,
refreshBoardWorkflows,
} = useBoardWorkflows({ projectId });
// Header may mount its workflow slot after this component, so resolve it on mount
// and re-resolve via a short polling effect until it attaches. Render only via portal.
@@ -39,57 +39,6 @@ export function PlanningWorkflowSwitcherSlot({ projectId, onOpenWorkflowEditor,
return document.getElementById("header-workflow-slot");
});
// Stale-response guard: drop out-of-order board-workflows responses.
const boardWorkflowsFetchSeqRef = useRef(0);
useEffect(() => {
const cached = readBoardWorkflowsCache(projectId);
setBoardWorkflowsState(cached ? { projectId, payload: cached } : null);
}, [projectId]);
/*
FNXC:PlanningWorkflowSwitcher 2026-06-22-00:00:
Opening the switcher must refresh the payload because task workflow assignment changes do not emit workflow-definition SSE events. Shared by mount, visibility/focus, and workflow-definition SSE refetches so the stale guard and cache writes stay identical to Board.
*/
const refreshBoardWorkflows = useCallback(() => {
const seq = ++boardWorkflowsFetchSeqRef.current;
fetchBoardWorkflows(projectId)
.then((payload) => {
if (seq === boardWorkflowsFetchSeqRef.current) {
setBoardWorkflowsState({ projectId, payload });
writeBoardWorkflowsCache(projectId, payload);
}
})
.catch(() => {
if (seq === boardWorkflowsFetchSeqRef.current) {
setBoardWorkflowsState({ projectId, payload: { flagEnabled: false, defaultWorkflowId: "builtin:coding", workflows: [], taskWorkflowIds: {} } });
}
});
}, [projectId]);
useEffect(() => {
refreshBoardWorkflows();
const onVisible = () => {
if (typeof document === "undefined" || document.visibilityState === "visible") refreshBoardWorkflows();
};
if (typeof document !== "undefined") document.addEventListener("visibilitychange", onVisible);
if (typeof window !== "undefined") window.addEventListener("focus", onVisible);
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
const unsubscribe = subscribeSse(`/api/events${query}`, {
events: {
"workflow:created": refreshBoardWorkflows,
"workflow:updated": refreshBoardWorkflows,
"workflow:deleted": refreshBoardWorkflows,
},
});
return () => {
boardWorkflowsFetchSeqRef.current++;
if (typeof document !== "undefined") document.removeEventListener("visibilitychange", onVisible);
if (typeof window !== "undefined") window.removeEventListener("focus", onVisible);
unsubscribe();
};
}, [projectId, refreshBoardWorkflows]);
// Attach to the header slot once the Header mounts it. Poll briefly until present.
useEffect(() => {
if (typeof document === "undefined") return;
@@ -111,36 +60,6 @@ export function PlanningWorkflowSwitcherSlot({ projectId, onOpenWorkflowEditor,
return () => window.clearInterval(interval);
}, []);
const flagOn = boardWorkflows?.flagEnabled === true;
const workflowMode = flagOn && Boolean(boardWorkflows?.workflows.length);
const workflowOptions = useMemo<BoardWorkflowDefinition[]>(() => {
if (!workflowMode || !boardWorkflows) return [];
return [...boardWorkflows.workflows].sort((a, b) => {
if (a.id === boardWorkflows.defaultWorkflowId) return -1;
if (b.id === boardWorkflows.defaultWorkflowId) return 1;
return a.name.localeCompare(b.name);
});
}, [boardWorkflows, workflowMode]);
const selectedWorkflow = useMemo<BoardWorkflowDefinition | null>(() => {
if (!workflowMode) return null;
return workflowOptions.find((workflow) => workflow.id === selectedWorkflowId)
?? workflowOptions.find((workflow) => workflow.id === boardWorkflows?.defaultWorkflowId)
?? workflowOptions[0]
?? null;
}, [boardWorkflows?.defaultWorkflowId, selectedWorkflowId, workflowMode, workflowOptions]);
useEffect(() => {
if (!workflowMode) {
setSelectedWorkflowId(null);
return;
}
if (selectedWorkflow && selectedWorkflow.id !== selectedWorkflowId) {
setSelectedWorkflowId(selectedWorkflow.id);
}
}, [selectedWorkflow, selectedWorkflowId, workflowMode]);
// Gate: only render when there is something to switch (>= 2 options), matching Board's "show only when switchable" intent.
if (!workflowMode || !selectedWorkflow || workflowOptions.length < 2 || !headerWorkflowSlot) {
return null;

View File

@@ -159,19 +159,98 @@ The hosted view is a flex child of the dock body; without min-height:0 it cannot
min-block-size: 0;
}
/*
FNXC:RightDock 2026-06-22-17:40:
The right-dock pop-out is a FLOATING, DRAGGABLE, RESIZABLE, NON-BLOCKING window. The user positions it anywhere on screen and keeps using the app behind it. This overlay MUST out-specify the base `.modal-overlay` (which dims the page with a backdrop + blur). Both base and override are single-class selectors, so if styles.css loads after this file the dim/blur would win and the page would fade; qualify with `.modal-overlay` (two classes) so the pop-out reliably keeps a 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`.
*/
.modal-overlay.right-dock-expand-modal-overlay {
align-items: stretch;
justify-content: flex-start;
padding: 0;
background: transparent;
backdrop-filter: none;
pointer-events: none;
}
.right-dock-expand-modal {
display: flex;
flex-direction: column;
width: min(90vw, calc(var(--space-2xl) * 36));
height: min(85vh, calc(var(--space-2xl) * 24));
min-width: min(90vw, calc(var(--space-2xl) * 12));
min-height: min(85vh, calc(var(--space-2xl) * 10));
max-width: 95vw;
max-height: 90vh;
resize: both;
overflow: hidden;
}
/*
FNXC:RightDock 2026-06-22-17:40:
Floating panel positioned by state-driven inline `left/top/width/height`. min/max keep content usable and the panel on-screen. `resize: none` because resizing is handled by the corner/edge handles (the native grip conflicts with the drag/resize pointer handlers). `pointer-events: auto` re-enables interaction on the panel only.
*/
.right-dock-expand-modal--floating {
position: fixed;
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));
resize: none;
pointer-events: auto;
box-shadow: var(--shadow-xl);
}
/*
FNXC:RightDock 2026-06-22-17:40:
Header is the drag handle; grab/grabbing cursor and non-selectable text signal and protect the drag.
*/
.right-dock-expand-modal__header--draggable {
cursor: grab;
user-select: none;
}
.right-dock-expand-modal__header--draggable:active {
cursor: grabbing;
}
/*
FNXC:RightDock 2026-06-22-17:40:
Edge + corner resize handles. touch-action:none keeps the drag from being hijacked by scroll/gestures so resizing stays smooth.
*/
.right-dock-expand-resize-handle {
position: absolute;
z-index: 2;
touch-action: none;
}
.right-dock-expand-resize-handle--n,
.right-dock-expand-resize-handle--s {
left: var(--space-sm);
right: var(--space-sm);
height: var(--space-sm);
cursor: ns-resize;
}
.right-dock-expand-resize-handle--n { top: 0; }
.right-dock-expand-resize-handle--s { bottom: 0; }
.right-dock-expand-resize-handle--e,
.right-dock-expand-resize-handle--w {
top: var(--space-sm);
bottom: var(--space-sm);
width: var(--space-sm);
cursor: ew-resize;
}
.right-dock-expand-resize-handle--e { right: 0; }
.right-dock-expand-resize-handle--w { left: 0; }
.right-dock-expand-resize-handle--ne,
.right-dock-expand-resize-handle--nw,
.right-dock-expand-resize-handle--se,
.right-dock-expand-resize-handle--sw {
width: var(--space-lg);
height: var(--space-lg);
}
.right-dock-expand-resize-handle--ne { top: 0; right: 0; cursor: nesw-resize; }
.right-dock-expand-resize-handle--nw { top: 0; left: 0; cursor: nwse-resize; }
.right-dock-expand-resize-handle--se { bottom: 0; right: 0; cursor: nwse-resize; }
.right-dock-expand-resize-handle--sw { bottom: 0; left: 0; cursor: nesw-resize; }
.right-dock-expand-modal__header,
.right-dock-expand-modal__title {
display: flex;

View File

@@ -1,11 +1,99 @@
import { useEffect, useRef, type RefObject } from "react";
import { useCallback, useEffect, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent, type RefObject } from "react";
import { Maximize2, X } from "lucide-react";
import { findOverflowViewEntry, type OverflowViewEntry, type OverflowViewKey, type OverflowViewRenderProps, type OverflowViewVisibilityOptions } from "./overflowViewRegistry";
import { useModalResizePersist } from "../hooks/useModalResizePersist";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
import "./RightDock.css";
const RIGHT_DOCK_EXPAND_MODAL_SIZE_STORAGE_KEY = "fusion:right-dock-expand-modal-size";
const RIGHT_DOCK_EXPAND_MODAL_POSITION_STORAGE_KEY = "fusion:right-dock-expand-modal-position";
/*
FNXC:RightDock 2026-06-22-17:40:
The right-dock pop-out is a FLOATING, DRAGGABLE, RESIZABLE, NON-BLOCKING window. The user positions it anywhere on screen and keeps using the app behind it: NO background dimming/blur, and the overlay is `pointer-events: none` so behind-clicks pass through (only the panel re-enables `pointer-events: auto`). Because behind-clicks never reach the overlay there is no overlay click-to-dismiss; the explicit header close button is the only dismissal. This mirrors TerminalModal's floating mode (drag the header, resize from the corners, rAF-batched updates, a single dragTeardownRef invoked on pointerup/pointercancel AND on unmount so no document listeners leak).
*/
const EXPAND_DEFAULT_WIDTH = 960;
const EXPAND_DEFAULT_HEIGHT = 600;
const EXPAND_MIN_WIDTH = 360;
const EXPAND_MIN_HEIGHT = 280;
const EXPAND_VIEWPORT_PADDING = 16;
interface ExpandSize {
width: number;
height: number;
}
interface ExpandPosition {
x: number;
y: number;
}
function clampExpandSize(size: ExpandSize): ExpandSize {
if (typeof window === "undefined") return size;
return {
width: Math.min(Math.max(size.width, EXPAND_MIN_WIDTH), Math.max(EXPAND_MIN_WIDTH, window.innerWidth - EXPAND_VIEWPORT_PADDING * 2)),
height: Math.min(Math.max(size.height, EXPAND_MIN_HEIGHT), Math.max(EXPAND_MIN_HEIGHT, window.innerHeight - EXPAND_VIEWPORT_PADDING * 2)),
};
}
function clampExpandPosition(position: ExpandPosition, size: ExpandSize): ExpandPosition {
if (typeof window === "undefined") return position;
return {
x: Math.min(Math.max(position.x, EXPAND_VIEWPORT_PADDING), Math.max(EXPAND_VIEWPORT_PADDING, window.innerWidth - size.width - EXPAND_VIEWPORT_PADDING)),
y: Math.min(Math.max(position.y, EXPAND_VIEWPORT_PADDING), Math.max(EXPAND_VIEWPORT_PADDING, window.innerHeight - size.height - EXPAND_VIEWPORT_PADDING)),
};
}
function readExpandSize(): ExpandSize {
if (typeof window === "undefined") return { width: EXPAND_DEFAULT_WIDTH, height: EXPAND_DEFAULT_HEIGHT };
try {
const raw = window.localStorage.getItem(RIGHT_DOCK_EXPAND_MODAL_SIZE_STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw) as Partial<ExpandSize>;
if (typeof parsed.width === "number" && typeof parsed.height === "number") {
return clampExpandSize({ width: parsed.width, height: parsed.height });
}
}
} catch {
// ignore corrupted persisted size
}
return clampExpandSize({ width: EXPAND_DEFAULT_WIDTH, height: EXPAND_DEFAULT_HEIGHT });
}
function writeExpandSize(size: ExpandSize): ExpandSize {
const clamped = clampExpandSize(size);
if (typeof window !== "undefined") {
window.localStorage.setItem(RIGHT_DOCK_EXPAND_MODAL_SIZE_STORAGE_KEY, JSON.stringify(clamped));
}
return clamped;
}
function readExpandPosition(size: ExpandSize): ExpandPosition {
if (typeof window === "undefined") return { x: EXPAND_VIEWPORT_PADDING, y: EXPAND_VIEWPORT_PADDING };
try {
const raw = window.localStorage.getItem(RIGHT_DOCK_EXPAND_MODAL_POSITION_STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw) as Partial<ExpandPosition>;
if (typeof parsed.x === "number" && typeof parsed.y === "number") {
return clampExpandPosition({ x: parsed.x, y: parsed.y }, size);
}
}
} catch {
// ignore corrupted persisted position
}
// Default: roughly centered.
return clampExpandPosition({ x: (window.innerWidth - size.width) / 2, y: (window.innerHeight - size.height) / 2 }, size);
}
function writeExpandPosition(position: ExpandPosition, size: ExpandSize): ExpandPosition {
const clamped = clampExpandPosition(position, size);
if (typeof window !== "undefined") {
window.localStorage.setItem(RIGHT_DOCK_EXPAND_MODAL_POSITION_STORAGE_KEY, JSON.stringify(clamped));
}
return clamped;
}
type ExpandResizeDirection = "n" | "s" | "e" | "w" | "ne" | "nw" | "se" | "sw";
const EXPAND_RESIZE_DIRECTIONS: ExpandResizeDirection[] = ["n", "s", "e", "w", "ne", "nw", "se", "sw"];
type RenderableOverflowViewEntry = OverflowViewEntry & Required<Pick<OverflowViewEntry, "render">>;
@@ -31,15 +119,149 @@ export function RightDockExpandModal({
onClose,
returnFocusRef,
}: RightDockExpandModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
const resolvedEntry = viewKey ? findOverflowViewEntry(viewKey, visibilityOptions) : undefined;
const entry: RenderableOverflowViewEntry | undefined = resolvedEntry?.render ? { ...resolvedEntry, render: resolvedEntry.render } : undefined;
const closeAndRestoreFocus = () => {
const [size, setSizeState] = useState<ExpandSize>(() => readExpandSize());
const [position, setPositionState] = useState<ExpandPosition>(() => readExpandPosition(readExpandSize()));
/*
FNXC:RightDock 2026-06-22-17:40:
A single active-drag teardown lives here (drag OR resize). pointerup/pointercancel run it, and the unmount effect runs it too, so a drag interrupted by close/unmount never leaks document pointer listeners or a pending rAF — this was a P1 in review of the terminal floating window.
*/
const dragTeardownRef = useRef<(() => void) | null>(null);
const persistSize = useCallback((next: ExpandSize) => {
setSizeState(writeExpandSize(next));
}, []);
const persistPosition = useCallback((next: ExpandPosition, withSize: ExpandSize) => {
setPositionState(writeExpandPosition(next, withSize));
}, []);
const closeAndRestoreFocus = useCallback(() => {
onClose();
window.setTimeout(() => returnFocusRef?.current?.focus(), 0);
};
const overlayDismissProps = useOverlayDismiss(closeAndRestoreFocus);
useModalResizePersist(modalRef, Boolean(entry), RIGHT_DOCK_EXPAND_MODAL_SIZE_STORAGE_KEY);
}, [onClose, returnFocusRef]);
/*
FNXC:RightDock 2026-06-22-17:40:
Header drag: pointerdown on the title bar moves the panel via state-driven `position: fixed; left/top`. Pointer capture keeps the drag alive past the header bounds, updates are rAF-batched so the move stays smooth, and the panel is clamped on-screen. Clicks on the close button are excluded so dragging never swallows the close.
*/
const handleFloatingDragPointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
if ((event.target as HTMLElement).closest("button")) return;
event.preventDefault();
event.currentTarget.setPointerCapture?.(event.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) => {
latest = { x: startPosition.x + moveEvent.clientX - startX, y: startPosition.y + moveEvent.clientY - startY };
if (frame) return;
frame = requestAnimationFrame(() => {
frame = 0;
setPositionState(clampExpandPosition(latest, currentSize));
});
};
const handlePointerUp = () => {
if (frame) cancelAnimationFrame(frame);
persistPosition(latest, currentSize);
document.body.style.userSelect = previousUserSelect;
document.removeEventListener("pointermove", handlePointerMove);
document.removeEventListener("pointerup", handlePointerUp);
document.removeEventListener("pointercancel", handlePointerUp);
dragTeardownRef.current = null;
};
// FNXC:RightDock 2026-06-22-17:40: Close/unmount-mid-drag teardown cancels the rAF and drops the listeners without persisting a partial move.
dragTeardownRef.current = () => {
if (frame) cancelAnimationFrame(frame);
document.body.style.userSelect = previousUserSelect;
document.removeEventListener("pointermove", handlePointerMove);
document.removeEventListener("pointerup", handlePointerUp);
document.removeEventListener("pointercancel", handlePointerUp);
dragTeardownRef.current = null;
};
document.addEventListener("pointermove", handlePointerMove);
document.addEventListener("pointerup", handlePointerUp);
document.addEventListener("pointercancel", handlePointerUp);
}, [persistPosition, position, size]);
/*
FNXC:RightDock 2026-06-22-17:40:
Corner/edge resize: pointer events resize the panel, rAF-batched for smoothness. 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: ExpandResizeDirection) => {
event.preventDefault();
event.stopPropagation();
event.currentTarget.setPointerCapture?.(event.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) => {
const dx = moveEvent.clientX - startX;
const dy = moveEvent.clientY - startY;
const nextSize = clampExpandSize({
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(clampExpandPosition(latestPosition, latestSize));
});
};
const handlePointerUp = () => {
if (frame) cancelAnimationFrame(frame);
persistSize(latestSize);
persistPosition(latestPosition, latestSize);
document.body.style.userSelect = previousUserSelect;
document.removeEventListener("pointermove", handlePointerMove);
document.removeEventListener("pointerup", handlePointerUp);
document.removeEventListener("pointercancel", handlePointerUp);
dragTeardownRef.current = null;
};
// FNXC:RightDock 2026-06-22-17:40: Close/unmount-mid-resize teardown.
dragTeardownRef.current = () => {
if (frame) cancelAnimationFrame(frame);
document.body.style.userSelect = previousUserSelect;
document.removeEventListener("pointermove", handlePointerMove);
document.removeEventListener("pointerup", handlePointerUp);
document.removeEventListener("pointercancel", handlePointerUp);
dragTeardownRef.current = null;
};
document.addEventListener("pointermove", handlePointerMove);
document.addEventListener("pointerup", handlePointerUp);
document.addEventListener("pointercancel", handlePointerUp);
}, [persistPosition, persistSize, position, size]);
// FNXC:RightDock 2026-06-22-17:40: Run any active drag/resize teardown on unmount so document pointer listeners + a pending rAF never outlive the modal.
useEffect(() => () => dragTeardownRef.current?.(), []);
useEffect(() => {
if (entry) return undefined;
@@ -54,10 +276,31 @@ export function RightDockExpandModal({
const Icon = entry.icon;
const panelStyle = {
left: `${position.x}px`,
top: `${position.y}px`,
width: `${size.width}px`,
height: `${size.height}px`,
} as CSSProperties;
return (
<div className="modal-overlay open" {...overlayDismissProps} role="dialog" aria-modal="true" aria-label={`${entry.label} expanded`} data-testid="right-dock-expand-modal">
<div className="modal right-dock-expand-modal" ref={modalRef}>
<div className="modal-header right-dock-expand-modal__header">
<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}>
{EXPAND_RESIZE_DIRECTIONS.map((direction) => (
<div
key={direction}
className={`right-dock-expand-resize-handle right-dock-expand-resize-handle--${direction}`}
data-testid={`right-dock-expand-resize-${direction}`}
role="separator"
aria-label="Resize expanded right dock window"
onPointerDown={(event) => handleFloatingResizePointerDown(event, direction)}
/>
))}
<div
className="modal-header right-dock-expand-modal__header right-dock-expand-modal__header--draggable"
data-testid="right-dock-expand-drag-handle"
onPointerDown={handleFloatingDragPointerDown}
>
<div className="right-dock-expand-modal__title">
<Maximize2 size={16} />
<Icon size={16} />

View File

@@ -18,6 +18,7 @@ import { RoutineEditor } from "./RoutineEditor";
import type { ToastType } from "../hooks/useToast";
import { useModalResizePersist } from "../hooks/useModalResizePersist";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
import { useEmbeddedPresentation, type ModalPresentation } from "../hooks/useEmbeddedPresentation";
/** Polling interval for auto-refreshing the schedule/routine list (30 seconds). */
const POLL_INTERVAL_MS = 30_000;
@@ -39,12 +40,12 @@ interface ScheduledTasksModalProps {
/** Optional project ID for project-scoped scheduling. When provided, scope defaults to "project". */
projectId?: string;
/** Presentation surface. "modal" (default) renders a fixed overlay; "embedded" renders inline in the main content area. */
presentation?: "modal" | "embedded";
presentation?: ModalPresentation;
}
export function ScheduledTasksModal({ onClose, addToast, projectId, presentation = "modal" }: ScheduledTasksModalProps) {
const { t } = useTranslation("app");
const isEmbedded = presentation === "embedded";
const { isEmbedded, resizePersistEnabled, escapeEnabled } = useEmbeddedPresentation(presentation);
// Scope state: defaults to "project" when projectId exists, else "global"
const [activeScope, setActiveScope] = useState<SchedulingScope>(() => projectId ? "project" : "global");
@@ -59,7 +60,7 @@ export function ScheduledTasksModal({ onClose, addToast, projectId, presentation
const modalRef = useRef<HTMLDivElement>(null);
// Resize-persist is a modal-only affordance; the embedded view fills its host and never resizes.
useModalResizePersist(modalRef, !isEmbedded, "fusion:automation-modal-size");
useModalResizePersist(modalRef, resizePersistEnabled, "fusion:automation-modal-size");
// Build scope options for API calls
const scopeOptions = useMemo(() => ({
@@ -108,7 +109,7 @@ export function ScheduledTasksModal({ onClose, addToast, projectId, presentation
// Close on Escape (only when not in a sub-form).
// FNXC:AutomationsEmbedded 2026-06-22-00:00: Escape-to-close is a modal-only affordance; the embedded view lives in the main content area and must not hijack Escape.
useEffect(() => {
if (isEmbedded) return;
if (!escapeEnabled) return;
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
if (routineView !== "list") {
@@ -121,7 +122,7 @@ export function ScheduledTasksModal({ onClose, addToast, projectId, presentation
};
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}, [onClose, routineView, isEmbedded]);
}, [onClose, routineView, escapeEnabled]);
const overlayDismissProps = useOverlayDismiss(onClose);

View File

@@ -2025,33 +2025,33 @@ The previous bespoke rules here hid the tab labels (icon-only) and used a crampe
flex-direction: column;
}
/*
FNXC:GitManager 2026-06-22-17:30:
The dock tab strip WRAPS so every section is visible at once (no single-tab horizontal swipe). Tabs take intrinsic width — width:auto overrides the base .gm-nav-item width:100% that otherwise made each tab fill the row (one per swipe) — and are compact icon+label so all ~7 sections fit across 2-3 wrapped rows.
*/
.gm-modal--embedded .gm-sidebar {
flex: 0 0 auto;
flex-direction: row;
flex-wrap: wrap;
width: 100%;
min-width: 0;
min-height: calc(var(--space-2xl) + var(--space-md));
border-right: none;
border-bottom: 1px solid var(--border);
overflow-x: auto;
overflow-y: hidden;
touch-action: pan-x pan-y;
-webkit-overflow-scrolling: touch;
overscroll-behavior-x: contain;
overflow: visible;
padding: var(--space-xs) var(--space-sm);
gap: var(--space-xs);
}
.gm-modal--embedded .gm-nav-item {
flex: 0 0 auto;
width: auto;
flex-direction: column;
gap: calc(var(--space-xs) / 2);
padding: var(--space-xs) var(--space-sm);
border-left: none;
border-bottom: 2px solid transparent;
font-size: var(--font-size-xs);
min-width: calc(var(--space-2xl) + var(--space-xl));
min-height: calc(var(--space-xl) + var(--space-sm));
min-width: calc(var(--space-2xl) + var(--space-sm));
text-align: center;
justify-content: center;
}

View File

@@ -56,6 +56,7 @@ import { appendTokenQuery, OAUTH_RELOGIN_SUCCESS_EVENT } from "../auth";
import { useConfirm } from "../hooks/useConfirm";
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { useEmbeddedPresentation, type ModalPresentation } from "../hooks/useEmbeddedPresentation";
import { useNodes } from "../hooks/useNodes";
import { useViewportMode } from "../hooks/useViewportMode";
import { useWorktrunkInstallStatus } from "../hooks/useWorktrunkInstallStatus";
@@ -382,7 +383,7 @@ interface SettingsModalProps {
FNXC:Settings 2026-06-22-00:00:
Settings renders both as a dialog overlay (presentation="modal", default) and as an embedded main-content view (presentation="embedded"). Embedded mode drops the fixed overlay backdrop and modal close button, fills the host pane, and disables modal-only behaviors (scroll lock, escape-to-close, resize-persist, overlay click-dismiss). The modal path is kept byte-identical for non-navigation callers (e.g. mobile/right-dock).
*/
presentation?: "modal" | "embedded";
presentation?: ModalPresentation;
}
/** Adapter descriptor served by GET /api/cli-agents (U15). */
@@ -631,14 +632,14 @@ export function SettingsModal({
onOpenWorkflowSettings,
presentation = "modal",
}: SettingsModalProps) {
const isEmbedded = presentation === "embedded";
const { isEmbedded, scrollLockEnabled, resizePersistEnabled, escapeEnabled, overlayDismissEnabled } = useEmbeddedPresentation(presentation);
const { t } = useTranslation("app");
const { confirm } = useConfirm();
const worktrunkInstall = useWorktrunkInstallStatus(projectId);
const worktrunkInstallVerified = worktrunkInstall.status === "installed";
const viewportMode = useViewportMode();
// Modal-only: lock background scroll on mobile. Embedded view owns its own scroll region.
useMobileScrollLock(!isEmbedded);
useMobileScrollLock(scrollLockEnabled);
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } = useMobileKeyboard({
enabled: viewportMode === "mobile",
});
@@ -656,7 +657,7 @@ export function SettingsModal({
workflowLaneSaverRef.current = saver;
}, []);
// Modal-only: persist user-resized dialog dimensions. Embedded view fills its host and is not resizable.
useModalResizePersist(modalRef, !isEmbedded, "fusion:settings-modal-size");
useModalResizePersist(modalRef, resizePersistEnabled, "fusion:settings-modal-size");
const sessionBannersHidden = useSessionBannersHidden();
const [form, setForm] = useState<SettingsFormState>({
maxConcurrent: 2,
@@ -2004,17 +2005,17 @@ export function SettingsModal({
// Modal-only: Escape dismisses the dialog. Embedded view is navigated away via the left sidebar, not Escape.
useEffect(() => {
if (isEmbedded) return;
if (!escapeEnabled) return;
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}, [onClose, isEmbedded]);
}, [onClose, escapeEnabled]);
// Modal-only: backdrop click dismisses. Embedded view has no overlay backdrop.
const modalOverlayDismissProps = useOverlayDismiss(onClose);
const overlayDismissProps = isEmbedded ? {} : modalOverlayDismissProps;
const overlayDismissProps = overlayDismissEnabled ? modalOverlayDismissProps : {};
/**
* Lane status types:

View File

@@ -251,8 +251,9 @@ export function SkillsView({ projectId, addToast, onClose }: SkillsViewProps) {
>
<X size={16} />
</button>
{/* FNXC:Skills 2026-06-22-17:35: Refresh uses plain btn btn-sm (no touch-target min-height) so it matches the Mailbox Compose button height (also btn btn-sm). */}
<button
className="btn btn-sm touch-target"
className="btn btn-sm"
onClick={() => void loadDiscoveredSkills()}
disabled={isLoadingDiscovered}
>

View File

@@ -807,15 +807,19 @@ FN-6659 keeps the loaded symbols @font-face out of xterm's measured font option
}
}
/*
FNXC:Terminal 2026-06-22-17:15:
The shortcut bar (modifier keys + arrow keys) must sit on ONE line, not stack into separate rows. The panel no longer wraps; the modifier-row and arrow-row are inline (intrinsic width, no 100% / margin-bottom), and the panel scrolls horizontally if the buttons exceed the width.
*/
.terminal-shortcut-panel {
display: flex;
flex-wrap: wrap;
flex-wrap: nowrap;
align-items: center;
gap: var(--space-xs);
padding: var(--space-xs) var(--space-sm);
background: var(--surface);
border-top: 1px solid var(--border);
max-height: calc(var(--space-2xl) + var(--space-xl) + var(--space-lg));
overflow-y: auto;
overflow-x: auto;
}
.terminal-shortcut-modifier-row,
@@ -823,12 +827,7 @@ FN-6659 keeps the loaded symbols @font-face out of xterm's measured font option
display: flex;
align-items: center;
gap: var(--space-xs);
width: 100%;
margin-bottom: var(--space-xs);
}
.terminal-shortcut-arrow-row {
justify-content: center;
flex: 0 0 auto;
}
.terminal-shortcut-btn {

View File

@@ -11,12 +11,10 @@ import {
ChevronLeft,
Loader2,
ListChecks,
CheckSquare,
Bot,
PlusCircle,
Lightbulb,
} from "lucide-react";
import { ViewHeader } from "./ViewHeader";
import { getErrorMessage, type Task, type TaskCreateInput, type TodoItem, type TodoList } from "@fusion/core";
import { createTask, fetchAgents } from "../api";
import type { Agent } from "../api";
@@ -319,18 +317,10 @@ export function TodoView({
}, [projectId, addToast, agents, onTaskCreated, t]);
/*
FNXC:Todos 2026-06-22-01:00:
Migrated to the shared ViewHeader (CheckSquare icon, matching the left-sidebar nav) so Todos reads consistently with the other main-content views. The descriptive subtitle moves into the actions slot so it stays visible while the icon + 1.125rem title come from ViewHeader. The header sits above the two-pane/stack layout (flex-shrink:0); the layout owns its own scroll.
FNXC:Todos 2026-06-22-17:45:
The redundant "Todos" title + "Manage reusable todo lists" subtitle are removed — Todos lives in the right dock (and left-sidebar nav) which already labels the view, so a repeated in-view header is noise. The list/detail layout owns the full height with no header above it.
*/
const header = (
<ViewHeader
icon={CheckSquare}
title={t("todo.todos", "Todos")}
actions={(
<p className="todo-view-subtitle">{t("todo.manageDescription", "Manage reusable todo lists for your project.")}</p>
)}
/>
);
const header = null;
if (loading) {
return (

View File

@@ -51,6 +51,7 @@ FNXC:i18n-Localize 2026-06-20-00:00:
FN-6770 localizes this workflow surface through t() and authored en catalog keys so hardcoded user-facing copy does not need a lint.ignore deferral.
*/
import { useModalResizePersist } from "../hooks/useModalResizePersist";
import { useEmbeddedPresentation, type ModalPresentation } from "../hooks/useEmbeddedPresentation";
import { useAppSettings } from "../hooks/useAppSettings";
import { isMobileViewport, useViewportMode } from "../hooks/useViewportMode";
import { workflowNodeTypes, type WorkflowFlowNodeData, type WorkflowEditorNodeKind } from "./nodes/WorkflowNodeTypes";
@@ -207,7 +208,7 @@ interface WorkflowNodeEditorProps {
click) so it reads as a persistent view rather than a dismissible dialog.
The modal path stays byte-identical when presentation is "modal"/undefined.
*/
presentation?: "modal" | "embedded";
presentation?: ModalPresentation;
}
let nodeSeq = 0;
@@ -4657,11 +4658,11 @@ export function WorkflowNodeEditor({
presentation = "modal",
}: WorkflowNodeEditorProps) {
const modalRef = useRef<HTMLDivElement>(null);
const isEmbedded = presentation === "embedded";
const { isEmbedded, resizePersistEnabled } = useEmbeddedPresentation(presentation);
// FNXC:WorkflowEditorEmbedding 2026-06-22-00:00:
// Size persistence + native resize are modal-only; an embedded view fills its
// host panel (width/height:100%) so persisting a saved pixel size is wrong.
useModalResizePersist(modalRef, isOpen && !isEmbedded, "fusion:workflow-node-editor-size");
useModalResizePersist(modalRef, isOpen && resizePersistEnabled, "fusion:workflow-node-editor-size");
if (!isOpen) return null;
return (
<ReactFlowProvider>

View File

@@ -117,6 +117,54 @@ describe("GitHubImportModal", () => {
expect(screen.queryByText("Import from GitHub")).toBeNull();
});
// FNXC:EmbeddedPresentation 2026-06-22-12:00:
// presentation="embedded" was a zero-coverage branch. Assert the embedded contract via useEmbeddedPresentation:
// embedded root class present, no fixed .modal-overlay backdrop, no close button, and Escape does NOT dismiss.
describe("embedded presentation", () => {
it("renders the embedded root class with no modal overlay or close button", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([]);
const { container } = render(
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} presentation="embedded" />,
);
await waitFor(() => {
expect(screen.getByText("Import Tasks")).toBeTruthy();
});
expect(container.querySelector(".github-import-embedded")).not.toBeNull();
expect(container.querySelector(".github-import-modal--embedded")).not.toBeNull();
// No fixed full-screen overlay backdrop, and no modal-header / close button in embedded mode.
expect(container.querySelector(".modal-overlay")).toBeNull();
expect(screen.queryByText("Import from GitHub")).toBeNull();
expect(container.querySelector(".github-import-modal__header")).toBeNull();
});
it("does not dismiss on Escape in embedded mode", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([]);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} presentation="embedded" />);
await waitFor(() => {
expect(screen.getByText("Import Tasks")).toBeTruthy();
});
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).not.toHaveBeenCalled();
});
it("keeps the modal overlay and Escape-to-close in modal mode", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([]);
const { container } = render(
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />,
);
await waitFor(() => {
expect(screen.getByText("Import from GitHub")).toBeTruthy();
});
expect(container.querySelector(".modal-overlay")).not.toBeNull();
expect(container.querySelector(".github-import-modal--embedded")).toBeNull();
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalled();
});
});
it("renders compact toolbar and two-pane layout", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);

View File

@@ -1122,28 +1122,75 @@ describe("ListView", () => {
it("supports keyboard resizing on the desktop split-pane handle", async () => {
const viewportSpy = mockDesktopViewport();
const clientWidthSpy = vi.spyOn(window.HTMLElement.prototype, "clientWidth", "get").mockReturnValue(1000);
localStorage.setItem(scopedStorageKey("kb-dashboard-list-sidebar-width"), "80");
// Persisted below the 64px min clamps up to 64.
localStorage.setItem(scopedStorageKey("kb-dashboard-list-sidebar-width"), "40");
const tasks = [createMockTask({ id: "FN-001", title: "Task" })];
renderListView({ tasks });
await waitFor(() => expect(screen.getByTestId("list-split-sidebar")).toHaveStyle({ width: "120px" }));
await waitFor(() => expect(screen.getByTestId("list-split-sidebar")).toHaveStyle({ width: "64px" }));
const handle = screen.getByTestId("list-split-resize-handle");
const startWidth = Number(handle.getAttribute("aria-valuenow"));
expect(handle).toHaveAttribute("tabindex", "0");
expect(handle).toHaveAttribute("aria-valuemin", "120");
expect(Number(handle.getAttribute("aria-valuemax"))).toBeGreaterThanOrEqual(120);
expect(handle).toHaveAttribute("aria-valuemin", "64");
expect(Number(handle.getAttribute("aria-valuemax"))).toBeGreaterThanOrEqual(64);
fireEvent.keyDown(handle, { key: "ArrowRight" });
expect(Number(handle.getAttribute("aria-valuenow"))).toBeGreaterThan(startWidth);
fireEvent.keyDown(handle, { key: "Home" });
expect(handle).toHaveAttribute("aria-valuenow", "120");
expect(screen.getByTestId("list-split-sidebar")).toHaveStyle({ width: "120px" });
expect(handle).toHaveAttribute("aria-valuenow", "64");
expect(screen.getByTestId("list-split-sidebar")).toHaveStyle({ width: "64px" });
clientWidthSpy.mockRestore();
viewportSpy.mockRestore();
});
it("resizes the desktop split sidebar by dragging the handle (pointer)", async () => {
// FNXC:ListView 2026-06-22-18:00: Regression guard — dragging the resize handle must change the
// sidebar width live and not collapse to the min when the container measures non-zero.
const viewportSpy = mockDesktopViewport();
const rectSpy = vi
.spyOn(window.HTMLElement.prototype, "getBoundingClientRect")
.mockReturnValue({ left: 0, width: 1000, top: 0, right: 1000, bottom: 300, height: 300, x: 0, y: 0, toJSON() {} } as DOMRect);
const cwSpy = vi.spyOn(window.HTMLElement.prototype, "clientWidth", "get").mockReturnValue(1000);
localStorage.setItem(scopedStorageKey("kb-dashboard-list-sidebar-width"), "300");
const tasks = [createMockTask({ id: "FN-001", title: "Task" })];
renderListView({ tasks });
await waitFor(() => expect(screen.getByTestId("list-split-sidebar")).toHaveStyle({ width: "300px" }));
const handle = screen.getByTestId("list-split-resize-handle");
// Narrow the pane.
fireEvent.pointerDown(handle, { clientX: 300, pointerId: 1 });
fireEvent.pointerMove(window, { clientX: 250, pointerId: 1 });
await waitFor(() => expect(screen.getByTestId("list-split-sidebar")).toHaveStyle({ width: "250px" }));
// Widen the pane.
fireEvent.pointerMove(window, { clientX: 420, pointerId: 1 });
await waitFor(() => expect(screen.getByTestId("list-split-sidebar")).toHaveStyle({ width: "420px" }));
fireEvent.pointerUp(window, { pointerId: 1 });
rectSpy.mockRestore();
cwSpy.mockRestore();
viewportSpy.mockRestore();
});
it("does not collapse the split sidebar to the min when the container width is unmeasurable", async () => {
// FNXC:ListView 2026-06-22-18:00: A zero/unreliable container measurement must not force the
// persisted width down to the min clamp — that was the resize regression (pane snapped to 64px).
const viewportSpy = mockDesktopViewport();
const cwSpy = vi.spyOn(window.HTMLElement.prototype, "clientWidth", "get").mockReturnValue(0);
localStorage.setItem(scopedStorageKey("kb-dashboard-list-sidebar-width"), "300");
const tasks = [createMockTask({ id: "FN-001", title: "Task" })];
renderListView({ tasks });
// Width must be preserved (not collapsed to 64) while the container reports 0.
await new Promise((resolve) => setTimeout(resolve, 60));
expect(screen.getByTestId("list-split-sidebar")).toHaveStyle({ width: "300px" });
cwSpy.mockRestore();
viewportSpy.mockRestore();
});
it("does not render split-pane structure on mobile", () => {
const viewportSpy = mockMobileViewport();
const tasks = [createMockTask({ id: "FN-001", title: "Task" })];

View File

@@ -1794,11 +1794,12 @@ describe("MailboxView", () => {
const afterRight = Number(handle.getAttribute("aria-valuenow"));
expect(afterRight).toBeGreaterThanOrEqual(afterLeft);
// FNXC:Mailbox 2026-06-22-18:05: Home clamps to MAILBOX_SIDEBAR_MIN_WIDTH (locked at 180); End clamps to the container max ratio.
fireEvent.keyDown(handle, { key: "Home" });
expect(Number(handle.getAttribute("aria-valuenow"))).toBe(280);
expect(Number(handle.getAttribute("aria-valuenow"))).toBe(180);
fireEvent.keyDown(handle, { key: "End" });
expect(Number(handle.getAttribute("aria-valuenow"))).toBeGreaterThanOrEqual(280);
expect(Number(handle.getAttribute("aria-valuenow"))).toBeGreaterThanOrEqual(180);
});
it("persists and restores scoped mailbox sidebar width", async () => {
@@ -1859,7 +1860,8 @@ describe("MailboxView", () => {
it("defines desktop/tablet split-pane selectors under .mailbox-view scope", async () => {
const css = loadAllAppCss();
expect(css).toMatch(/\.mailbox-view\s+\.mailbox-split-layout\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*auto\s+auto\s+minmax\(0,\s*1fr\);[^}]*gap:\s*0;[^}]*min-height:\s*0;[^}]*\}/);
// FNXC:Mailbox 2026-06-22-18:05: split layout is a flex row so the list pane's inline width is honored (drag-resizable); grid `auto` tracks ignored it.
expect(css).toMatch(/\.mailbox-view\s+\.mailbox-split-layout\s*\{[^}]*display:\s*flex;[^}]*flex-direction:\s*row;[^}]*gap:\s*0;[^}]*min-height:\s*0;[^}]*\}/);
const splitPaneBlockMatch = css.match(/\.mailbox-view\s+\.mailbox-split-list-pane,\s*\n\.mailbox-view\s+\.mailbox-split-detail-pane\s*\{([^}]*)\}/);
expect(splitPaneBlockMatch).toBeTruthy();
@@ -1868,6 +1870,17 @@ describe("MailboxView", () => {
expect(splitPaneBlock).toContain("border: var(--btn-border-width) solid var(--border);");
expect(splitPaneBlock).toContain("background: var(--surface);");
// FNXC:Mailbox 2026-06-22-18:05: list pane fixed to inline width; detail pane fills remainder and may shrink below content.
const listPaneBlockMatch = css.match(/\.mailbox-view\s+\.mailbox-split-list-pane\s*\{([^}]*)\}/);
expect(listPaneBlockMatch).toBeTruthy();
expect(listPaneBlockMatch![1]).toContain("flex: 0 0 auto;");
// Match the standalone detail-pane rule (the one declaring `display: flex;`), not the shared border/background block.
const detailPaneBlockMatch = css.match(/\.mailbox-view\s+\.mailbox-split-detail-pane\s*\{([^}]*display:\s*flex;[^}]*)\}/);
expect(detailPaneBlockMatch).toBeTruthy();
expect(detailPaneBlockMatch![1]).toContain("flex: 1 1 auto;");
expect(detailPaneBlockMatch![1]).toContain("min-width: 0;");
const resizeHandleBlockMatch = css.match(/\.mailbox-view\s+\.mailbox-split-resize-handle\s*\{([^}]*)\}/);
expect(resizeHandleBlockMatch).toBeTruthy();
const resizeHandleBlock = resizeHandleBlockMatch![1];

View File

@@ -219,6 +219,15 @@ describe("RightDock", () => {
expect(screen.getByTestId("right-dock-expand-modal")).toBeInTheDocument();
expect(screen.getByTestId("right-dock-expand-body")).toBeInTheDocument();
/*
FNXC:RightDock 2026-06-22-17:40:
The pop-out is a floating, non-blocking window: the overlay carries the non-blocking class (transparent + pointer-events:none in CSS so behind-clicks pass through), a drag handle (header) exists, and the panel is the floating variant. There is no overlay click-to-dismiss; the explicit close button is the only dismissal.
*/
expect(screen.getByTestId("right-dock-expand-modal")).toHaveClass("right-dock-expand-modal-overlay");
expect(screen.getByTestId("right-dock-expand-modal")).toHaveAttribute("aria-modal", "false");
expect(screen.getByTestId("right-dock-expand-drag-handle")).toBeInTheDocument();
expect(screen.getByTestId("right-dock-expand-modal").querySelector(".right-dock-expand-modal--floating")).not.toBeNull();
expect(screen.getByTestId("right-dock-expand-resize-se")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("right-dock-expand-close"));
expect(onClose).toHaveBeenCalledTimes(1);
await new Promise((resolve) => window.setTimeout(resolve, 0));
@@ -254,6 +263,31 @@ describe("RightDock", () => {
});
});
it("drags the floating pop-out by its header and clamps + persists the new position", () => {
/*
FNXC:RightDock 2026-06-22-17:40:
Pointerdown on the header drag handle then pointermove on the document moves the panel via state-driven fixed left/top, and pointerup persists the clamped position. Assert the panel moved and that a position was persisted (clamped on-screen).
*/
render(
<RightDockExpandModal
viewKey="files"
renderProps={renderProps}
onClose={vi.fn()}
/>,
);
const handle = screen.getByTestId("right-dock-expand-drag-handle");
fireEvent.pointerDown(handle, { pointerId: 1, clientX: 100, clientY: 100 });
fireEvent.pointerMove(document, { pointerId: 1, clientX: 60, clientY: 140 });
fireEvent.pointerUp(document, { pointerId: 1, clientX: 60, clientY: 140 });
const persisted = window.localStorage.getItem("fusion:right-dock-expand-modal-position");
expect(persisted).not.toBeNull();
const parsed = JSON.parse(persisted as string) as { x: number; y: number };
expect(parsed.x).toBeGreaterThanOrEqual(0);
expect(parsed.y).toBeGreaterThanOrEqual(0);
});
it("fires expand for the currently selected inline entry", () => {
/*
FNXC:Navigation 2026-06-22-16:00:

View File

@@ -354,4 +354,35 @@ describe("ScheduledTasksModal", () => {
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalled();
});
// FNXC:EmbeddedPresentation 2026-06-22-12:00:
// presentation="embedded" was a zero-coverage branch. Assert the embedded contract via useEmbeddedPresentation:
// embedded root class present, no fixed .modal-overlay backdrop / dialog role / close button, and Escape does NOT dismiss.
describe("embedded presentation", () => {
it("renders the embedded root class with no modal overlay, dialog role, or close button", async () => {
const { container } = render(
<ScheduledTasksModal onClose={onClose} addToast={addToast} presentation="embedded" />,
);
await waitFor(() => {
expect(screen.getByText("No automations yet")).toBeDefined();
});
expect(screen.getByText("Automations")).toBeDefined();
expect(container.querySelector(".automations-embedded")).not.toBeNull();
// No fixed overlay backdrop, no dialog role, no modal close button in embedded mode.
expect(container.querySelector(".modal-overlay")).toBeNull();
expect(screen.queryByRole("dialog")).toBeNull();
expect(screen.queryByRole("button", { name: "Close" })).toBeNull();
});
it("does not dismiss on Escape in embedded mode", async () => {
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} presentation="embedded" />);
await waitFor(() => {
expect(screen.getByText("No automations yet")).toBeDefined();
});
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).not.toHaveBeenCalled();
});
});
});

View File

@@ -357,6 +357,44 @@ describe("SettingsModal", () => {
expect(screen.getByRole("heading", { name: "Authentication" })).toBeInTheDocument();
});
// FNXC:EmbeddedPresentation 2026-06-22-12:00:
// presentation="embedded" (SettingsView) was a zero-coverage branch. Assert the embedded contract via
// useEmbeddedPresentation: embedded root class present, region role (not dialog), no fixed .modal-overlay
// backdrop / modal close button, and Escape does NOT dismiss (navigated away via the left sidebar instead).
describe("embedded presentation", () => {
it("renders the embedded root class with region role and no modal overlay or close button", async () => {
const { container } = renderModal({ presentation: "embedded" });
await waitForSettingsModalReady();
expect(container.querySelector(".settings-embedded")).not.toBeNull();
expect(container.querySelector(".settings-modal--embedded")).not.toBeNull();
expect(screen.getByRole("region", { name: "Settings" })).toBeInTheDocument();
// No fixed full-screen overlay backdrop and no dialog role in embedded mode.
expect(container.querySelector(".settings-modal-overlay")).toBeNull();
expect(screen.queryByRole("dialog")).toBeNull();
});
it("does not dismiss on Escape in embedded mode", async () => {
const onClose = vi.fn();
renderModal({ presentation: "embedded", onClose });
await waitForSettingsModalReady();
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).not.toHaveBeenCalled();
});
it("keeps the overlay and Escape-to-close in modal mode", async () => {
const onClose = vi.fn();
const { container } = renderModal({ onClose });
await waitForSettingsModalReady();
expect(container.querySelector(".settings-modal-overlay")).not.toBeNull();
expect(container.querySelector(".settings-modal--embedded")).toBeNull();
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalled();
});
});
it("maps the legacy pi-extensions initialSection alias to Plugins", async () => {
renderModal({ initialSection: "pi-extensions" });
await waitForSettingsModalReady();

View File

@@ -1248,6 +1248,62 @@ describe("WorkflowNodeEditor", () => {
});
});
// FNXC:EmbeddedPresentation 2026-06-22-12:00:
// presentation="embedded" was a zero-coverage branch. These assert the embedded contract via useEmbeddedPresentation:
// no fixed .modal-overlay backdrop, Escape does NOT dismiss (escapeEnabled is false), and the embedded root class renders.
describe("WorkflowNodeEditor — embedded presentation", () => {
beforeEach(() => {
vi.mocked(fetchWorkflows).mockResolvedValue([]);
vi.mocked(fetchTraits).mockResolvedValue(TRAIT_CATALOG);
vi.mocked(fetchStepParsers).mockResolvedValue(["step-headings", "json-steps"]);
vi.mocked(fetchModels).mockResolvedValue({ models: [] });
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it("renders the embedded root class and no modal overlay", async () => {
const { container } = render(
<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} presentation="embedded" />,
);
expect(await screen.findByText("Workflows")).toBeInTheDocument();
expect(container.querySelector(".workflow-editor-embedded")).not.toBeNull();
expect(container.querySelector(".wf-editor-modal--embedded")).not.toBeNull();
// No fixed full-screen overlay host in embedded mode.
expect(container.querySelector(".modal-overlay")).toBeNull();
expect(container.querySelector(".wf-editor-overlay")).toBeNull();
});
it("does not dismiss on Escape in embedded mode", async () => {
const onClose = vi.fn();
const { container } = render(
<WorkflowNodeEditor isOpen onClose={onClose} addToast={() => {}} presentation="embedded" />,
);
expect(await screen.findByText("Workflows")).toBeInTheDocument();
// Escape is handled on the modal element (onKeyDown), so fire it there — not on document.
const embeddedModal = container.querySelector(".wf-editor-modal--embedded")!;
fireEvent.keyDown(embeddedModal, { key: "Escape" });
expect(onClose).not.toHaveBeenCalled();
});
it("keeps the modal overlay and Escape-to-close in modal mode", async () => {
const onClose = vi.fn();
const { container } = render(<WorkflowNodeEditor isOpen onClose={onClose} addToast={() => {}} />);
expect(await screen.findByText("Workflows")).toBeInTheDocument();
expect(container.querySelector(".wf-editor-overlay")).not.toBeNull();
expect(container.querySelector(".wf-editor-modal--embedded")).toBeNull();
// Modal-mode Escape is handled on the modal element (onKeyDown), not document.
const modal = container.querySelector(".wf-editor-modal")!;
fireEvent.keyDown(modal, { key: "Escape" });
expect(onClose).toHaveBeenCalled();
});
});
describe("WorkflowNodeEditor — U1 card-style nodes", () => {
beforeEach(() => {
vi.mocked(fetchTraits).mockResolvedValue(TRAIT_CATALOG);

View File

@@ -556,9 +556,37 @@ The Command Center subtree previously had no tablet tier, so at 769px–1024px t
}
/*
FNXC:CommandCenter 2026-06-22-15:30:
Overview "View Board" / "View Agents" shortcut row sits directly under the Live activity snapshot. Self-styled here (OverviewTab does not pull in areas.css) using theme tokens only; mirrors the .cc-team-engine-nav wrapping-row look. Buttons grow to share the row and wrap on narrow widths.
FNXC:CommandCenter 2026-06-22-18:00:
The "AI Engine" panel is a bordered card that hosts the "View Board"/"View Agents" shortcuts plus an optional one-line engine status. It lives in controlsSection so it renders in every Overview branch (loading/error/empty/populated) and is always visible. Self-styled here (OverviewTab does not pull in areas.css) using theme tokens only. The button row reuses .cc-overview-engine-nav / .cc-overview-engine-nav-btn: buttons grow to share the row and wrap on narrow widths.
*/
.cc-overview-engine-panel {
display: flex;
flex-direction: column;
gap: var(--space-sm);
padding: var(--space-md);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
background: var(--surface-1);
}
.cc-overview-engine-panel-header {
display: flex;
align-items: center;
gap: var(--space-sm);
color: var(--text);
}
.cc-overview-engine-panel-title {
font-size: 0.9375rem;
font-weight: 600;
}
.cc-overview-engine-panel-status {
margin: 0;
font-size: 0.8125rem;
color: var(--text-muted);
}
.cc-overview-engine-nav {
display: flex;
flex-wrap: wrap;

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { AlertCircle, Gauge } from "lucide-react";
import { AlertCircle, Cpu, 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";
@@ -267,17 +267,60 @@ function OverviewTab({
// The throughput funnel reads its own data (activityLog transitions) and shows
// 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.
*/
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
projectId={projectId}
colorTheme={colorTheme}
themeMode={themeMode}
shadcnCustomColors={shadcnCustomColors}
resolvedThemeMode={resolvedThemeMode}
onColorThemeChange={onColorThemeChange}
onThemeModeChange={onThemeModeChange}
onShadcnCustomColorsChange={onShadcnCustomColorsChange}
/>
<>
<CommandCenterControls
projectId={projectId}
colorTheme={colorTheme}
themeMode={themeMode}
shadcnCustomColors={shadcnCustomColors}
resolvedThemeMode={resolvedThemeMode}
onColorThemeChange={onColorThemeChange}
onThemeModeChange={onThemeModeChange}
onShadcnCustomColorsChange={onShadcnCustomColorsChange}
/>
{enginePanel}
</>
);
const throughputSection = (
<div className="cc-overview-throughput" data-testid="command-center-throughput">
@@ -374,28 +417,6 @@ function OverviewTab({
/>
</div>
</div>
{/*
FNXC:CommandCenter 2026-06-22-15:30:
"View Board" / "View Agents" shortcuts live on the Overview landing, directly under the Live activity snapshot (the engine-activity strip — the closest "AI engine" element on Overview). Moved here from the Team-tab Heartbeat card. Navigation is owned by App (onChangeView), so this row only renders when wired up. Reuses the .cc-team-engine-nav row styling.
*/}
{onChangeView ? (
<div className="cc-overview-engine-nav" data-testid="command-center-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}
{hasOverviewChartData ? (
/*
FNXC:CommandCenter 2026-06-18-00:00:

View File

@@ -388,6 +388,30 @@ describe("CommandCenter shell", () => {
expect(screen.queryByTestId("command-center-overview-chart-activity")).toBeNull();
});
/*
FNXC:CommandCenter 2026-06-22-18:00:
The "AI Engine" panel (with "View Board"/"View Agents" shortcuts) lives in controlsSection and must render in every Overview branch — including the empty-data state — and its buttons must call onChangeView. Previously the shortcuts rendered only inside the populated return, so loading/empty/error states had no navigation.
*/
it("renders the AI Engine panel with working shortcuts even in the empty-data state", async () => {
mockEmptyOverviewApi();
const onChangeView = vi.fn();
render(<CommandCenter onChangeView={onChangeView} />);
// Panel + buttons present immediately (controlsSection renders in the loading branch).
expect(screen.getByTestId("command-center-engine-panel")).toBeTruthy();
const board = screen.getByRole("button", { name: "View Board" });
const agents = screen.getByRole("button", { name: "View Agents" });
// Still present after the empty-data branch resolves.
await screen.findByTestId("command-center-empty");
expect(screen.getByTestId("command-center-engine-panel")).toBeTruthy();
fireEvent.click(board);
expect(onChangeView).toHaveBeenCalledWith("board");
fireEvent.click(agents);
expect(onChangeView).toHaveBeenCalledWith("agents");
});
it("renders the Overview agent-runs card when run data is the only activity", async () => {
mockOverviewApi({
tokens: tokenFixture(0),

View File

@@ -0,0 +1,112 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import { useBoardWorkflows } from "../useBoardWorkflows";
import type { BoardWorkflowsPayload } from "../../api";
function makePayload(overrides: Partial<BoardWorkflowsPayload> = {}): BoardWorkflowsPayload {
return {
flagEnabled: true,
defaultWorkflowId: "wf-a",
workflows: [
{ id: "wf-a", name: "Alpha", columns: [] },
{ id: "wf-b", name: "Beta", columns: [] },
],
taskWorkflowIds: {},
...overrides,
} as BoardWorkflowsPayload;
}
describe("useBoardWorkflows", () => {
let subscribeHandlers: Record<string, (payload?: unknown) => void>;
let unsubscribe: ReturnType<typeof vi.fn>;
beforeEach(() => {
subscribeHandlers = {};
unsubscribe = vi.fn();
});
function makeDeps(fetchImpl: () => Promise<BoardWorkflowsPayload>) {
return {
fetchBoardWorkflows: vi.fn(fetchImpl),
subscribeSse: vi.fn((_url: string, sub: { events?: Record<string, (p?: unknown) => void> }) => {
subscribeHandlers = { ...(sub.events ?? {}) };
return unsubscribe;
}),
readBoardWorkflowsCache: vi.fn(() => null),
writeBoardWorkflowsCache: vi.fn(),
};
}
it("initial fetch populates workflow options and selects the default", async () => {
const deps = makeDeps(() => Promise.resolve(makePayload()));
const { result } = renderHook(() => useBoardWorkflows({ projectId: "p1", ...deps }));
await waitFor(() => expect(result.current.workflowOptions.length).toBe(2));
expect(deps.fetchBoardWorkflows).toHaveBeenCalledTimes(1);
expect(result.current.workflowMode).toBe(true);
// Default sorts first.
expect(result.current.workflowOptions[0].id).toBe("wf-a");
expect(result.current.selectedWorkflow?.id).toBe("wf-a");
expect(deps.writeBoardWorkflowsCache).toHaveBeenCalledWith("p1", expect.objectContaining({ flagEnabled: true }));
});
it("stale-response guard drops an out-of-order response", async () => {
let resolveFirst: (p: BoardWorkflowsPayload) => void = () => {};
let resolveSecond: (p: BoardWorkflowsPayload) => void = () => {};
const promises = [
new Promise<BoardWorkflowsPayload>((r) => { resolveFirst = r; }),
new Promise<BoardWorkflowsPayload>((r) => { resolveSecond = r; }),
];
let call = 0;
const deps = makeDeps(() => promises[call++] ?? Promise.resolve(makePayload()));
const { result } = renderHook(() => useBoardWorkflows({ projectId: "p1", ...deps }));
// First fetch fired on mount; fire a second (newer) refresh.
act(() => { result.current.refreshBoardWorkflows(); });
// Resolve the SECOND (newest) request first — this should win.
await act(async () => {
resolveSecond(makePayload({ workflows: [{ id: "wf-new", name: "New", columns: [] }], defaultWorkflowId: "wf-new" }));
});
await waitFor(() => expect(result.current.selectedWorkflow?.id).toBe("wf-new"));
// Now resolve the older request — it is stale and must be dropped.
await act(async () => {
resolveFirst(makePayload());
});
expect(result.current.selectedWorkflow?.id).toBe("wf-new");
expect(result.current.workflowOptions.map((w) => w.id)).toEqual(["wf-new"]);
});
it("an SSE workflow event re-fetches", async () => {
const deps = makeDeps(() => Promise.resolve(makePayload()));
const { result } = renderHook(() => useBoardWorkflows({ projectId: "p1", ...deps }));
await waitFor(() => expect(deps.fetchBoardWorkflows).toHaveBeenCalledTimes(1));
expect(typeof subscribeHandlers["workflow:updated"]).toBe("function");
await act(async () => { subscribeHandlers["workflow:updated"](); });
expect(deps.fetchBoardWorkflows).toHaveBeenCalledTimes(2);
});
it("unmount removes visibility/focus listeners and unsubscribes from SSE", async () => {
const addSpy = vi.spyOn(document, "addEventListener");
const removeSpy = vi.spyOn(document, "removeEventListener");
const winRemoveSpy = vi.spyOn(window, "removeEventListener");
const deps = makeDeps(() => Promise.resolve(makePayload()));
const { unmount } = renderHook(() => useBoardWorkflows({ projectId: "p1", ...deps }));
await waitFor(() => expect(deps.fetchBoardWorkflows).toHaveBeenCalled());
expect(addSpy).toHaveBeenCalledWith("visibilitychange", expect.any(Function));
unmount();
expect(removeSpy).toHaveBeenCalledWith("visibilitychange", expect.any(Function));
expect(winRemoveSpy).toHaveBeenCalledWith("focus", expect.any(Function));
expect(unsubscribe).toHaveBeenCalledTimes(1);
addSpy.mockRestore();
removeSpy.mockRestore();
winRemoveSpy.mockRestore();
});
});

View File

@@ -0,0 +1,159 @@
import { useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from "react";
import {
fetchBoardWorkflows as defaultFetchBoardWorkflows,
type BoardWorkflowDefinition,
type BoardWorkflowsPayload,
} from "../api";
import { subscribeSse as defaultSubscribeSse } from "../sse-bus";
import {
readBoardWorkflowsCache as defaultReadBoardWorkflowsCache,
writeBoardWorkflowsCache as defaultWriteBoardWorkflowsCache,
} from "../utils/boardWorkflowsCache";
/*
FNXC:Workflows 2026-06-22-17:00:
Single source of truth for board-workflow fetch/cache/SSE/selection, shared verbatim by Board.tsx and the Planning header slot (PlanningWorkflowSwitcherSlot.tsx). Both surfaces must show the SAME workflow dropdown driven by the SAME data path: refetch on mount, on tab visibility/focus, and on `workflow:created|updated|deleted` SSE; every fetch is guarded by a monotonic sequence ref that drops out-of-order responses; successful payloads persist to the per-project session cache; failures collapse to a flag-off payload. Selection (`selectedWorkflowId`) is local per-consumer and auto-syncs to the resolved default/first workflow.
Per-consumer subscription semantics are preserved: each call to this hook installs its OWN visibilitychange/focus listeners and its OWN SSE subscription, so two consumers (Board + Planning slot) each subscribe and unsubscribe independently — the hook does not dedupe across consumers. Dependencies (fetch, subscribeSse, cache helpers) are injectable to keep the hook DI-friendly and free of App-level singletons.
*/
export interface UseBoardWorkflowsParams {
projectId?: string;
/**
* Gate cache hydration. Board passes `workflowColumnsEnabled === true || settingsLoaded === false`
* to avoid flashing the legacy board; Planning has no such gate and leaves this at the default `true`.
*/
shouldHydrateCache?: boolean;
fetchBoardWorkflows?: typeof defaultFetchBoardWorkflows;
subscribeSse?: typeof defaultSubscribeSse;
readBoardWorkflowsCache?: typeof defaultReadBoardWorkflowsCache;
writeBoardWorkflowsCache?: typeof defaultWriteBoardWorkflowsCache;
}
export interface UseBoardWorkflowsResult {
/** Raw payload for the current project, or null when unloaded / project mismatch. */
boardWorkflows: BoardWorkflowsPayload | null;
/** True when the flag is on AND at least one workflow is defined. */
workflowMode: boolean;
/** Workflows sorted with the default first, then alphabetical. Empty unless in workflow mode. */
workflowOptions: BoardWorkflowDefinition[];
/** Currently selected workflow (resolved from selection / default / first), or null. */
selectedWorkflow: BoardWorkflowDefinition | null;
selectedWorkflowId: string | null;
setSelectedWorkflowId: Dispatch<SetStateAction<string | null>>;
/** Force a fresh fetch (used on switcher open, since task assignment changes emit no workflow SSE). */
refreshBoardWorkflows: () => void;
/**
* Raw state setter, exposed so Board can apply optimistic task→workflow assignment.
* Planning does not use this.
*/
setBoardWorkflowsState: Dispatch<SetStateAction<{ projectId?: string; payload: BoardWorkflowsPayload } | null>>;
}
export function useBoardWorkflows(params: UseBoardWorkflowsParams): UseBoardWorkflowsResult {
const {
projectId,
shouldHydrateCache = true,
fetchBoardWorkflows = defaultFetchBoardWorkflows,
subscribeSse = defaultSubscribeSse,
readBoardWorkflowsCache = defaultReadBoardWorkflowsCache,
writeBoardWorkflowsCache = defaultWriteBoardWorkflowsCache,
} = params;
const [boardWorkflowsState, setBoardWorkflowsState] = useState<{ projectId?: string; payload: BoardWorkflowsPayload } | null>(() => {
const cached = shouldHydrateCache ? readBoardWorkflowsCache(projectId) : null;
return cached ? { projectId, payload: cached } : null;
});
const boardWorkflows = boardWorkflowsState?.projectId === projectId && boardWorkflowsState ? boardWorkflowsState.payload : null;
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null>(null);
// Stale-response guard: a monotonic sequence ref drops out-of-order responses.
const boardWorkflowsFetchSeqRef = useRef(0);
// Re-hydrate from the per-project cache on project change (and gate change).
useEffect(() => {
const cached = shouldHydrateCache ? readBoardWorkflowsCache(projectId) : null;
setBoardWorkflowsState(cached ? { projectId, payload: cached } : null);
}, [projectId, shouldHydrateCache, readBoardWorkflowsCache]);
const refreshBoardWorkflows = useCallback(() => {
const seq = ++boardWorkflowsFetchSeqRef.current;
fetchBoardWorkflows(projectId)
.then((payload) => {
if (seq === boardWorkflowsFetchSeqRef.current) {
setBoardWorkflowsState({ projectId, payload });
writeBoardWorkflowsCache(projectId, payload);
}
})
.catch(() => {
if (seq === boardWorkflowsFetchSeqRef.current) {
setBoardWorkflowsState({ projectId, payload: { flagEnabled: false, defaultWorkflowId: "builtin:coding", workflows: [], taskWorkflowIds: {} } });
}
});
}, [projectId, fetchBoardWorkflows, writeBoardWorkflowsCache]);
useEffect(() => {
refreshBoardWorkflows();
const onVisible = () => {
if (typeof document === "undefined" || document.visibilityState === "visible") refreshBoardWorkflows();
};
if (typeof document !== "undefined") document.addEventListener("visibilitychange", onVisible);
if (typeof window !== "undefined") window.addEventListener("focus", onVisible);
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
const unsubscribe = subscribeSse(`/api/events${query}`, {
events: {
"workflow:created": refreshBoardWorkflows,
"workflow:updated": refreshBoardWorkflows,
"workflow:deleted": refreshBoardWorkflows,
},
});
return () => {
// Advance the seq so any in-flight response is dropped on cleanup.
boardWorkflowsFetchSeqRef.current++;
if (typeof document !== "undefined") document.removeEventListener("visibilitychange", onVisible);
if (typeof window !== "undefined") window.removeEventListener("focus", onVisible);
unsubscribe();
};
}, [projectId, refreshBoardWorkflows, subscribeSse]);
const flagOn = boardWorkflows?.flagEnabled === true;
const workflowMode = flagOn && Boolean(boardWorkflows?.workflows.length);
const workflowOptions = useMemo<BoardWorkflowDefinition[]>(() => {
if (!workflowMode || !boardWorkflows) return [];
return [...boardWorkflows.workflows].sort((a, b) => {
if (a.id === boardWorkflows.defaultWorkflowId) return -1;
if (b.id === boardWorkflows.defaultWorkflowId) return 1;
return a.name.localeCompare(b.name);
});
}, [boardWorkflows, workflowMode]);
const selectedWorkflow = useMemo<BoardWorkflowDefinition | null>(() => {
if (!workflowMode) return null;
return workflowOptions.find((workflow) => workflow.id === selectedWorkflowId)
?? workflowOptions.find((workflow) => workflow.id === boardWorkflows?.defaultWorkflowId)
?? workflowOptions[0]
?? null;
}, [boardWorkflows?.defaultWorkflowId, selectedWorkflowId, workflowMode, workflowOptions]);
useEffect(() => {
if (!workflowMode) {
setSelectedWorkflowId(null);
return;
}
if (selectedWorkflow && selectedWorkflow.id !== selectedWorkflowId) {
setSelectedWorkflowId(selectedWorkflow.id);
}
}, [selectedWorkflow, selectedWorkflowId, workflowMode]);
return {
boardWorkflows,
workflowMode,
workflowOptions,
selectedWorkflow,
selectedWorkflowId,
setSelectedWorkflowId,
refreshBoardWorkflows,
setBoardWorkflowsState,
};
}

View File

@@ -0,0 +1,47 @@
/*
FNXC:EmbeddedPresentation 2026-06-22-12:00:
Seven modal components (ActivityLogModal, GitManagerModal, GitHubImportModal, ScheduledTasksModal, PlanningModeModal, SettingsModal, WorkflowNodeEditor) each independently grew the same "embedded vs modal" presentation switch for the right-dock / main-content-area redesign. Each derived `isEmbedded = presentation === "embedded"` locally and gated the same modal-only behaviors off it: mobile scroll lock, modal resize-persist, Escape-to-close, and overlay click-dismiss.
This hook collapses that copy-pasted pattern into one place. The returned booleans are the enabled-arg for the hooks/handlers the components already call (e.g. `useMobileScrollLock(open && scrollLockEnabled)`), so the gating stays a single boolean expression and the underlying hooks remain CALLED UNCONDITIONALLY (React hook rules) — only their enabled arg flips.
Embedded surfaces are persistent main-content destinations owned by the dock/router, so all four modal-only affordances are disabled when embedded; every flag is simply `!isEmbedded`. Modal presentation (the default) keeps every affordance on, byte-identical to the historical behavior.
*/
/** Presentation surface for a component that can render as a fixed dialog overlay or inline in the main content area. */
export type ModalPresentation = "modal" | "embedded";
/**
* Derived presentation flags shared by the embedded-capable modal components.
*
* - `isEmbedded` / `isModal` — the raw mode test.
* - `scrollLockEnabled` — gate for `useMobileScrollLock`; off when embedded (the host page owns scrolling).
* - `resizePersistEnabled` — gate for `useModalResizePersist`; off when embedded (the view fills its container).
* - `escapeEnabled` — gate for Escape-to-close handlers; off when embedded (the dock/router owns lifecycle).
* - `overlayDismissEnabled` — gate for backdrop click-to-dismiss; off when embedded (no overlay backdrop exists).
*/
export interface EmbeddedPresentation {
isEmbedded: boolean;
isModal: boolean;
scrollLockEnabled: boolean;
resizePersistEnabled: boolean;
escapeEnabled: boolean;
overlayDismissEnabled: boolean;
}
/**
* Resolve the shared embedded-presentation flags for a component.
*
* @param presentation - The component's `presentation` prop. Defaults to "modal" so callers that omit it keep full modal behavior.
*/
export function useEmbeddedPresentation(presentation: ModalPresentation = "modal"): EmbeddedPresentation {
const isEmbedded = presentation === "embedded";
// Every modal-only affordance is disabled in embedded mode; embedded surfaces are persistent and host-owned.
return {
isEmbedded,
isModal: !isEmbedded,
scrollLockEnabled: !isEmbedded,
resizePersistEnabled: !isEmbedded,
escapeEnabled: !isEmbedded,
overlayDismissEnabled: !isEmbedded,
};
}