From 70442d2b429c38ad6c8a5604584560558f613a9c Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 01:15:11 -0700 Subject: [PATCH] feat(dashboard): dock file viewer, planning workflow switcher, gm mobile layout - Files dock: clicking a file opens it inline (read-only FileEditor) with Back + pop-out-to-resizable-modal controls (DockFilesView). - Planning view shows the same board WorkflowSwitcher in the same Header workflow slot (PlanningWorkflowSwitcherSlot, portaled). - Embedded Git Manager uses the mobile single-column layout in the narrow dock (section tabs strip + full-width content). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/dashboard/app/App.tsx | 6 + .../app/components/DockFilesView.css | 63 +++++++ .../app/components/DockFilesView.tsx | 125 ++++++++++++++ .../PlanningWorkflowSwitcherSlot.tsx | 160 ++++++++++++++++++ .../app/components/ProjectSelector.css | 5 + .../dashboard/app/components/RightDock.css | 11 +- .../dashboard/app/components/ScriptsModal.css | 47 +++++ .../app/components/overflowViewRegistry.tsx | 25 +-- 8 files changed, 417 insertions(+), 25 deletions(-) create mode 100644 packages/dashboard/app/components/DockFilesView.css create mode 100644 packages/dashboard/app/components/DockFilesView.tsx create mode 100644 packages/dashboard/app/components/PlanningWorkflowSwitcherSlot.tsx diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 0b8fc90292..4fd4db5830 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -99,6 +99,7 @@ import { subscribeSse } from "./sse-bus"; import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } from "./auth"; import { AuthTokenRecoveryDialog } from "./components/AuthTokenRecoveryDialog"; import { PlanningModeModal } from "./components/PlanningModeModal"; +import { PlanningWorkflowSwitcherSlot } from "./components/PlanningWorkflowSwitcherSlot"; // ChatView's CSS is imported eagerly so the styles bundle into the main // CSS file. Without this, the lazy ChatView JS chunk loaded its own CSS @@ -1823,6 +1824,11 @@ function AppInner() { }; return ( + {/* + FNXC:Navigation 2026-06-22-00:00: + Planning shows the same board WorkflowSwitcher in the same Header workflow slot as Board/List (portaled by PlanningWorkflowSwitcherSlot), so workflow selection is reachable from the left-sidebar Planning destination. + */} + (null); + const [content, setContent] = useState(""); + const [contentLoading, setContentLoading] = useState(false); + const [contentError, setContentError] = useState(null); + + // Load the selected file's content read-only from the project workspace. + useEffect(() => { + if (!selectedFile) { + setContent(""); + setContentError(null); + return; + } + + let cancelled = false; + setContentLoading(true); + setContentError(null); + + fetchWorkspaceFileContent("project", selectedFile, projectId) + .then((response) => { + if (cancelled) return; + setContent(response.content); + }) + .catch((err) => { + if (cancelled) return; + setContentError(getErrorMessage(err) || t("editor.failedToLoadFile", "Failed to load file")); + setContent(""); + }) + .finally(() => { + if (!cancelled) setContentLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [selectedFile, projectId, t]); + + const handleBack = useCallback(() => setSelectedFile(null), []); + const handlePopOut = useCallback(() => { + if (selectedFile) openFile?.(selectedFile, { workspace: "project" }); + }, [openFile, selectedFile]); + + if (selectedFile) { + const fileName = selectedFile.split("/").pop() || selectedFile; + return ( +
+
+ + {fileName} + +
+
+ {contentLoading ? ( +
{t("common.loading", "Loading...")}
+ ) : contentError ? ( +
{contentError}
+ ) : ( + {}} readOnly filePath={selectedFile} /> + )} +
+
+ ); + } + + return ( +
+ setSelectedFile(path)} + onNavigate={setPath} + loading={loading} + error={error} + onRetry={refresh} + workspace="project" + onRefresh={refresh} + projectId={projectId} + /> +
+ ); +} diff --git a/packages/dashboard/app/components/PlanningWorkflowSwitcherSlot.tsx b/packages/dashboard/app/components/PlanningWorkflowSwitcherSlot.tsx new file mode 100644 index 0000000000..6bcbc7d37a --- /dev/null +++ b/packages/dashboard/app/components/PlanningWorkflowSwitcherSlot.tsx @@ -0,0 +1,160 @@ +import { useCallback, useEffect, useMemo, useRef, 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"; + +/* +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). + +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). +*/ + +interface PlanningWorkflowSwitcherSlotProps { + projectId?: string; + onOpenWorkflowEditor?: () => void; + onCreateWorkflow?: () => void; +} + +// Counts require live task/column data that Planning does not thread here. +// WorkflowSwitcher renders zero counts for an empty map, so pass a stable empty Map +// rather than threading tasks into the Planning view. +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); + + // 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. + const [headerWorkflowSlot, setHeaderWorkflowSlot] = useState(() => { + if (typeof document === "undefined") return null; + 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; + const resolve = () => { + const slot = document.getElementById("header-workflow-slot"); + setHeaderWorkflowSlot((prev) => (prev === slot ? prev : slot)); + return slot; + }; + if (resolve()) return; + const interval = window.setInterval(() => { + if (resolve()) window.clearInterval(interval); + }, 250); + 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; + } + + const workflowToolbar = ( +
+
+ +
+
+ ); + + return createPortal(workflowToolbar, headerWorkflowSlot); +} diff --git a/packages/dashboard/app/components/ProjectSelector.css b/packages/dashboard/app/components/ProjectSelector.css index 27b99e6703..6e2c33f957 100644 --- a/packages/dashboard/app/components/ProjectSelector.css +++ b/packages/dashboard/app/components/ProjectSelector.css @@ -577,6 +577,11 @@ min-height: 0; min-width: 0; width: 100%; + /* + FNXC:Navigation 2026-06-22-00:10: + Anchor for the right dock, which is absolutely positioned so it overlays the page content instead of shrinking it. + */ + position: relative; } .dashboard-project-shell--with-sidebar { diff --git a/packages/dashboard/app/components/RightDock.css b/packages/dashboard/app/components/RightDock.css index 6cb2b59fe9..7cb793fb64 100644 --- a/packages/dashboard/app/components/RightDock.css +++ b/packages/dashboard/app/components/RightDock.css @@ -2,10 +2,17 @@ FNXC:Navigation 2026-06-21-00:00: The right dock CSS uses a mobile media query as a belt-and-suspenders guard only. The authoritative mobile gate is the JS `rightDockActive` value from `useViewportMode`, which also covers phone classes that a width-only query cannot classify reliably. */ +/* +FNXC:Navigation 2026-06-22-00:10: +The right dock OVERLAYS the page content (floats over the right edge) instead of being a flex sibling that shrinks the main content. It is absolutely positioned against the project shell (which is position:relative) so opening/closing or resizing it never reflows the page beneath. z-index sits above content but below the docked terminal/modals. +*/ .right-dock { - position: relative; + position: absolute; + top: 0; + right: 0; + bottom: 0; + z-index: 20; display: flex; - flex: 0 0 auto; flex-direction: column; min-width: min(100%, var(--right-dock-min-width, calc(var(--space-2xl) * 8))); max-width: min(100%, var(--right-dock-max-width, calc(var(--space-2xl) * 22))); diff --git a/packages/dashboard/app/components/ScriptsModal.css b/packages/dashboard/app/components/ScriptsModal.css index f715cc0b4d..75cb7b60f1 100644 --- a/packages/dashboard/app/components/ScriptsModal.css +++ b/packages/dashboard/app/components/ScriptsModal.css @@ -1966,6 +1966,53 @@ The embedded host fills its right-dock container; the inner shell drops overlay- position: static; } +/* +FNXC:GitManager 2026-06-22-00:10: +The embedded Git Manager renders inside the narrow right dock, so it must use the mobile single-column layout (section tabs as a horizontal strip above a full-width content pane) regardless of viewport width, mirroring the max-width:768px rules. +*/ +.gm-modal--embedded .gm-layout { + flex-direction: column; +} + +.gm-modal--embedded .gm-sidebar { + flex: 0 0 auto; + flex-direction: row; + 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; + padding: var(--space-xs) var(--space-sm); + gap: var(--space-xs); +} + +.gm-modal--embedded .gm-nav-item { + flex: 0 0 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; + min-width: calc(var(--space-2xl) + var(--space-xl)); + text-align: center; + justify-content: center; +} + +.gm-modal--embedded .gm-nav-item.active { + border-left-color: transparent; + border-bottom-color: var(--todo); +} + +.gm-modal--embedded .gm-status-grid { + grid-template-columns: 1fr; +} + +.gm-modal--embedded .gm-create-form { + flex-wrap: wrap; +} + /* Main layout: sidebar + content */ .gm-layout { display: flex; diff --git a/packages/dashboard/app/components/overflowViewRegistry.tsx b/packages/dashboard/app/components/overflowViewRegistry.tsx index b389ceec30..e6244e0e80 100644 --- a/packages/dashboard/app/components/overflowViewRegistry.tsx +++ b/packages/dashboard/app/components/overflowViewRegistry.tsx @@ -9,11 +9,10 @@ import { import type { Task, TaskDetail, WorkflowStep } from "@fusion/core"; import type { PluginDashboardViewEntry } from "../api"; import type { ToastType } from "../hooks/useToast"; -import { useWorkspaceFileBrowser } from "../hooks/useWorkspaceFileBrowser"; import { buildPluginTaskViewId } from "../plugins/pluginViewRegistry"; import { PluginDashboardViewHost } from "../plugins/PluginDashboardViewHost"; import type { DetailTaskTab, PluginDashboardViewContext } from "../plugins/types"; -import { FileBrowser } from "./FileBrowser"; +import { DockFilesView } from "./DockFilesView"; import { PageErrorBoundary } from "./ErrorBoundary"; import { getPluginNavIcon } from "./pluginNavIcon"; import { UsageIndicator } from "./UsageIndicator"; @@ -88,26 +87,6 @@ function wrapOverflowView(node: ReactNode): ReactNode { ); } -function InlineFilesView({ projectId, openFile }: Pick) { - const { entries, currentPath, setPath, loading, error, refresh } = useWorkspaceFileBrowser("project", true, projectId); - return ( -
- openFile?.(path, { workspace: "project" })} - onNavigate={setPath} - loading={loading} - error={error} - onRetry={refresh} - workspace="project" - onRefresh={refresh} - projectId={projectId} - /> -
- ); -} - /* FNXC:Navigation 2026-06-21-00:00: The right dock and its expand modal must resolve every hosted overflow destination through this registry so toolbar gating, component choice, and props cannot drift between the compact panel and full-size modal surfaces. @@ -166,7 +145,7 @@ export const STATIC_OVERFLOW_VIEW_ENTRIES: readonly OverflowViewEntry[] = [ label: "Files", icon: Folder, testId: "right-dock-tab-files", - render: (props) => wrapOverflowView(), + render: (props) => wrapOverflowView(), }, ];