From ce6c0fb0b0cdb12adb8d3be348b4ec6c6f4490d5 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 16:01:31 -0700 Subject: [PATCH] fix(dashboard): align view headers and toolbars --- .changeset/dashboard-view-toolbar-polish.md | 3 + packages/dashboard/app/App.tsx | 6 +- packages/dashboard/app/components/Board.css | 29 +++++++ packages/dashboard/app/components/Board.tsx | 64 +++++++++++----- .../dashboard/app/components/ChatView.css | 41 +++++++++- .../dashboard/app/components/ChatView.tsx | 53 +++++++------ .../app/components/DockFilesView.css | 8 +- .../app/components/DockFilesView.tsx | 76 +++++++++---------- .../app/components/GitHubImportModal.css | 9 ++- .../dashboard/app/components/ScriptsModal.css | 19 ++++- .../__tests__/DockFilesView.test.tsx | 49 +++++++++++- 11 files changed, 265 insertions(+), 92 deletions(-) create mode 100644 .changeset/dashboard-view-toolbar-polish.md diff --git a/.changeset/dashboard-view-toolbar-polish.md b/.changeset/dashboard-view-toolbar-polish.md new file mode 100644 index 0000000000..6e0bb4019d --- /dev/null +++ b/.changeset/dashboard-view-toolbar-polish.md @@ -0,0 +1,3 @@ +"@runfusion/fusion": patch + +Polish dashboard view chrome: align Dashboard, Import Tasks, Automations, Chat, and docked Files editor controls with the shared view header and toolbar styling. diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index fa4db6cb27..27a4f665fa 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -2527,7 +2527,11 @@ function AppInner() { className="floating-window--chat" persistGeometryKey="kb-dashboard-chat-floating-window" defaultSize={{ width: 980, height: 680 }} - minSize={{ width: 520, height: 420 }} + /* + FNXC:ChatModal 2026-06-22-16:05: + The full Chat pop-out must be resizable into a narrower desktop utility window. ChatView already switches to its mobile one-pane layout at narrow widths, so allow the FloatingWindow to shrink below the old two-pane desktop minimum while preserving enough width for composer controls. + */ + minSize={{ width: 360, height: 420 }} > .board, +.dashboard-board-view .board-workflow-view { + flex: 1 1 auto; + height: auto; + min-height: 0; +} + +.dashboard-board-view__count { + font-size: 14px; + color: var(--text-muted); + white-space: nowrap; +} + .board.board-workflows-skeleton { display: flex; align-items: stretch; diff --git a/packages/dashboard/app/components/Board.tsx b/packages/dashboard/app/components/Board.tsx index aef57d3242..0bc472b70c 100644 --- a/packages/dashboard/app/components/Board.tsx +++ b/packages/dashboard/app/components/Board.tsx @@ -16,6 +16,9 @@ import { WorkflowSwitcher } from "./WorkflowSwitcher"; import { computeWorkflowStatusCounts } from "./workflowStatusCounts"; import { writeBoardWorkflowsCache } from "../utils/boardWorkflowsCache"; import { useBoardWorkflows } from "../hooks/useBoardWorkflows"; +import { useTranslation } from "react-i18next"; +import { LayoutDashboard } from "lucide-react"; +import { ViewHeader } from "./ViewHeader"; interface BoardProps { tasks: Task[]; @@ -146,6 +149,7 @@ function BoardWorkflowSkeleton({ empty = false }: { empty?: boolean }) { } export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, onLoadArchivedTasks, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, taskStuckTimeoutMs, onOpenMission, staleHighFanoutBlockerAgeThresholdMs, lastFetchTimeMs, prAuthAvailable, onOpenWorkflowEditor, onCreateWorkflow, workflowColumnsEnabled, settingsLoaded, workflowControlsInHeader = false }: BoardProps) { + const { t } = useTranslation("app"); const [archivedCollapsed, setArchivedCollapsed] = useState(true); const archivedLoadedRef = useRef(false); const [workflowStepNameLookup, setWorkflowStepNameLookup] = useState>(EMPTY_WORKFLOW_STEP_NAME_LOOKUP); @@ -515,9 +519,29 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask const shouldGateLegacyBoard = boardWorkflows === null ? (workflowColumnsEnabled === true || settingsLoaded === false) : boardWorkflows.flagEnabled === true && boardWorkflows.workflows.length === 0; + /* + FNXC:DashboardHeader 2026-06-22-16:05: + Dashboard/Board is a first-class left-sidebar view, so it needs the same canonical ViewHeader chrome as Artifacts, Skills, and Goals instead of letting the board columns touch the top app chrome. Keep the board itself as the scroll owner below the header so horizontal lane scrolling and mobile snap behavior are unchanged. + */ + const dashboardHeader = ( + + {t("dashboard.taskCount", "{{count}} task{{plural}}", { count: tasks.length, plural: tasks.length === 1 ? "" : "s" })} + + )} + /> + ); if (shouldGateLegacyBoard) { - return ; + return ( +
+ {dashboardHeader} + +
+ ); } if (workflowMode && selectedWorkflow) { @@ -549,20 +573,22 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask : null; return ( -
- {workflowControlsInHeader && headerWorkflowSlot ? relocatedWorkflowToolbar : workflowToolbar} -
{ - const id = (e.target as HTMLElement)?.closest?.("[data-id]")?.getAttribute("data-id"); - if (id) draggingTaskIdRef.current = id; - }} - onDragEnd={() => { - draggingTaskIdRef.current = null; - }} - > +
+ {dashboardHeader} +
+ {workflowControlsInHeader && headerWorkflowSlot ? relocatedWorkflowToolbar : workflowToolbar} +
{ + const id = (e.target as HTMLElement)?.closest?.("[data-id]")?.getAttribute("data-id"); + if (id) draggingTaskIdRef.current = id; + }} + onDragEnd={() => { + draggingTaskIdRef.current = null; + }} + > {selectedWorkflowColumns.map((columnDef) => { const isCreateColumn = columnDef.id === selectedWorkflowCreateColumnId; return ( @@ -657,13 +683,15 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask onToggleCollapse={handleToggleArchivedCollapse} /> )} -
+
+
); } return ( - <> +
+ {dashboardHeader}
{COLUMNS.map((col) => ( ))}
- +
); } diff --git a/packages/dashboard/app/components/ChatView.css b/packages/dashboard/app/components/ChatView.css index 61deaf22c9..414ae2c645 100644 --- a/packages/dashboard/app/components/ChatView.css +++ b/packages/dashboard/app/components/ChatView.css @@ -70,6 +70,21 @@ border-bottom: 1px solid var(--border); } +/* +FNXC:ChatHeader 2026-06-22-16:18: +Direct/Rooms now lives in the Chat ViewHeader immediately before New Chat. The control must scale with available header width: bounded flex-basis, minmax grid columns, and truncating labels let it fit desktop, narrow pop-out, and mobile headers without forcing the title/actions to overlap. +*/ +.chat-view-header-scope-toggle { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + flex: 1 1 clamp(128px, 24vw, 220px); + width: clamp(128px, 24vw, 220px); + min-width: min(128px, 100%); + max-width: 220px; + padding: 0; + border-bottom: none; +} + .chat-sidebar-scope-btn { flex: 1; padding: var(--space-sm) var(--space-md); @@ -81,6 +96,17 @@ transition: background var(--transition-fast), color var(--transition-fast), box-shadow var(--transition-fast); } +.chat-view-header-scope-toggle .chat-sidebar-scope-btn { + min-width: 0; + min-height: 0; + height: var(--view-header-content-row, 28px); + padding: 0 clamp(var(--space-xs), 1.2vw, var(--space-sm)); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; +} + .chat-sidebar-scope-btn:hover { background: var(--card-hover); color: var(--text); @@ -654,13 +680,26 @@ Mobile chat session switching needs a dedicated rename tap target beside each se } .chat-view-header-new-chat { - flex-shrink: 0; + flex: 0 1 auto; + min-width: fit-content; } .chat-view-header-icon { flex: 0 0 auto; } +.chat-view .view-header__actions { + min-width: 0; +} + +@media (max-width: 768px), (max-height: 480px) { + .chat-view-header-scope-toggle { + flex-basis: clamp(112px, 42vw, 180px); + width: clamp(112px, 42vw, 180px); + max-width: 180px; + } +} + /* FNXC:ChatModal 2026-06-22-13:22: The old Quick Chat panel is replaced by the full ChatView inside a movable FloatingWindow. In floating mode ChatView's shared header is the only visible modal header and doubles as the drag handle, with minimize/close controls in the same action row. diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index 68facde1d3..4cf5b1de9c 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -3216,6 +3216,34 @@ export function ChatView({ projectId, addToast, experimentalFeatures, floating = * FN-6516 refines the tablet keyboard behavior: keep the sidebar at the same persisted width while the keyboard is open instead of narrowing to the minimum. The FN-6210 CSS max-width guard remains the upper bound, and resize controls still stay disabled while typing. */ const sidebarInlineStyle: React.CSSProperties | undefined = isChatMobile ? undefined : { width: `${sidebarWidth}px` }; + /* + FNXC:ChatHeader 2026-06-22-16:18: + Direct/Rooms is a view-level scope switch, so it belongs in Chat's canonical header directly before New Chat instead of consuming the first row of the sidebar. Keep the existing test ids while moving the DOM so direct and room conversations share one header control surface. + */ + const scopeToggle = chatRoomsEnabled ? ( +
+ + +
+ ) : null; return ( /* @@ -3228,6 +3256,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures, floating = title={t("chat.title", "Chat")} actions={ <> + {scopeToggle} {!isChatMobile ? ( - - - )} {!chatRoomsEnabled || chatScope === "direct" ? ( <> {/* Search section */} diff --git a/packages/dashboard/app/components/DockFilesView.css b/packages/dashboard/app/components/DockFilesView.css index 0952c943a6..cc5c57f84d 100644 --- a/packages/dashboard/app/components/DockFilesView.css +++ b/packages/dashboard/app/components/DockFilesView.css @@ -82,10 +82,16 @@ Hidden until a file is selected; when selected it overlays the tree as the singl } .dock-files-viewer__back, -.dock-files-viewer__popout { +.dock-files-viewer__popout, +.dock-files-viewer__save { flex: 0 0 auto; } +.dock-files-viewer__save { + gap: var(--space-xs); + white-space: nowrap; +} + .dock-files-viewer__body { flex: 1 1 auto; min-height: 0; diff --git a/packages/dashboard/app/components/DockFilesView.tsx b/packages/dashboard/app/components/DockFilesView.tsx index dbe086c78a..88ee5ff4e9 100644 --- a/packages/dashboard/app/components/DockFilesView.tsx +++ b/packages/dashboard/app/components/DockFilesView.tsx @@ -1,10 +1,9 @@ import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { ArrowLeft, Maximize2 } from "lucide-react"; -import { getErrorMessage } from "@fusion/core"; +import { ArrowLeft, Maximize2, Save } from "lucide-react"; import type { PluginDashboardViewContext } from "../plugins/types"; -import { fetchWorkspaceFileContent } from "../api"; import { useWorkspaceFileBrowser } from "../hooks/useWorkspaceFileBrowser"; +import { useWorkspaceFileEditor } from "../hooks/useWorkspaceFileEditor"; import { getScopedItem, removeScopedItem, scopedKey, setScopedItem } from "../utils/projectStorage"; import { FileBrowser } from "./FileBrowser"; import { FileEditor } from "./FileEditor"; @@ -50,6 +49,7 @@ export function DockFilesView({ projectId, openFile, layout = "auto" }: DockFile // FNXC:RightDockFiles 2026-06-22-12:00: selected file drives the inline read-only viewer; null returns to the tree. // FNXC:RightDockFiles 2026-06-22-23:30: initialize from the shared scoped-storage key so the expand pop-out opens the same file the dock is showing. const [selectedFile, setSelectedFile] = useState(() => getScopedItem(DOCK_FILES_CURRENT_KEY, projectId) || null); + const [showLineNumbers, setShowLineNumbers] = useState(true); /* FNXC:RightDockFiles 2026-06-22-23:30: @@ -78,45 +78,25 @@ export function DockFilesView({ projectId, openFile, layout = "auto" }: DockFile return () => window.removeEventListener("storage", onStorage); }, [projectId]); - 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]); + /* + FNXC:RightDockFiles 2026-06-22-16:28: + The right-sidebar file viewer must be the same editor surface as the modal/mobile file browser: real workspace editor state, visible toolbar options, Preview/Edit for markdown, Line #, and Wrap. Use the shared editor hook instead of the old read-only content fetch so edits can be saved and the toolbar is not a reduced sidebar-only variant. + */ + const { + content, + setContent, + loading: contentLoading, + saving, + error: contentError, + save, + hasChanges, + } = useWorkspaceFileEditor("project", selectedFile, Boolean(selectedFile), projectId); const handleBack = useCallback(() => selectFile(null), [selectFile]); const handlePopOut = useCallback(() => { if (selectedFile) openFile?.(selectedFile, { workspace: "project" }); }, [openFile, selectedFile]); + const handleToggleLineNumbers = useCallback(() => setShowLineNumbers((current) => !current), []); const fileName = selectedFile ? selectedFile.split("/").pop() || selectedFile : ""; @@ -175,6 +155,18 @@ export function DockFilesView({ projectId, openFile, layout = "auto" }: DockFile > + {selectedFile ? ( + + ) : null}
{!selectedFile ? ( @@ -186,7 +178,15 @@ export function DockFilesView({ projectId, openFile, layout = "auto" }: DockFile ) : contentError ? (
{contentError}
) : ( - {}} readOnly filePath={selectedFile} /> + )}
diff --git a/packages/dashboard/app/components/GitHubImportModal.css b/packages/dashboard/app/components/GitHubImportModal.css index 6e95e887af..4a31431a03 100644 --- a/packages/dashboard/app/components/GitHubImportModal.css +++ b/packages/dashboard/app/components/GitHubImportModal.css @@ -1425,6 +1425,11 @@ The embedded root is a plain flow box that fills the host; the inner shell sheds width: 100%; height: 100%; min-height: 0; + /* + FNXC:ImportTasks 2026-06-22-16:05: + Import Tasks is a full main-content view, not a modal card. Match Skills/other view bodies by letting the host read as the dashboard background while the shared header owns the surface band. + */ + background: var(--bg); } /* @@ -1445,6 +1450,8 @@ Embedded root drops its uniform --space-lg padding so the header can span edge-t border-radius: 0; resize: none; padding: 0; + background: var(--bg); + border: none; } /* @@ -1484,6 +1491,7 @@ Import Tasks embedded header now adopts the canonical ViewHeader chrome — edge /* Body re-applies the horizontal + bottom inset the now-edge-to-edge header no longer provides. */ .github-import-modal--embedded .github-import-modal__body { padding: var(--space-lg) var(--space-xl) var(--space-lg); + background: var(--bg); } /* @@ -1601,4 +1609,3 @@ body. Let the content take its full intrinsic height and hand vertical scrolling display: block; } } - diff --git a/packages/dashboard/app/components/ScriptsModal.css b/packages/dashboard/app/components/ScriptsModal.css index f556c76ef8..14ff9f1e28 100644 --- a/packages/dashboard/app/components/ScriptsModal.css +++ b/packages/dashboard/app/components/ScriptsModal.css @@ -62,21 +62,26 @@ Non-Command-Center dashboard CSS must use the canonical --text token. The legacy /* ── Scheduled Tasks ──────────────────────────────────────────────── */ -/* Scheduling toolbar below modal header */ +/* +FNXC:Automations 2026-06-22-16:05: +The Automations toolbar should match Artifacts' controls row: a plain body row with standalone controls on the dashboard background, not a tinted sub-header strip with its own divider. +*/ .scheduling-toolbar { display: flex; align-items: center; justify-content: space-between; gap: var(--space-md); - padding: var(--space-sm) var(--modal-padding, var(--space-lg)); - border-bottom: 1px solid var(--border); - background: color-mix(in srgb, var(--text) 10%, transparent); + flex-wrap: wrap; + padding: var(--space-lg) var(--modal-padding, var(--space-lg)); + border-bottom: none; + background: transparent; } .scheduling-toolbar-left { display: flex; align-items: center; gap: var(--space-md); + flex-wrap: wrap; min-width: 0; } @@ -84,6 +89,7 @@ Non-Command-Center dashboard CSS must use the canonical --text token. The legacy display: flex; align-items: center; gap: var(--space-sm); + margin-left: auto; } .scheduling-count { @@ -1267,6 +1273,11 @@ With the header now edge-to-edge (its own border-bottom divider), the first body margin-bottom: var(--space-lg); } +.automations-embedded-view > .scheduling-toolbar { + padding-right: 0; + padding-left: 0; +} + /* Two-pane body: single column by default (narrow); two columns when the container is wide enough. */ .automations-two-pane { display: grid; diff --git a/packages/dashboard/app/components/__tests__/DockFilesView.test.tsx b/packages/dashboard/app/components/__tests__/DockFilesView.test.tsx index fedbd18251..84e66cc962 100644 --- a/packages/dashboard/app/components/__tests__/DockFilesView.test.tsx +++ b/packages/dashboard/app/components/__tests__/DockFilesView.test.tsx @@ -29,15 +29,34 @@ vi.mock("../../hooks/useWorkspaceFileBrowser", () => ({ })); const mockFetchContent = vi.fn(() => Promise.resolve({ content: "# hi" })); +const mockSaveContent = vi.fn(() => Promise.resolve({ mtime: "2026-01-15T10:31:00Z" })); vi.mock("../../api", () => ({ fetchWorkspaceFileContent: (...args: unknown[]) => mockFetchContent(...(args as [])), + saveWorkspaceFileContent: (...args: unknown[]) => mockSaveContent(...(args as [])), })); -// Keep the viewer simple: surface the file path it was asked to render. +const capturedFileEditorProps: Array<{ + filePath?: string; + toolbarExpanded?: boolean; + forceToolbarActionsVisible?: boolean; + showLineNumbers?: boolean; + onToggleLineNumbers?: () => void; + readOnly?: boolean; +}> = []; + +// Keep the viewer simple: surface the file path it was asked to render and capture toolbar props. vi.mock("../FileEditor", () => ({ - FileEditor: ({ filePath }: { filePath?: string }) => ( -
- ), + FileEditor: (props: { + filePath?: string; + toolbarExpanded?: boolean; + forceToolbarActionsVisible?: boolean; + showLineNumbers?: boolean; + onToggleLineNumbers?: () => void; + readOnly?: boolean; + }) => { + capturedFileEditorProps.push(props); + return
; + }, })); // Render the tree's files as buttons so we can click one. @@ -60,6 +79,8 @@ describe("DockFilesView shared current-file state", () => { beforeEach(() => { window.localStorage.clear(); mockFetchContent.mockClear(); + mockSaveContent.mockClear(); + capturedFileEditorProps.length = 0; }); afterEach(() => cleanup()); @@ -109,4 +130,24 @@ describe("DockFilesView shared current-file state", () => { expect(screen.getByTestId("mock-file-editor")).toHaveAttribute("data-file-path", "readme.md"); }); }); + + it("uses the full modal/mobile file editor toolbar in the right dock viewer", async () => { + render(); + fireEvent.click(screen.getByText("readme.md")); + + await waitFor(() => { + expect(screen.getByTestId("mock-file-editor")).toHaveAttribute("data-file-path", "readme.md"); + }); + + const latest = capturedFileEditorProps.at(-1); + expect(latest).toMatchObject({ + filePath: "readme.md", + toolbarExpanded: true, + forceToolbarActionsVisible: true, + showLineNumbers: true, + }); + expect(latest?.readOnly).toBeFalsy(); + expect(latest?.onToggleLineNumbers).toEqual(expect.any(Function)); + expect(screen.getByTestId("right-dock-files-save")).toBeDisabled(); + }); });