From cc2753c8a24eafa9694fa76f6984ec17829705b2 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 04:18:35 -0700 Subject: [PATCH] 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) --- .../app/components/ActivityLogModal.tsx | 9 +- .../app/components/AgentDetailView.css | 22 +- packages/dashboard/app/components/Board.tsx | 115 ++------ .../app/components/GitHubImportModal.tsx | 13 +- .../app/components/GitManagerModal.tsx | 15 +- .../dashboard/app/components/ListView.tsx | 68 ++++- .../dashboard/app/components/MailboxModal.css | 29 +- .../dashboard/app/components/MailboxView.tsx | 50 +++- .../dashboard/app/components/MemoryView.css | 28 +- .../app/components/PlanningModeModal.tsx | 12 +- .../PlanningWorkflowSwitcherSlot.tsx | 105 +------ .../dashboard/app/components/RightDock.css | 93 +++++- .../app/components/RightDockExpandModal.tsx | 265 +++++++++++++++++- .../app/components/ScheduledTasksModal.tsx | 11 +- .../dashboard/app/components/ScriptsModal.css | 16 +- .../app/components/SettingsModal.tsx | 15 +- .../dashboard/app/components/SkillsView.tsx | 3 +- .../app/components/TerminalModal.css | 17 +- .../dashboard/app/components/TodoView.tsx | 16 +- .../app/components/WorkflowNodeEditor.tsx | 7 +- .../__tests__/GitHubImportModal.test.tsx | 48 ++++ .../components/__tests__/ListView.test.tsx | 59 +++- .../components/__tests__/MailboxView.test.tsx | 19 +- .../components/__tests__/RightDock.test.tsx | 34 +++ .../__tests__/ScheduledTasksModal.test.tsx | 31 ++ .../__tests__/SettingsModal.test.tsx | 38 +++ .../__tests__/WorkflowNodeEditor.test.tsx | 56 ++++ .../command-center/CommandCenter.css | 32 ++- .../command-center/CommandCenter.tsx | 87 +++--- .../__tests__/CommandCenter.test.tsx | 24 ++ .../hooks/__tests__/useBoardWorkflows.test.ts | 112 ++++++++ .../dashboard/app/hooks/useBoardWorkflows.ts | 159 +++++++++++ .../app/hooks/useEmbeddedPresentation.ts | 47 ++++ 33 files changed, 1292 insertions(+), 363 deletions(-) create mode 100644 packages/dashboard/app/hooks/__tests__/useBoardWorkflows.test.ts create mode 100644 packages/dashboard/app/hooks/useBoardWorkflows.ts create mode 100644 packages/dashboard/app/hooks/useEmbeddedPresentation.ts diff --git a/packages/dashboard/app/components/ActivityLogModal.tsx b/packages/dashboard/app/components/ActivityLogModal.tsx index 8c32ea151d..36d2f8e5d4 100644 --- a/packages/dashboard/app/components/ActivityLogModal.tsx +++ b/packages/dashboard/app/components/ActivityLogModal.tsx @@ -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 { @@ -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("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"; diff --git a/packages/dashboard/app/components/AgentDetailView.css b/packages/dashboard/app/components/AgentDetailView.css index f3a8a99b98..f4c98aede4 100644 --- a/packages/dashboard/app/components/AgentDetailView.css +++ b/packages/dashboard/app/components/AgentDetailView.css @@ -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; } diff --git a/packages/dashboard/app/components/Board.tsx b/packages/dashboard/app/components/Board.tsx index fa4d9745d0..ae073f0f67 100644 --- a/packages/dashboard/app/components/Board.tsx +++ b/packages/dashboard/app/components/Board.tsx @@ -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(null); + const { + boardWorkflows, + workflowMode, + workflowOptions, + selectedWorkflow, + selectedWorkflowId, + setSelectedWorkflowId, + refreshBoardWorkflows, + setBoardWorkflowsState, + } = useBoardWorkflows({ projectId, shouldHydrateCache: shouldHydrateBoardWorkflowsCache }); const draggingTaskIdRef = useRef(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(() => { - 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(() => { - 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) => { diff --git a/packages/dashboard/app/components/GitHubImportModal.tsx b/packages/dashboard/app/components/GitHubImportModal.tsx index 4239576c1d..1e66588eb1 100644 --- a/packages/dashboard/app/components/GitHubImportModal.tsx +++ b/packages/dashboard/app/components/GitHubImportModal.tsx @@ -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(""); const mountedRef = useRef(false); const modalRef = useRef(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(() => { diff --git a/packages/dashboard/app/components/GitManagerModal.tsx b/packages/dashboard/app/components/GitManagerModal.tsx index b3d67340c7..93e927cd03 100644 --- a/packages/dashboard/app/components/GitManagerModal.tsx +++ b/packages/dashboard/app/components/GitManagerModal.tsx @@ -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(null); const modalRef = useRef(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 ──────────────────────────────────────────── diff --git a/packages/dashboard/app/components/ListView.tsx b/packages/dashboard/app/components/ListView.tsx index 5568c9c5aa..bd96e1e1a4 100644 --- a/packages/dashboard/app/components/ListView.tsx +++ b/packages/dashboard/app/components/ListView.tsx @@ -405,6 +405,8 @@ export function ListView({ const [sidebarWidth, setSidebarWidth] = useState(() => readSidebarWidth(projectId)); const splitLayoutRef = useRef(null); const splitSidebarRef = useRef(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) => { + /* + 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) => { 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) => { if (isMobile) return; const measuredWidth = splitLayoutRef.current?.clientWidth ?? 0; @@ -2394,7 +2438,7 @@ export function ListView({
(() => readMailboxSidebarWidth(projectId)); const splitLayoutRef = useRef(null); const mailboxContentRef = useRef(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(null); const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } = useMobileKeyboard({ enabled: isMobile }); const containerKeyboardStyle = useMemo(() => { @@ -285,27 +290,56 @@ export function MailboxView({ } }, [isSplitPane, projectId, sidebarWidth]); - const handleSplitResizeStart = useCallback((event: React.MouseEvent) => { + /* + 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) => { 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) => { 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} />
diff --git a/packages/dashboard/app/components/MemoryView.css b/packages/dashboard/app/components/MemoryView.css index 0596411d6f..c01778ff4a 100644 --- a/packages/dashboard/app/components/MemoryView.css +++ b/packages/dashboard/app/components/MemoryView.css @@ -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 , 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 , 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 { diff --git a/packages/dashboard/app/components/PlanningModeModal.tsx b/packages/dashboard/app/components/PlanningModeModal.tsx index cf2507a91c..4acbbe3f48 100644 --- a/packages/dashboard/app/components/PlanningModeModal.tsx +++ b/packages/dashboard/app/components/PlanningModeModal.tsx @@ -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({ type: "initial" }); const [error, setError] = useState(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 diff --git a/packages/dashboard/app/components/PlanningWorkflowSwitcherSlot.tsx b/packages/dashboard/app/components/PlanningWorkflowSwitcherSlot.tsx index 6a8432ced1..2441d4a503 100644 --- a/packages/dashboard/app/components/PlanningWorkflowSwitcherSlot.tsx +++ b/packages/dashboard/app/components/PlanningWorkflowSwitcherSlot.tsx @@ -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 = 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(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(() => { - 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(() => { - 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; diff --git a/packages/dashboard/app/components/RightDock.css b/packages/dashboard/app/components/RightDock.css index c2317fb40f..3d2e2ce7f1 100644 --- a/packages/dashboard/app/components/RightDock.css +++ b/packages/dashboard/app/components/RightDock.css @@ -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; diff --git a/packages/dashboard/app/components/RightDockExpandModal.tsx b/packages/dashboard/app/components/RightDockExpandModal.tsx index db3063d756..3df2343986 100644 --- a/packages/dashboard/app/components/RightDockExpandModal.tsx +++ b/packages/dashboard/app/components/RightDockExpandModal.tsx @@ -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; + 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; + 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>; @@ -31,15 +119,149 @@ export function RightDockExpandModal({ onClose, returnFocusRef, }: RightDockExpandModalProps) { - const modalRef = useRef(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(() => readExpandSize()); + const [position, setPositionState] = useState(() => 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) => { + 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, 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 ( -
-
-
+
+
+ {EXPAND_RESIZE_DIRECTIONS.map((direction) => ( +
handleFloatingResizePointerDown(event, direction)} + /> + ))} +
diff --git a/packages/dashboard/app/components/ScheduledTasksModal.tsx b/packages/dashboard/app/components/ScheduledTasksModal.tsx index d686c37c0e..5e03be2848 100644 --- a/packages/dashboard/app/components/ScheduledTasksModal.tsx +++ b/packages/dashboard/app/components/ScheduledTasksModal.tsx @@ -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(() => projectId ? "project" : "global"); @@ -59,7 +60,7 @@ export function ScheduledTasksModal({ onClose, addToast, projectId, presentation const modalRef = useRef(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); diff --git a/packages/dashboard/app/components/ScriptsModal.css b/packages/dashboard/app/components/ScriptsModal.css index 65bd6856cf..4ce18e939e 100644 --- a/packages/dashboard/app/components/ScriptsModal.css +++ b/packages/dashboard/app/components/ScriptsModal.css @@ -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; } diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index c2aac913f5..1c5defcc58 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -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({ 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: diff --git a/packages/dashboard/app/components/SkillsView.tsx b/packages/dashboard/app/components/SkillsView.tsx index d79ddfcd4e..9b3df6450a 100644 --- a/packages/dashboard/app/components/SkillsView.tsx +++ b/packages/dashboard/app/components/SkillsView.tsx @@ -251,8 +251,9 @@ export function SkillsView({ projectId, addToast, onClose }: SkillsViewProps) { > + {/* 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). */} + +
+ ) : null} +
+ ); const controlsSection = ( - + <> + + {enginePanel} + ); const throughputSection = (
@@ -374,28 +417,6 @@ function OverviewTab({ />
- {/* - 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 ? ( -
- - -
- ) : null} {hasOverviewChartData ? ( /* FNXC:CommandCenter 2026-06-18-00:00: diff --git a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx index 42189095c5..ef40640f9e 100644 --- a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx +++ b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx @@ -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(); + + // 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), diff --git a/packages/dashboard/app/hooks/__tests__/useBoardWorkflows.test.ts b/packages/dashboard/app/hooks/__tests__/useBoardWorkflows.test.ts new file mode 100644 index 0000000000..d60efae009 --- /dev/null +++ b/packages/dashboard/app/hooks/__tests__/useBoardWorkflows.test.ts @@ -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 { + 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 void>; + let unsubscribe: ReturnType; + + beforeEach(() => { + subscribeHandlers = {}; + unsubscribe = vi.fn(); + }); + + function makeDeps(fetchImpl: () => Promise) { + return { + fetchBoardWorkflows: vi.fn(fetchImpl), + subscribeSse: vi.fn((_url: string, sub: { events?: Record 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((r) => { resolveFirst = r; }), + new Promise((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(); + }); +}); diff --git a/packages/dashboard/app/hooks/useBoardWorkflows.ts b/packages/dashboard/app/hooks/useBoardWorkflows.ts new file mode 100644 index 0000000000..0cb97d0654 --- /dev/null +++ b/packages/dashboard/app/hooks/useBoardWorkflows.ts @@ -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>; + /** 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>; +} + +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(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(() => { + 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(() => { + 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, + }; +} diff --git a/packages/dashboard/app/hooks/useEmbeddedPresentation.ts b/packages/dashboard/app/hooks/useEmbeddedPresentation.ts new file mode 100644 index 0000000000..889bfb4a1e --- /dev/null +++ b/packages/dashboard/app/hooks/useEmbeddedPresentation.ts @@ -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, + }; +}