fix(dashboard): align view headers and toolbars
This commit is contained in:
3
.changeset/dashboard-view-toolbar-polish.md
Normal file
3
.changeset/dashboard-view-toolbar-polish.md
Normal file
@@ -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.
|
||||||
@@ -2527,7 +2527,11 @@ function AppInner() {
|
|||||||
className="floating-window--chat"
|
className="floating-window--chat"
|
||||||
persistGeometryKey="kb-dashboard-chat-floating-window"
|
persistGeometryKey="kb-dashboard-chat-floating-window"
|
||||||
defaultSize={{ width: 980, height: 680 }}
|
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 }}
|
||||||
>
|
>
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<ChatView
|
<ChatView
|
||||||
|
|||||||
@@ -2,6 +2,35 @@
|
|||||||
FNXC:BoardWorkflows 2026-06-20-09:02:
|
FNXC:BoardWorkflows 2026-06-20-09:02:
|
||||||
The board needs a neutral first-paint shell whenever workflow lanes are enabled but metadata is not ready, so users never see the legacy single-lane board flash before workflow lanes load.
|
The board needs a neutral first-paint shell whenever workflow lanes are enabled but metadata is not ready, so users never see the legacy single-lane board flash before workflow lanes load.
|
||||||
*/
|
*/
|
||||||
|
.dashboard-board-view {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:DashboardHeader 2026-06-22-16:05:
|
||||||
|
The Dashboard board now sits under the shared ViewHeader like Artifacts/Skills. The board remains the scroll container below the header, so reset the old full-height assumption within this shell and let flex allocate the remaining height.
|
||||||
|
*/
|
||||||
|
.dashboard-board-view > .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 {
|
.board.board-workflows-skeleton {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ import { WorkflowSwitcher } from "./WorkflowSwitcher";
|
|||||||
import { computeWorkflowStatusCounts } from "./workflowStatusCounts";
|
import { computeWorkflowStatusCounts } from "./workflowStatusCounts";
|
||||||
import { writeBoardWorkflowsCache } from "../utils/boardWorkflowsCache";
|
import { writeBoardWorkflowsCache } from "../utils/boardWorkflowsCache";
|
||||||
import { useBoardWorkflows } from "../hooks/useBoardWorkflows";
|
import { useBoardWorkflows } from "../hooks/useBoardWorkflows";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { LayoutDashboard } from "lucide-react";
|
||||||
|
import { ViewHeader } from "./ViewHeader";
|
||||||
|
|
||||||
interface BoardProps {
|
interface BoardProps {
|
||||||
tasks: Task[];
|
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) {
|
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 [archivedCollapsed, setArchivedCollapsed] = useState(true);
|
||||||
const archivedLoadedRef = useRef(false);
|
const archivedLoadedRef = useRef(false);
|
||||||
const [workflowStepNameLookup, setWorkflowStepNameLookup] = useState<ReadonlyMap<string, string>>(EMPTY_WORKFLOW_STEP_NAME_LOOKUP);
|
const [workflowStepNameLookup, setWorkflowStepNameLookup] = useState<ReadonlyMap<string, string>>(EMPTY_WORKFLOW_STEP_NAME_LOOKUP);
|
||||||
@@ -515,9 +519,29 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
|
|||||||
const shouldGateLegacyBoard = boardWorkflows === null
|
const shouldGateLegacyBoard = boardWorkflows === null
|
||||||
? (workflowColumnsEnabled === true || settingsLoaded === false)
|
? (workflowColumnsEnabled === true || settingsLoaded === false)
|
||||||
: boardWorkflows.flagEnabled === true && boardWorkflows.workflows.length === 0;
|
: 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 = (
|
||||||
|
<ViewHeader
|
||||||
|
icon={LayoutDashboard}
|
||||||
|
title={t("dashboard.title", "Dashboard")}
|
||||||
|
actions={(
|
||||||
|
<span className="dashboard-board-view__count">
|
||||||
|
{t("dashboard.taskCount", "{{count}} task{{plural}}", { count: tasks.length, plural: tasks.length === 1 ? "" : "s" })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
if (shouldGateLegacyBoard) {
|
if (shouldGateLegacyBoard) {
|
||||||
return <BoardWorkflowSkeleton empty={boardWorkflows?.flagEnabled === true} />;
|
return (
|
||||||
|
<div className="dashboard-board-view">
|
||||||
|
{dashboardHeader}
|
||||||
|
<BoardWorkflowSkeleton empty={boardWorkflows?.flagEnabled === true} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (workflowMode && selectedWorkflow) {
|
if (workflowMode && selectedWorkflow) {
|
||||||
@@ -549,20 +573,22 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="board-workflow-view">
|
<div className="dashboard-board-view">
|
||||||
{workflowControlsInHeader && headerWorkflowSlot ? relocatedWorkflowToolbar : workflowToolbar}
|
{dashboardHeader}
|
||||||
<main
|
<div className="board-workflow-view">
|
||||||
className="board board-workflow-columns"
|
{workflowControlsInHeader && headerWorkflowSlot ? relocatedWorkflowToolbar : workflowToolbar}
|
||||||
id="board"
|
<main
|
||||||
ref={boardRef}
|
className="board board-workflow-columns"
|
||||||
onDragStart={(e) => {
|
id="board"
|
||||||
const id = (e.target as HTMLElement)?.closest?.("[data-id]")?.getAttribute("data-id");
|
ref={boardRef}
|
||||||
if (id) draggingTaskIdRef.current = id;
|
onDragStart={(e) => {
|
||||||
}}
|
const id = (e.target as HTMLElement)?.closest?.("[data-id]")?.getAttribute("data-id");
|
||||||
onDragEnd={() => {
|
if (id) draggingTaskIdRef.current = id;
|
||||||
draggingTaskIdRef.current = null;
|
}}
|
||||||
}}
|
onDragEnd={() => {
|
||||||
>
|
draggingTaskIdRef.current = null;
|
||||||
|
}}
|
||||||
|
>
|
||||||
{selectedWorkflowColumns.map((columnDef) => {
|
{selectedWorkflowColumns.map((columnDef) => {
|
||||||
const isCreateColumn = columnDef.id === selectedWorkflowCreateColumnId;
|
const isCreateColumn = columnDef.id === selectedWorkflowCreateColumnId;
|
||||||
return (
|
return (
|
||||||
@@ -657,13 +683,15 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
|
|||||||
onToggleCollapse={handleToggleArchivedCollapse}
|
onToggleCollapse={handleToggleArchivedCollapse}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="dashboard-board-view">
|
||||||
|
{dashboardHeader}
|
||||||
<main className="board" id="board" ref={boardRef}>
|
<main className="board" id="board" ref={boardRef}>
|
||||||
{COLUMNS.map((col) => (
|
{COLUMNS.map((col) => (
|
||||||
<Column
|
<Column
|
||||||
@@ -706,6 +734,6 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</main>
|
</main>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,21 @@
|
|||||||
border-bottom: 1px solid var(--border);
|
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 {
|
.chat-sidebar-scope-btn {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: var(--space-sm) var(--space-md);
|
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);
|
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 {
|
.chat-sidebar-scope-btn:hover {
|
||||||
background: var(--card-hover);
|
background: var(--card-hover);
|
||||||
color: var(--text);
|
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 {
|
.chat-view-header-new-chat {
|
||||||
flex-shrink: 0;
|
flex: 0 1 auto;
|
||||||
|
min-width: fit-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-view-header-icon {
|
.chat-view-header-icon {
|
||||||
flex: 0 0 auto;
|
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:
|
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.
|
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.
|
||||||
|
|||||||
@@ -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.
|
* 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` };
|
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 ? (
|
||||||
|
<div className="chat-sidebar-scope-toggle chat-view-header-scope-toggle" role="tablist" data-testid="chat-sidebar-scope-toggle">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
className={`chat-sidebar-scope-btn${chatScope === "direct" ? " chat-sidebar-scope-btn--active" : ""}`}
|
||||||
|
aria-selected={chatScope === "direct"}
|
||||||
|
data-testid="chat-sidebar-scope-direct"
|
||||||
|
onClick={() => setChatScope("direct")}
|
||||||
|
>
|
||||||
|
{t("chat.scopeDirect", "Direct")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
className={`chat-sidebar-scope-btn${chatScope === "rooms" ? " chat-sidebar-scope-btn--active" : ""}`}
|
||||||
|
aria-selected={chatScope === "rooms"}
|
||||||
|
data-testid="chat-sidebar-scope-rooms"
|
||||||
|
onClick={() => setChatScope("rooms")}
|
||||||
|
>
|
||||||
|
{t("chat.scopeRooms", "Rooms")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
/*
|
/*
|
||||||
@@ -3228,6 +3256,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures, floating =
|
|||||||
title={t("chat.title", "Chat")}
|
title={t("chat.title", "Chat")}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
|
{scopeToggle}
|
||||||
{!isChatMobile ? (
|
{!isChatMobile ? (
|
||||||
<button
|
<button
|
||||||
className="btn btn-sm btn-primary chat-view-header-new-chat"
|
className="btn btn-sm btn-primary chat-view-header-new-chat"
|
||||||
@@ -3295,30 +3324,6 @@ export function ChatView({ projectId, addToast, experimentalFeatures, floating =
|
|||||||
className={`chat-sidebar${!sidebarVisible ? " chat-sidebar--hidden" : ""}`}
|
className={`chat-sidebar${!sidebarVisible ? " chat-sidebar--hidden" : ""}`}
|
||||||
style={sidebarInlineStyle}
|
style={sidebarInlineStyle}
|
||||||
>
|
>
|
||||||
{chatRoomsEnabled && (
|
|
||||||
<div className="chat-sidebar-scope-toggle" role="tablist" data-testid="chat-sidebar-scope-toggle">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
className={`chat-sidebar-scope-btn${chatScope === "direct" ? " chat-sidebar-scope-btn--active" : ""}`}
|
|
||||||
aria-selected={chatScope === "direct"}
|
|
||||||
data-testid="chat-sidebar-scope-direct"
|
|
||||||
onClick={() => setChatScope("direct")}
|
|
||||||
>
|
|
||||||
{t("chat.scopeDirect", "Direct")}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
className={`chat-sidebar-scope-btn${chatScope === "rooms" ? " chat-sidebar-scope-btn--active" : ""}`}
|
|
||||||
aria-selected={chatScope === "rooms"}
|
|
||||||
data-testid="chat-sidebar-scope-rooms"
|
|
||||||
onClick={() => setChatScope("rooms")}
|
|
||||||
>
|
|
||||||
{t("chat.scopeRooms", "Rooms")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!chatRoomsEnabled || chatScope === "direct" ? (
|
{!chatRoomsEnabled || chatScope === "direct" ? (
|
||||||
<>
|
<>
|
||||||
{/* Search section */}
|
{/* Search section */}
|
||||||
|
|||||||
@@ -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__back,
|
||||||
.dock-files-viewer__popout {
|
.dock-files-viewer__popout,
|
||||||
|
.dock-files-viewer__save {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dock-files-viewer__save {
|
||||||
|
gap: var(--space-xs);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.dock-files-viewer__body {
|
.dock-files-viewer__body {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ArrowLeft, Maximize2 } from "lucide-react";
|
import { ArrowLeft, Maximize2, Save } from "lucide-react";
|
||||||
import { getErrorMessage } from "@fusion/core";
|
|
||||||
import type { PluginDashboardViewContext } from "../plugins/types";
|
import type { PluginDashboardViewContext } from "../plugins/types";
|
||||||
import { fetchWorkspaceFileContent } from "../api";
|
|
||||||
import { useWorkspaceFileBrowser } from "../hooks/useWorkspaceFileBrowser";
|
import { useWorkspaceFileBrowser } from "../hooks/useWorkspaceFileBrowser";
|
||||||
|
import { useWorkspaceFileEditor } from "../hooks/useWorkspaceFileEditor";
|
||||||
import { getScopedItem, removeScopedItem, scopedKey, setScopedItem } from "../utils/projectStorage";
|
import { getScopedItem, removeScopedItem, scopedKey, setScopedItem } from "../utils/projectStorage";
|
||||||
import { FileBrowser } from "./FileBrowser";
|
import { FileBrowser } from "./FileBrowser";
|
||||||
import { FileEditor } from "./FileEditor";
|
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-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.
|
// 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<string | null>(() => getScopedItem(DOCK_FILES_CURRENT_KEY, projectId) || null);
|
const [selectedFile, setSelectedFile] = useState<string | null>(() => getScopedItem(DOCK_FILES_CURRENT_KEY, projectId) || null);
|
||||||
|
const [showLineNumbers, setShowLineNumbers] = useState(true);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
FNXC:RightDockFiles 2026-06-22-23:30:
|
FNXC:RightDockFiles 2026-06-22-23:30:
|
||||||
@@ -78,45 +78,25 @@ export function DockFilesView({ projectId, openFile, layout = "auto" }: DockFile
|
|||||||
return () => window.removeEventListener("storage", onStorage);
|
return () => window.removeEventListener("storage", onStorage);
|
||||||
}, [projectId]);
|
}, [projectId]);
|
||||||
|
|
||||||
const [content, setContent] = useState<string>("");
|
/*
|
||||||
const [contentLoading, setContentLoading] = useState(false);
|
FNXC:RightDockFiles 2026-06-22-16:28:
|
||||||
const [contentError, setContentError] = useState<string | null>(null);
|
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.
|
||||||
|
*/
|
||||||
// Load the selected file's content read-only from the project workspace.
|
const {
|
||||||
useEffect(() => {
|
content,
|
||||||
if (!selectedFile) {
|
setContent,
|
||||||
setContent("");
|
loading: contentLoading,
|
||||||
setContentError(null);
|
saving,
|
||||||
return;
|
error: contentError,
|
||||||
}
|
save,
|
||||||
|
hasChanges,
|
||||||
let cancelled = false;
|
} = useWorkspaceFileEditor("project", selectedFile, Boolean(selectedFile), projectId);
|
||||||
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(() => selectFile(null), [selectFile]);
|
const handleBack = useCallback(() => selectFile(null), [selectFile]);
|
||||||
const handlePopOut = useCallback(() => {
|
const handlePopOut = useCallback(() => {
|
||||||
if (selectedFile) openFile?.(selectedFile, { workspace: "project" });
|
if (selectedFile) openFile?.(selectedFile, { workspace: "project" });
|
||||||
}, [openFile, selectedFile]);
|
}, [openFile, selectedFile]);
|
||||||
|
const handleToggleLineNumbers = useCallback(() => setShowLineNumbers((current) => !current), []);
|
||||||
|
|
||||||
const fileName = selectedFile ? selectedFile.split("/").pop() || selectedFile : "";
|
const fileName = selectedFile ? selectedFile.split("/").pop() || selectedFile : "";
|
||||||
|
|
||||||
@@ -175,6 +155,18 @@ export function DockFilesView({ projectId, openFile, layout = "auto" }: DockFile
|
|||||||
>
|
>
|
||||||
<Maximize2 size={14} />
|
<Maximize2 size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
{selectedFile ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-primary dock-files-viewer__save"
|
||||||
|
onClick={() => void save()}
|
||||||
|
disabled={!hasChanges || saving}
|
||||||
|
data-testid="right-dock-files-save"
|
||||||
|
>
|
||||||
|
<Save size={14} />
|
||||||
|
{saving ? t("fileBrowser.saving", "Saving…") : t("actions.save", "Save")}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="dock-files-viewer__body">
|
<div className="dock-files-viewer__body">
|
||||||
{!selectedFile ? (
|
{!selectedFile ? (
|
||||||
@@ -186,7 +178,15 @@ export function DockFilesView({ projectId, openFile, layout = "auto" }: DockFile
|
|||||||
) : contentError ? (
|
) : contentError ? (
|
||||||
<div className="dock-files-viewer__status dock-files-viewer__status--error">{contentError}</div>
|
<div className="dock-files-viewer__status dock-files-viewer__status--error">{contentError}</div>
|
||||||
) : (
|
) : (
|
||||||
<FileEditor content={content} onChange={() => {}} readOnly filePath={selectedFile} />
|
<FileEditor
|
||||||
|
content={content}
|
||||||
|
onChange={setContent}
|
||||||
|
filePath={selectedFile}
|
||||||
|
showLineNumbers={showLineNumbers}
|
||||||
|
onToggleLineNumbers={handleToggleLineNumbers}
|
||||||
|
toolbarExpanded
|
||||||
|
forceToolbarActionsVisible
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1425,6 +1425,11 @@ The embedded root is a plain flow box that fills the host; the inner shell sheds
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
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;
|
border-radius: 0;
|
||||||
resize: none;
|
resize: none;
|
||||||
padding: 0;
|
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. */
|
/* Body re-applies the horizontal + bottom inset the now-edge-to-edge header no longer provides. */
|
||||||
.github-import-modal--embedded .github-import-modal__body {
|
.github-import-modal--embedded .github-import-modal__body {
|
||||||
padding: var(--space-lg) var(--space-xl) var(--space-lg);
|
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;
|
display: block;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,21 +62,26 @@ Non-Command-Center dashboard CSS must use the canonical --text token. The legacy
|
|||||||
|
|
||||||
/* ── Scheduled Tasks ──────────────────────────────────────────────── */
|
/* ── 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 {
|
.scheduling-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: var(--space-md);
|
gap: var(--space-md);
|
||||||
padding: var(--space-sm) var(--modal-padding, var(--space-lg));
|
flex-wrap: wrap;
|
||||||
border-bottom: 1px solid var(--border);
|
padding: var(--space-lg) var(--modal-padding, var(--space-lg));
|
||||||
background: color-mix(in srgb, var(--text) 10%, transparent);
|
border-bottom: none;
|
||||||
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scheduling-toolbar-left {
|
.scheduling-toolbar-left {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-md);
|
gap: var(--space-md);
|
||||||
|
flex-wrap: wrap;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +89,7 @@ Non-Command-Center dashboard CSS must use the canonical --text token. The legacy
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scheduling-count {
|
.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);
|
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. */
|
/* Two-pane body: single column by default (narrow); two columns when the container is wide enough. */
|
||||||
.automations-two-pane {
|
.automations-two-pane {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -29,15 +29,34 @@ vi.mock("../../hooks/useWorkspaceFileBrowser", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const mockFetchContent = vi.fn(() => Promise.resolve({ content: "# hi" }));
|
const mockFetchContent = vi.fn(() => Promise.resolve({ content: "# hi" }));
|
||||||
|
const mockSaveContent = vi.fn(() => Promise.resolve({ mtime: "2026-01-15T10:31:00Z" }));
|
||||||
vi.mock("../../api", () => ({
|
vi.mock("../../api", () => ({
|
||||||
fetchWorkspaceFileContent: (...args: unknown[]) => mockFetchContent(...(args as [])),
|
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", () => ({
|
vi.mock("../FileEditor", () => ({
|
||||||
FileEditor: ({ filePath }: { filePath?: string }) => (
|
FileEditor: (props: {
|
||||||
<div data-testid="mock-file-editor" data-file-path={filePath} />
|
filePath?: string;
|
||||||
),
|
toolbarExpanded?: boolean;
|
||||||
|
forceToolbarActionsVisible?: boolean;
|
||||||
|
showLineNumbers?: boolean;
|
||||||
|
onToggleLineNumbers?: () => void;
|
||||||
|
readOnly?: boolean;
|
||||||
|
}) => {
|
||||||
|
capturedFileEditorProps.push(props);
|
||||||
|
return <div data-testid="mock-file-editor" data-file-path={props.filePath} />;
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Render the tree's files as buttons so we can click one.
|
// Render the tree's files as buttons so we can click one.
|
||||||
@@ -60,6 +79,8 @@ describe("DockFilesView shared current-file state", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
window.localStorage.clear();
|
window.localStorage.clear();
|
||||||
mockFetchContent.mockClear();
|
mockFetchContent.mockClear();
|
||||||
|
mockSaveContent.mockClear();
|
||||||
|
capturedFileEditorProps.length = 0;
|
||||||
});
|
});
|
||||||
afterEach(() => cleanup());
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
@@ -109,4 +130,24 @@ describe("DockFilesView shared current-file state", () => {
|
|||||||
expect(screen.getByTestId("mock-file-editor")).toHaveAttribute("data-file-path", "readme.md");
|
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(<DockFilesView projectId={PROJECT_ID} layout="auto" />);
|
||||||
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user