From 4850e4309e2cafb1ef4ec2f2ccab84ffc98e208e Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 03:57:10 -0700 Subject: [PATCH] fix(dashboard): apply code-review fixes + live UI tweaks Code review (8 reviewers) fixes: - P1: embedded board task-detail now passes onRequestClose so delete/merge/retry dismiss the panel (no ghost task). - P1: RightDock + TerminalModal drag handlers add pointercancel + unmount teardown (no leaked document listeners / rAF / stuck userSelect); remove dead onOpenChange prop. - Planning slot DOM-poll interval now caps (self-cancels on mobile); heartbeat slider persist debounced + mounted-guarded. - Cleanup: remove dead ViewHeader-migration CSS (Skills/Insights), extract shared GithubIcon, move Activity Log embedded CSS to ActivityLogModal.css, FNXC date-format fixes, AGENTS lazy-view note. - Update stale RightDock roster tests + LeftSidebarNav 'Compound Eng' test to current behavior. Live UI tweaks: - Default load lands on board, never the Command Center Dashboard. - View Board/View Agents moved to the Command Center Overview tab (under the live-activity strip). - Heartbeat card full-width with spaced, wrapping controls. - Dev Server panel scrolls in the dock; ChatView right-pane title takes the full line. - Files pop-out renders deterministic left-right two-pane. - Mailbox list pane narrower + narrow by default; Add Goal button height matches the Compound stage button; Memory Working-Memory tab no longer overlaps; Skills view fills full width. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 2 + packages/dashboard/app/App.tsx | 5 + .../app/components/ActivityLogModal.css | 99 ++++++++++ .../app/components/ActivityLogModal.tsx | 6 +- .../dashboard/app/components/ChatView.css | 15 ++ .../app/components/DevServerView.css | 9 + .../app/components/DockFilesView.css | 49 ++++- .../app/components/DockFilesView.tsx | 33 +++- .../app/components/GitHubImportModal.tsx | 5 +- .../dashboard/app/components/GithubIcon.tsx | 14 ++ .../dashboard/app/components/GoalsView.tsx | 3 +- packages/dashboard/app/components/Header.tsx | 3 +- .../dashboard/app/components/InsightsView.css | 3 +- .../app/components/LeftSidebarNav.tsx | 16 +- .../dashboard/app/components/MailboxView.tsx | 8 +- .../dashboard/app/components/MemoryView.css | 18 ++ .../PlanningWorkflowSwitcherSlot.tsx | 8 +- .../dashboard/app/components/RightDock.css | 8 + .../dashboard/app/components/RightDock.tsx | 32 +++- .../app/components/RightDockExpandModal.tsx | 6 +- .../dashboard/app/components/ScriptsModal.css | 94 +--------- .../dashboard/app/components/SkillsView.css | 45 +---- .../app/components/TerminalModal.tsx | 28 +++ .../app/components/__tests__/App.test.tsx | 2 +- .../components/__tests__/GoalsView.test.tsx | 2 +- .../__tests__/LeftSidebarNav.test.tsx | 6 +- .../components/__tests__/MemoryView.test.tsx | 2 +- .../components/__tests__/RightDock.test.tsx | 171 ++++++++++-------- .../__tests__/navigation-history.test.tsx | 4 +- .../command-center/CommandCenter.css | 14 ++ .../command-center/CommandCenter.tsx | 30 ++- .../command-center/areas/TeamArea.tsx | 73 ++++---- .../components/command-center/areas/areas.css | 28 ++- .../app/components/overflowViewRegistry.tsx | 18 +- .../app/components/useRightDockController.tsx | 7 +- .../app/hooks/__tests__/useViewState.test.ts | 11 ++ packages/dashboard/app/hooks/useViewState.ts | 14 +- .../dashboard/__tests__/theme-tokens.test.ts | 2 +- 38 files changed, 573 insertions(+), 320 deletions(-) create mode 100644 packages/dashboard/app/components/ActivityLogModal.css create mode 100644 packages/dashboard/app/components/GithubIcon.tsx diff --git a/AGENTS.md b/AGENTS.md index dca5e5bab8..9887d7cad5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -245,6 +245,8 @@ Keep this AGENTS inventory in sync with App lazy imports, AppModals lazy modal i - `PiExtensionsManager` - `AgentDetailView` +Note: the embedded main-content views ImportTasks (`_ImportTasksView`) and Automations (`_AutomationsView`) in App.tsx are `_`-prefixed lazy splits that reuse the GitHubImportModal/ScheduledTasksModal chunks. They are intentionally excluded from the curated list above and from the count; `lazy-loaded-views-docs.test.ts` filters out `_`-prefixed lazy consts (`extractAppLazyViews`), so do not add them as bullets. + ## FNXC_LOG comments: - Please whenever you're working on a codebase. I want you to add comments describing the date of the change (must be in this format yyyy-MM-dd-hh:mm) and describing the requirements or the change in requirements that made you implement certain functionality. - I want you to write FNXC:Area-of-product in front of all your comments so they can be grepped. diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index bbf2b1a102..beca443601 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -2066,6 +2066,11 @@ function AppInner() { onRetryTask={retryTask} onResetTask={resetTask} onDuplicateTask={duplicateTask} + /* + FNXC:Navigation 2026-06-22-09:00: + The full-panel task-detail must dismiss back to the board when a destructive/terminal action (delete/merge/archive/retry/reset/duplicate) fires, mirroring the modal path. Without onRequestClose the panel kept showing a ghost of the just-acted-on task. + */ + onRequestClose={closeTaskDetailMainPanel} onTaskUpdated={(updatedTask) => { setMainPanelDetailTask((previous) => { if (!previous || previous.id !== updatedTask.id) return previous; diff --git a/packages/dashboard/app/components/ActivityLogModal.css b/packages/dashboard/app/components/ActivityLogModal.css new file mode 100644 index 0000000000..32c260daa8 --- /dev/null +++ b/packages/dashboard/app/components/ActivityLogModal.css @@ -0,0 +1,99 @@ +/* +FNXC:RightDockEmbedded 2026-06-22-12:00: +Activity Log embedded (right-dock) styles live here with the ActivityLogModal component. These rules were previously appended to ScriptsModal.css by mistake; the gm/automation rules stay in ScriptsModal.css. The base .activity-log-* modal rules still live in ScriptsModal.css (imported by ActivityLogModal.tsx) pending a full extraction. +*/ + +/* +FNXC:RightDockEmbedded 2026-06-22-00:00: +Right-dock redesign renders the activity log inline inside the dock container instead of as a fixed popup overlay. +The embedded root is a plain flow box that fills the dock; the inner panel sheds overlay chrome (fixed sizing, shadow, radius, resize) and fills 100% of the host so the dock owns the frame and its own header/close. +*/ +.activity-log-embedded.right-dock-embedded-view { + display: flex; + width: 100%; + height: 100%; + min-height: 0; +} + +.activity-log-modal--embedded { + width: 100%; + height: 100%; + max-width: none; + max-height: none; + box-shadow: none; + border-radius: 0; + resize: none; + /* + FNXC:RightDockEmbedded 2026-06-22-00:00: + The dock is narrow (~280-420px) while the viewport stays desktop, so the view's @media (max-width:768px) mobile + rules never fire. Make the embedded panel an inline-size query container so the dock width — not the viewport — + drives the mobile single-column layout below. See the @container activity-log-embedded block below. + */ + container-type: inline-size; + container-name: activity-log-embedded; +} + +/* +FNXC:RightDockEmbedded 2026-06-22-00:00: +Mirror the phone-width (@media max-width:768px) activity-log layout-stacking rules for the narrow dock, scoped to the +embedded variant. Header wraps (title + close on top row, actions/filters stack full-width), filters/selects go 100%, +the active-filters bar wraps, and entry headers/details/text wrap instead of overflowing horizontally. Only layout +stacking is mirrored; behavior and the real @media rules are untouched. +*/ +@container activity-log-embedded (max-width: 560px) { + .activity-log-modal--embedded .activity-log-header { + flex-wrap: wrap; + gap: var(--space-sm); + padding: var(--space-md) var(--space-lg); + } + + .activity-log-modal--embedded .activity-log-title { + flex: 1 1 auto; + order: 0; + } + + .activity-log-modal--embedded .activity-log-actions { + flex: 1 1 100%; + flex-wrap: wrap; + gap: var(--space-xs); + order: 2; + } + + .activity-log-modal--embedded .activity-log-filter, + .activity-log-modal--embedded .activity-log-filter--project { + flex: 1 1 0; + min-width: 0; + } + + .activity-log-modal--embedded .activity-log-filter-select { + width: 100%; + } + + .activity-log-modal--embedded .activity-log-active-filters { + flex-wrap: wrap; + padding: var(--space-sm) var(--space-lg); + gap: var(--space-xs); + } + + .activity-log-modal--embedded .activity-log-clear-filters { + margin-left: 0; + } + + .activity-log-modal--embedded .activity-log-content { + padding: var(--space-md) var(--space-lg); + } + + .activity-log-modal--embedded .activity-log-entry-header { + flex-wrap: wrap; + gap: var(--space-xs); + } + + .activity-log-modal--embedded .activity-log-entry-details { + flex-wrap: wrap; + word-break: break-word; + } + + .activity-log-modal--embedded .activity-log-entry-text { + word-break: break-word; + } +} diff --git a/packages/dashboard/app/components/ActivityLogModal.tsx b/packages/dashboard/app/components/ActivityLogModal.tsx index 24045d2c2b..8c32ea151d 100644 --- a/packages/dashboard/app/components/ActivityLogModal.tsx +++ b/packages/dashboard/app/components/ActivityLogModal.tsx @@ -1,6 +1,8 @@ -// ActivityLogModal styles (.activity-log-*, .activity-icon, etc.) currently live -// in ScriptsModal.css. Until extracted, import that file so this eager modal is styled. +// Base ActivityLogModal styles (.activity-log-*, .activity-icon, etc.) currently live +// in ScriptsModal.css. Until fully extracted, import that file so this eager modal is styled. import "./ScriptsModal.css"; +// Embedded (right-dock) activity-log styles were extracted to their own file next to this component. +import "./ActivityLogModal.css"; import { useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; diff --git a/packages/dashboard/app/components/ChatView.css b/packages/dashboard/app/components/ChatView.css index b302e8b1a0..3111c9670d 100644 --- a/packages/dashboard/app/components/ChatView.css +++ b/packages/dashboard/app/components/ChatView.css @@ -397,17 +397,31 @@ gap: 8px; } +/* +FNXC:Chat 2026-06-22-15:30: +The active-chat pane header must give the title the full available line to the LEFT of the actions. The identity row grows (flex:1, min-width:0) and the title truncates with an ellipsis instead of being squeezed to one-word-per-line by the model badge, eye/preview toggle, and New Chat button. Those action controls stay flex-shrink:0 so they keep their size and the title absorbs all slack. +*/ .chat-thread-header-identity { display: inline-flex; align-items: center; gap: var(--space-sm); + flex: 1 1 auto; min-width: 0; } .chat-thread-header-title { + flex: 1 1 auto; font-weight: 600; font-size: 15px; min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Model badge keeps its intrinsic size next to the truncating title. */ +.chat-thread-header-identity .chat-model-tag { + flex-shrink: 0; } .chat-mobile-session-menu { @@ -662,6 +676,7 @@ Mobile chat session switching needs a dedicated rename tap target beside each se * used to live inside every assistant bubble. */ .chat-thread-header-render-toggle { margin-left: auto; + flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; diff --git a/packages/dashboard/app/components/DevServerView.css b/packages/dashboard/app/components/DevServerView.css index 4f4bf5251c..c5c382f970 100644 --- a/packages/dashboard/app/components/DevServerView.css +++ b/packages/dashboard/app/components/DevServerView.css @@ -731,9 +731,18 @@ actions wrap, candidate rows wrap, and logs/preview keep a bounded min-height. T vertically (.dev-server-view overflow-y:auto), so each panel just needs to be full-width and not overflow. */ @container right-dock-body (max-width: 768px) { + /* + FNXC:DevServer 2026-06-22-15:30: + In the narrow right dock the view is a flex child of .right-dock__body. Force the single-column flex layout and make + the view the bounded vertical scroll owner (flex:1 + min-height:0 + overflow-y:auto, inherited from base) so a tall + panel scrolls within the dock instead of overflowing it. + */ .dev-server-view { display: flex; + flex: 1; flex-direction: column; + min-height: 0; + overflow-y: auto; padding: 0 var(--space-md) var(--space-md); } diff --git a/packages/dashboard/app/components/DockFilesView.css b/packages/dashboard/app/components/DockFilesView.css index 2e912673b1..0952c943a6 100644 --- a/packages/dashboard/app/components/DockFilesView.css +++ b/packages/dashboard/app/components/DockFilesView.css @@ -11,6 +11,9 @@ The root is a query container (container-type: inline-size, container-name: dock FNXC:Files 2026-06-22-01:00: Breakpoint lowered 720px -> 640px and root forced to width:100%. The expand modal body has horizontal padding/overflow, so the root's content-box landed just under 720px at common laptop widths and the query never fired, leaving the pop-out stacked. 640px triggers two-pane for any realistic pop-out while staying above the narrow dock width. + +FNXC:RightDockFiles 2026-06-22-15:00: +DETERMINISTIC replacement for the container query in the pop-out. The @container rule kept missing inside RightDockExpandModal (the root content-box measured under the breakpoint despite width:100%, because the modal body's flex/overflow context never gave the root the expected inline-size), so the pop-out stayed stacked. The expand host now passes `layout="two-pane"` -> `.dock-files-view--two-pane`, which forces the LEFT|RIGHT split with NO container-query gate. The @container path below is kept ONLY for the default `auto` (dock) layout. */ .dock-files-view { display: flex; @@ -24,12 +27,12 @@ Breakpoint lowered 720px -> 640px and root forced to width:100%. The expand moda the @container breakpoint never fired in the expand modal and the layout stayed stacked. Pair with width:100%. */ width: 100%; - /* FNXC:Files — establish the query container so child panes can respond to the dock vs expand-modal width. */ + /* FNXC:RightDockFiles 2026-06-22-12:00: establish the query container so child panes can respond to the dock vs expand-modal width. */ container-type: inline-size; container-name: dock-files; } -/* FNXC:Files — NARROW default: tree fills the root as the single panel. */ +/* FNXC:RightDockFiles 2026-06-22-12:00: NARROW default: tree fills the root as the single panel. */ .dock-files-view__tree { display: flex; flex-direction: column; @@ -39,7 +42,7 @@ Breakpoint lowered 720px -> 640px and root forced to width:100%. The expand moda } /* -FNXC:Files — NARROW default: viewer is the stacked second panel. +FNXC:RightDockFiles 2026-06-22-12:00: NARROW default: viewer is the stacked second panel. Hidden until a file is selected; when selected it overlays the tree as the single visible panel (the tree is hidden below). */ .dock-files-view__viewer { @@ -106,7 +109,7 @@ Hidden until a file is selected; when selected it overlays the tree as the singl color: var(--danger, var(--text)); } -/* FNXC:Files — empty-state placeholder shown in the wide right pane until a file is selected. */ +/* FNXC:RightDockFiles 2026-06-22-12:00: empty-state placeholder shown in the wide right pane until a file is selected. */ .dock-files-viewer__empty { display: flex; align-items: center; @@ -152,3 +155,41 @@ Both panes are always visible; data-selected no longer toggles visibility here. display: none; } } + +/* +FNXC:RightDockFiles 2026-06-22-15:00: +DETERMINISTIC two-pane layout for the RightDockExpandModal pop-out. Driven by the `.dock-files-view--two-pane` modifier (DockFilesView layout="two-pane"), NOT by any @container width, so it always renders LEFT|RIGHT regardless of how the modal body measures the root's inline-size. Mirrors the @container rules above but unconditionally. +- Tree pinned LEFT: clamped/resizable-feel fixed width, scrolls independently, divider against the viewer. +- Viewer fills the RIGHT, scrolls independently, empty-state until a file is selected. +- data-selected never toggles pane visibility here (both panes always visible); BACK is hidden because the tree never disappears. +*/ +.dock-files-view--two-pane { + flex-direction: row; +} + +.dock-files-view--two-pane .dock-files-view__tree { + display: flex; + flex: 0 0 clamp(220px, 32%, 360px); + min-width: 0; + min-height: 0; + overflow: auto; + border-right: 1px solid var(--border); +} + +.dock-files-view--two-pane .dock-files-view__viewer, +.dock-files-view--two-pane[data-selected="true"] .dock-files-view__viewer { + display: flex; + flex: 1 1 auto; + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.dock-files-view--two-pane[data-selected="true"] .dock-files-view__tree { + display: flex; +} + +/* BACK is meaningless when the tree is always visible in the two-pane split. */ +.dock-files-view--two-pane .dock-files-view__viewer .dock-files-viewer__back { + display: none; +} diff --git a/packages/dashboard/app/components/DockFilesView.tsx b/packages/dashboard/app/components/DockFilesView.tsx index 353339d043..13e6ea95d0 100644 --- a/packages/dashboard/app/components/DockFilesView.tsx +++ b/packages/dashboard/app/components/DockFilesView.tsx @@ -12,6 +12,13 @@ import "./DockFilesView.css"; interface DockFilesViewProps { projectId?: string; openFile?: PluginDashboardViewContext["openFile"]; + /* + FNXC:RightDockFiles 2026-06-22-15:00: + Deterministic layout selector, replacing the fragile container-query-only approach. + - "auto" (default, compact right dock): keep the container-query single-panel stack (tree, then viewer overlays on select). + - "two-pane" (RightDockExpandModal pop-out): force the LEFT|RIGHT split (tree left, viewer right) via a root modifier class, NOT gated by any @container width. The container query never reliably fired inside the modal body (the content-box landed under the breakpoint), so the pop-out kept stacking. + */ + layout?: "auto" | "two-pane"; } /* @@ -21,15 +28,18 @@ Clicking a file in the tree sets local `selectedFile` (it does NOT call `openFil The viewer header carries a BACK button (clears `selectedFile`, returning to the tree) and a POP-OUT button that calls `openFile(path, { workspace: "project" })` to escalate to the existing resizable/movable modal. This preserves the modal path; it is now opt-in via pop-out rather than the default click behavior. FNXC:Files 2026-06-22-00:00: -Responsive layout via CSS container query. The root is a query container (container-type: inline-size, container-name: dock-files). BOTH the tree pane and the viewer pane are always rendered in the DOM; CSS decides what is visible per container width. -- NARROW (dock, default): single-panel stack. Tree shows alone; selecting a file reveals the viewer pane which overlays the stack, and the BACK button returns to the tree. This preserves the prior navigation-stack UX. -- WIDE (>=720px, the RightDockExpandModal pop-out): two-pane side-by-side. Left pane = tree (always visible, clamped width, scrollable). Right pane = viewer (flex:1, scrollable) showing an empty-state until a file is selected. Selecting a file updates the right pane without hiding the tree, so the BACK button is hidden when wide. +Responsive layout. BOTH the tree pane and the viewer pane are always rendered in the DOM; CSS decides what is visible. +- AUTO (dock, default `layout="auto"`): container-query single-panel stack. Tree shows alone; selecting a file reveals the viewer pane which overlays the stack, and the BACK button returns to the tree. This preserves the prior navigation-stack UX. +- TWO-PANE (RightDockExpandModal pop-out, `layout="two-pane"`): two-pane side-by-side. Left pane = tree (clamped width, scrollable). Right pane = viewer (flex:1, scrollable) showing an empty-state until a file is selected. Selecting a file updates the right pane without hiding the tree, so the BACK button is hidden. + +FNXC:RightDockFiles 2026-06-22-15:00: +The two-pane split is now DETERMINISTIC via the `layout` prop / `.dock-files-view--two-pane` modifier, NOT the @container query. The container query was unreliable inside the expand modal body (the root's content-box measured under the breakpoint at common laptop widths), so the pop-out kept stacking. The container-query path remains only for the `auto` (dock) layout. */ -export function DockFilesView({ projectId, openFile }: DockFilesViewProps) { +export function DockFilesView({ projectId, openFile, layout = "auto" }: DockFilesViewProps) { const { t } = useTranslation("app"); const { entries, currentPath, setPath, loading, error, refresh } = useWorkspaceFileBrowser("project", true, projectId); - // FNXC:RightDockFiles — 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. const [selectedFile, setSelectedFile] = useState(null); const [content, setContent] = useState(""); const [contentLoading, setContentLoading] = useState(false); @@ -77,11 +87,16 @@ export function DockFilesView({ projectId, openFile }: DockFilesViewProps) { // `data-selected` on the root lets the container query distinguish "no file selected" (narrow: viewer pane hidden so only the tree shows) from "file selected" (narrow: viewer pane covers the stack). When wide both panes are always visible regardless of this flag. return (
- {/* FNXC:Files — left pane: tree. Always in the DOM; CSS hides it only in the narrow single-panel stack when a file is selected. */} + {/* FNXC:RightDockFiles 2026-06-22-12:00: left pane: tree. Always in the DOM; CSS hides it only in the narrow single-panel stack when a file is selected. */}
- {/* FNXC:Files — right pane: viewer. Always in the DOM; CSS shows it side-by-side when wide, or as the single-panel stack when narrow + a file is selected. */} + {/* FNXC:RightDockFiles 2026-06-22-12:00: right pane: viewer. Always in the DOM; CSS shows it side-by-side when wide, or as the single-panel stack when narrow + a file is selected. */}
- {/* FNXC:Files — BACK only matters in the narrow stack (returns to the tree); CSS hides it when wide since the tree is always visible. */} + {/* FNXC:RightDockFiles 2026-06-22-12:00: BACK only matters in the narrow stack (returns to the tree); CSS hides it when wide since the tree is always visible. */} diff --git a/packages/dashboard/app/components/Header.tsx b/packages/dashboard/app/components/Header.tsx index 608a086f55..1dce747dd9 100644 --- a/packages/dashboard/app/components/Header.tsx +++ b/packages/dashboard/app/components/Header.tsx @@ -966,7 +966,8 @@ export function Header({ Left sidebar navigation owns desktop Settings when active, so Header hides its duplicate icon to preserve a single titled Settings control for users and navigation-history tests. */} {!isCompact && !leftSidebarNavActive && ( - )} diff --git a/packages/dashboard/app/components/InsightsView.css b/packages/dashboard/app/components/InsightsView.css index cb52777d28..a3f6aded94 100644 --- a/packages/dashboard/app/components/InsightsView.css +++ b/packages/dashboard/app/components/InsightsView.css @@ -533,8 +533,7 @@ Header migrated to the shared ViewHeader component (.view-header). The old .insi height: calc(var(--space-2xl) + var(--space-sm)); } - .view-header__actions .insights-view-actions-btn, - .insights-view-actions .btn { + .view-header__actions .insights-view-actions-btn { min-height: calc(var(--space-2xl) + var(--space-xs)); } diff --git a/packages/dashboard/app/components/LeftSidebarNav.tsx b/packages/dashboard/app/components/LeftSidebarNav.tsx index 59c112ae7a..b893f43081 100644 --- a/packages/dashboard/app/components/LeftSidebarNav.tsx +++ b/packages/dashboard/app/components/LeftSidebarNav.tsx @@ -32,18 +32,7 @@ import type { ProjectInfo, PluginDashboardViewEntry } from "../api"; import type { TaskView } from "../hooks/useViewState"; import { buildPluginTaskViewId } from "../plugins/pluginViewRegistry"; import { getPluginNavIcon } from "./pluginNavIcon"; - -/* -FNXC:Navigation 2026-06-22-00:30: -Import Tasks uses the GitHub brand mark. lucide-react in this repo does not export a `Github` icon, so render the octocat glyph as a LucideProps-compatible component (size defaults to 16, currentColor fill) usable wherever a sidebar entry icon is expected. -*/ -function GithubIcon({ size = 16, ...props }: LucideProps) { - return ( - - ); -} +import { GithubIcon } from "./GithubIcon"; export interface LeftSidebarExperimentalFeatures { insights?: boolean; @@ -506,7 +495,8 @@ export function LeftSidebarNav({ aria-label={t("header.settings", "Settings")} title={t("header.settings", "Settings")} data-testid="sidebar-nav-settings" - onClick={onOpenSettings} + /* FNXC:Navigation 2026-06-22-12:00: Wrap so React's MouseEvent is not forwarded as onOpenSettings' settingsInitialSection arg. */ + onClick={() => onOpenSettings?.()} > {t("header.settings", "Settings")} diff --git a/packages/dashboard/app/components/MailboxView.tsx b/packages/dashboard/app/components/MailboxView.tsx index 059e2d8496..9af4d94d6b 100644 --- a/packages/dashboard/app/components/MailboxView.tsx +++ b/packages/dashboard/app/components/MailboxView.tsx @@ -60,10 +60,14 @@ interface MailboxViewProps { const ALL_AGENTS_MAILBOX_ID = "__all_agents__"; -const MAILBOX_SIDEBAR_MIN_WIDTH = 280; +/* +FNXC:Mailbox 2026-06-22-16:00: +The mailbox message-list pane defaults narrow and can be dragged narrower than before. Lowered min 280->180 and default 320->220 so the conversation list takes less horizontal room by default while the active-message pane gets more; users can still widen via the resize handle (persisted per project). +*/ +const MAILBOX_SIDEBAR_MIN_WIDTH = 180; const MAILBOX_SIDEBAR_MAX_RATIO = 0.65; const MAILBOX_SIDEBAR_KEYBOARD_STEP = 16; -const MAILBOX_SIDEBAR_DEFAULT_WIDTH = 320; +const MAILBOX_SIDEBAR_DEFAULT_WIDTH = 220; function getMailboxSidebarMaxWidth(containerWidth: number): number { return Math.max(MAILBOX_SIDEBAR_MIN_WIDTH, containerWidth * MAILBOX_SIDEBAR_MAX_RATIO); diff --git a/packages/dashboard/app/components/MemoryView.css b/packages/dashboard/app/components/MemoryView.css index e3a45af888..0596411d6f 100644 --- a/packages/dashboard/app/components/MemoryView.css +++ b/packages/dashboard/app/components/MemoryView.css @@ -77,6 +77,15 @@ After the header migrated to the shared .view-header (which is flex-shrink:0), t overflow-y: auto; } +/* +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 and its hint collapse onto the divider line and look struck-through. Direct-child form-groups other than the editor one are pinned to flex-shrink:0. +*/ +.memory-editor-section > .form-group:not(.memory-editor-form-group), +.memory-editor-section > .memory-file-summary { + flex-shrink: 0; +} + .memory-editor-form-group { flex: 1; min-height: 0; diff --git a/packages/dashboard/app/components/PlanningWorkflowSwitcherSlot.tsx b/packages/dashboard/app/components/PlanningWorkflowSwitcherSlot.tsx index 6bcbc7d37a..6a8432ced1 100644 --- a/packages/dashboard/app/components/PlanningWorkflowSwitcherSlot.tsx +++ b/packages/dashboard/app/components/PlanningWorkflowSwitcherSlot.tsx @@ -99,8 +99,14 @@ export function PlanningWorkflowSwitcherSlot({ projectId, onOpenWorkflowEditor, return slot; }; if (resolve()) return; + /* + FNXC:PlanningWorkflowSwitcher 2026-06-22-09:00: + On mobile the Header never renders `#header-workflow-slot`, so this poll would otherwise spin every 250ms for the entire Planning session. Cap it at 20 attempts (~5s); after that the slot is presumed absent and the poll self-cancels. The unmount cleanup still clears any in-flight interval. + */ + let attempts = 0; const interval = window.setInterval(() => { - if (resolve()) window.clearInterval(interval); + attempts += 1; + if (resolve() || attempts >= 20) window.clearInterval(interval); }, 250); return () => window.clearInterval(interval); }, []); diff --git a/packages/dashboard/app/components/RightDock.css b/packages/dashboard/app/components/RightDock.css index 8f310b7f5a..c2317fb40f 100644 --- a/packages/dashboard/app/components/RightDock.css +++ b/packages/dashboard/app/components/RightDock.css @@ -148,9 +148,15 @@ container. NOTE: the wide right-dock expand modal is NOT this container, so its container-name: right-dock-body; } +/* +FNXC:RightDockEmbedded 2026-06-22-15:30: +The hosted view is a flex child of the dock body; without min-height:0 it cannot shrink below its content height, so a tall view (e.g. DevServerView) blows past the dock and its own overflow-y:auto never engages. min-height:0 + min-block-size:0 lets the child bound itself so the view (or the dock body) actually scrolls vertically. +*/ .right-dock__body > * { flex: 1; min-width: 0; + min-height: 0; + min-block-size: 0; } .right-dock-expand-modal { @@ -199,6 +205,8 @@ wide child cannot collapse the flex line. .right-dock-expand-modal__body > * { flex: 1; min-width: 0; + min-height: 0; + min-block-size: 0; } @media (max-width: 768px) { diff --git a/packages/dashboard/app/components/RightDock.tsx b/packages/dashboard/app/components/RightDock.tsx index e26c524768..76788a53dc 100644 --- a/packages/dashboard/app/components/RightDock.tsx +++ b/packages/dashboard/app/components/RightDock.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState, type KeyboardEvent as ReactKeyboardEvent } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react"; import { Maximize2 } from "lucide-react"; import { findOverflowViewEntry, @@ -70,7 +70,6 @@ function persistRightDockView(key: OverflowViewKey): void { export interface RightDockProps { open: boolean; - onOpenChange: (open: boolean) => void; renderProps: OverflowViewRenderProps; visibilityOptions?: OverflowViewVisibilityOptions; onExpand?: (key: OverflowViewKey) => void; @@ -84,8 +83,8 @@ The right dock is an auxiliary tablet/desktop surface: it remembers the last ove FNXC:Navigation 2026-06-21-20:14: FN-6882 splits right-dock entries into launcher actions and inline views. Action tabs invoke their existing Header handlers without replacing the Files body; only inline entries persist selection or expand into the modal. -FNXC:Navigation 2026-06-21-23:40: -The right dock is persistent and visible by default on tablet/desktop project screens. Its in-dock collapse toggle replaces the removed Header right-dock toggle, keeping one far-right control surface while preserving a narrow rail for restoring the panel. +FNXC:Navigation 2026-06-22-09:00: +The right dock is visible by default on tablet/desktop project screens. Show/hide is owned solely by the canonical Header right-sidebar toggle (the in-dock collapse toggle was removed); the dock takes only `open` and renders null when closed so the main content reclaims the space. */ export function RightDock({ open, @@ -97,6 +96,12 @@ export function RightDock({ const entries = useMemo(() => getVisibleOverflowViewEntries(visibilityOptions), [visibilityOptions]); const [selectedKey, setSelectedKey] = useState(() => readStoredRightDockView(visibilityOptions)); const [width, setWidth] = useState(readStoredRightDockWidth); + /* + FNXC:Navigation 2026-06-22-09:00: + The dock renders null while closed, so a resize drag that is still mid-flight when the dock closes (or the component unmounts) would leave document pointer listeners and a frozen body.userSelect behind. Store the active drag teardown in a ref and run it from an unmount-cleanup effect to plug that leak. + */ + const resizeTeardownRef = useRef<(() => void) | null>(null); + useEffect(() => () => resizeTeardownRef.current?.(), []); useEffect(() => { if (!isOverflowViewKeyVisible(selectedKey, visibilityOptions) || !isInlineOverflowViewKey(selectedKey, visibilityOptions)) { @@ -132,6 +137,7 @@ export function RightDock({ const startX = event.clientX; const startWidth = width; let latestWidth = startWidth; + const previousUserSelect = document.body.style.userSelect; document.body.style.userSelect = "none"; const onPointerMove = (moveEvent: PointerEvent) => { @@ -140,18 +146,28 @@ export function RightDock({ setWidth(nextWidth); }; - const onPointerUp = (upEvent: PointerEvent) => { - if (typeof resizeHandle.releasePointerCapture === "function") { + /* + FNXC:Navigation 2026-06-22-09:00: + teardown restores body.userSelect, drops the document pointermove/up/cancel listeners, and persists the final width. It runs on pointerup, pointercancel (touch/pen interruption), and on unmount/dock-close via resizeTeardownRef so listeners never leak. + */ + const teardown = (upEvent?: PointerEvent) => { + if (upEvent && typeof resizeHandle.releasePointerCapture === "function") { resizeHandle.releasePointerCapture(upEvent.pointerId); } - document.body.style.userSelect = ""; + document.body.style.userSelect = previousUserSelect; document.removeEventListener("pointermove", onPointerMove); document.removeEventListener("pointerup", onPointerUp); + document.removeEventListener("pointercancel", onPointerUp); + resizeTeardownRef.current = null; persistRightDockWidth(latestWidth); }; + const onPointerUp = (upEvent: PointerEvent) => teardown(upEvent); + + resizeTeardownRef.current = () => teardown(); document.addEventListener("pointermove", onPointerMove); document.addEventListener("pointerup", onPointerUp); + document.addEventListener("pointercancel", onPointerUp); }, [width]); const handleResizeKeyDown = useCallback((event: ReactKeyboardEvent) => { @@ -170,7 +186,7 @@ export function RightDock({ /* FNXC:Navigation 2026-06-22-00:00: - The right dock is no longer a persistent rail: when closed it renders nothing so the main content reclaims the space (the shell is flex, so a null dock simply reflows). The Header right-sidebar toggle is the canonical reopen control; the in-dock collapse toggle still hides the panel from inside. + The right dock is no longer a persistent rail: when closed it renders nothing so the main content reclaims the space (the shell is flex, so a null dock simply reflows). The Header right-sidebar toggle is the canonical show/hide control. */ if (!open) { return null; diff --git a/packages/dashboard/app/components/RightDockExpandModal.tsx b/packages/dashboard/app/components/RightDockExpandModal.tsx index fd8c382323..db3063d756 100644 --- a/packages/dashboard/app/components/RightDockExpandModal.tsx +++ b/packages/dashboard/app/components/RightDockExpandModal.tsx @@ -67,8 +67,12 @@ export function RightDockExpandModal({
+ {/* + FNXC:RightDockFiles 2026-06-22-15:00: + Tag the render props with `surface="expand"` so registry entries (notably Files) deterministically choose their pop-out layout instead of guessing from a measured container width. DockFilesView reads this to force its LEFT|RIGHT two-pane layout. + */}
- {entry.render(renderProps)} + {entry.render({ ...renderProps, surface: "expand" })}
diff --git a/packages/dashboard/app/components/ScriptsModal.css b/packages/dashboard/app/components/ScriptsModal.css index 9a4212a9e2..65bd6856cf 100644 --- a/packages/dashboard/app/components/ScriptsModal.css +++ b/packages/dashboard/app/components/ScriptsModal.css @@ -1194,99 +1194,9 @@ Non-Command-Center dashboard CSS must use the canonical --text token. The legacy } /* -FNXC:RightDockEmbedded 2026-06-22-00:00: -Right-dock redesign renders the activity log inline inside the dock container instead of as a fixed popup overlay. -The embedded root is a plain flow box that fills the dock; the inner panel sheds overlay chrome (fixed sizing, shadow, radius, resize) and fills 100% of the host so the dock owns the frame and its own header/close. +FNXC:RightDockEmbedded 2026-06-22-12:00: +The activity-log embedded (.activity-log-embedded / .activity-log-modal--embedded) rules and their @container block were moved to ActivityLogModal.css, next to the component. The base .activity-log-* modal rules remain here pending a full extraction. */ -.activity-log-embedded.right-dock-embedded-view { - display: flex; - width: 100%; - height: 100%; - min-height: 0; -} - -.activity-log-modal--embedded { - width: 100%; - height: 100%; - max-width: none; - max-height: none; - box-shadow: none; - border-radius: 0; - resize: none; - /* - FNXC:RightDockEmbedded 2026-06-22-00:00: - The dock is narrow (~280-420px) while the viewport stays desktop, so the view's @media (max-width:768px) mobile - rules never fire. Make the embedded panel an inline-size query container so the dock width — not the viewport — - drives the mobile single-column layout below. See the @container activity-log-embedded block below. - */ - container-type: inline-size; - container-name: activity-log-embedded; -} - -/* -FNXC:RightDockEmbedded 2026-06-22-00:00: -Mirror the phone-width (@media max-width:768px) activity-log layout-stacking rules for the narrow dock, scoped to the -embedded variant. Header wraps (title + close on top row, actions/filters stack full-width), filters/selects go 100%, -the active-filters bar wraps, and entry headers/details/text wrap instead of overflowing horizontally. Only layout -stacking is mirrored; behavior and the real @media rules are untouched. -*/ -@container activity-log-embedded (max-width: 560px) { - .activity-log-modal--embedded .activity-log-header { - flex-wrap: wrap; - gap: var(--space-sm); - padding: var(--space-md) var(--space-lg); - } - - .activity-log-modal--embedded .activity-log-title { - flex: 1 1 auto; - order: 0; - } - - .activity-log-modal--embedded .activity-log-actions { - flex: 1 1 100%; - flex-wrap: wrap; - gap: var(--space-xs); - order: 2; - } - - .activity-log-modal--embedded .activity-log-filter, - .activity-log-modal--embedded .activity-log-filter--project { - flex: 1 1 0; - min-width: 0; - } - - .activity-log-modal--embedded .activity-log-filter-select { - width: 100%; - } - - .activity-log-modal--embedded .activity-log-active-filters { - flex-wrap: wrap; - padding: var(--space-sm) var(--space-lg); - gap: var(--space-xs); - } - - .activity-log-modal--embedded .activity-log-clear-filters { - margin-left: 0; - } - - .activity-log-modal--embedded .activity-log-content { - padding: var(--space-md) var(--space-lg); - } - - .activity-log-modal--embedded .activity-log-entry-header { - flex-wrap: wrap; - gap: var(--space-xs); - } - - .activity-log-modal--embedded .activity-log-entry-details { - flex-wrap: wrap; - word-break: break-word; - } - - .activity-log-modal--embedded .activity-log-entry-text { - word-break: break-word; - } -} /* FNXC:AutomationsEmbedded 2026-06-22-00:00: diff --git a/packages/dashboard/app/components/SkillsView.css b/packages/dashboard/app/components/SkillsView.css index a7392d3470..2cf2b48ea4 100644 --- a/packages/dashboard/app/components/SkillsView.css +++ b/packages/dashboard/app/components/SkillsView.css @@ -1,43 +1,22 @@ /* === Skills View === */ +/* +FNXC:SkillsView 2026-06-22-16:15: +Skills mounts as a flex child of the flex-row .project-content. A flex item with no flex-grow collapses to its intrinsic content width, so the Skills content rendered narrow instead of spanning the main panel. Grow into available space, zero the min-width floor, and pin width:100% — mirroring the GoalsView/SecretsView (FN-6446/FN-6789) fix — so the view fills the full panel width. +*/ .skills-view { display: flex; + flex: 1 1 auto; flex-direction: column; height: 100%; + min-width: 0; + width: 100%; overflow: hidden; } -.skills-view-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: var(--space-lg); - border-bottom: 1px solid var(--border); - background: var(--surface); - flex-wrap: wrap; - gap: var(--space-sm); -} - -.skills-view-title { - display: flex; - align-items: center; - gap: var(--space-sm); -} - -.skills-view-title h2 { - font-weight: 600; - margin: 0; -} - .skills-view-count { color: var(--text-muted); } -.skills-view-actions { - display: flex; - align-items: center; - gap: var(--space-sm); -} - /* FNXC:Navigation 2026-06-22-02:00: The shared ViewHeader already supplies the top + side --space-lg padding, so the content area must NOT repeat the top padding (that doubled the gap under the header). Side + bottom padding only, aligned with the header. @@ -375,20 +354,10 @@ The shared ViewHeader already supplies the top + side --space-lg padding, so the } @media (max-width: 768px) { - .skills-view-header { - padding: var(--space-sm) var(--space-md); - } - .skills-view-content { padding: var(--space-md); } - .skills-view-title h2 { - } - - .skills-view-count { - } - .skills-view-section { margin-bottom: var(--space-md); } diff --git a/packages/dashboard/app/components/TerminalModal.tsx b/packages/dashboard/app/components/TerminalModal.tsx index d819acb67a..f7a725c8c4 100644 --- a/packages/dashboard/app/components/TerminalModal.tsx +++ b/packages/dashboard/app/components/TerminalModal.tsx @@ -431,6 +431,11 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG const initializedRendererRef = useRef(terminalPreferences.renderer); /** Tracks a pending requestAnimationFrame for deferred xterm re-fit. */ const pendingFitRef = useRef(null); + /* + FNXC:Terminal 2026-06-22-09:00: + Docked-resize, floating-drag, and floating-resize each attach document pointer listeners (and docked schedules a rAF) for the duration of a drag. If the modal closes or the component unmounts mid-drag, those listeners + the pending frame would leak. Track the active drag teardown here and run it from the close/unmount effect. + */ + const dragTeardownRef = useRef<(() => void) | null>(null); /** Tracks the previous projectId to detect project switches and invalidate xterm. */ const previousProjectIdRef = useRef(projectId); @@ -514,6 +519,17 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG document.removeEventListener("pointermove", handlePointerMove); document.removeEventListener("pointerup", handlePointerUp); document.removeEventListener("pointercancel", handlePointerUp); + dragTeardownRef.current = null; + }; + + // FNXC:Terminal 2026-06-22-09:00: Unmount/close-mid-drag teardown cancels the pending rAF and removes the document listeners without persisting a partial drag. + 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); @@ -539,8 +555,11 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG document.removeEventListener("pointermove", handlePointerMove); document.removeEventListener("pointerup", handlePointerUp); document.removeEventListener("pointercancel", handlePointerUp); + dragTeardownRef.current = null; }; + // FNXC:Terminal 2026-06-22-09:00: Unmount/close-mid-drag teardown removes the document listeners so a floating-drag never leaks them. + dragTeardownRef.current = handlePointerUp; document.addEventListener("pointermove", handlePointerMove); document.addEventListener("pointerup", handlePointerUp); document.addEventListener("pointercancel", handlePointerUp); @@ -578,8 +597,11 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG document.removeEventListener("pointermove", handlePointerMove); document.removeEventListener("pointerup", handlePointerUp); document.removeEventListener("pointercancel", handlePointerUp); + dragTeardownRef.current = null; }; + // FNXC:Terminal 2026-06-22-09:00: Unmount/close-mid-drag teardown removes the document listeners so a floating-resize never leaks them. + dragTeardownRef.current = handlePointerUp; document.addEventListener("pointermove", handlePointerMove); document.addEventListener("pointerup", handlePointerUp); document.addEventListener("pointercancel", handlePointerUp); @@ -1118,10 +1140,16 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG // (Input forwarding + window resize listener are wired inside initTerminal // so they share the xterm instance's lifetime — see comment there.) + // FNXC:Terminal 2026-06-22-09:00: Run any active drag teardown when the component unmounts mid-drag so document pointer listeners + the pending docked-resize rAF never outlive the modal. + useEffect(() => () => dragTeardownRef.current?.(), []); + // Cleanup xterm when modal closes useEffect(() => { if (isOpen) return; + // A close mid-drag must also drop the active drag's document listeners + rAF. + dragTeardownRef.current?.(); + // Modal is closed - cleanup xterm if (xtermRef.current) { xtermRef.current.dispose(); diff --git a/packages/dashboard/app/components/__tests__/App.test.tsx b/packages/dashboard/app/components/__tests__/App.test.tsx index 8eec896811..c91f92780d 100644 --- a/packages/dashboard/app/components/__tests__/App.test.tsx +++ b/packages/dashboard/app/components/__tests__/App.test.tsx @@ -455,7 +455,7 @@ vi.mock("../../components/SettingsModal", async () => { ); } - // FNXC:Settings 2026-06-22: Settings opens as an embedded main-content view (SettingsView) reusing the same body. + // FNXC:Settings 2026-06-22-12:00: Settings opens as an embedded main-content view (SettingsView) reusing the same body. return { SettingsModal: MockSettingsModal, SettingsView: MockSettingsModal }; }); diff --git a/packages/dashboard/app/components/__tests__/GoalsView.test.tsx b/packages/dashboard/app/components/__tests__/GoalsView.test.tsx index 4fd6b7835c..a52fd7586f 100644 --- a/packages/dashboard/app/components/__tests__/GoalsView.test.tsx +++ b/packages/dashboard/app/components/__tests__/GoalsView.test.tsx @@ -14,7 +14,7 @@ vi.mock("lucide-react", () => ({ Link: () => , Plus: () => , Sparkles: () => , - // Target backs the shared ViewHeader icon for the Goals view header (FNXC:Navigation 2026-06-22). + // Target backs the shared ViewHeader icon for the Goals view header (FNXC:Navigation 2026-06-22-12:00). Target: () => , X: () => , })); diff --git a/packages/dashboard/app/components/__tests__/LeftSidebarNav.test.tsx b/packages/dashboard/app/components/__tests__/LeftSidebarNav.test.tsx index 7ed6cf006c..0c2cd72f78 100644 --- a/packages/dashboard/app/components/__tests__/LeftSidebarNav.test.tsx +++ b/packages/dashboard/app/components/__tests__/LeftSidebarNav.test.tsx @@ -420,9 +420,9 @@ describe("LeftSidebarNav", () => { expect(primaryPlugin).toHaveAttribute("title", "Primary Plugin"); expect(primaryPlugin).toHaveTextContent("Primary Plugin"); expect(primaryPlugin).not.toHaveTextContent("view"); - expect(compoundPlugin).toHaveAccessibleName("Compound"); - expect(compoundPlugin).toHaveAttribute("title", "Compound"); - expect(compoundPlugin).toHaveTextContent("Compound"); + expect(compoundPlugin).toHaveAccessibleName("Compound Eng"); + expect(compoundPlugin).toHaveAttribute("title", "Compound Eng"); + expect(compoundPlugin).toHaveTextContent("Compound Eng"); expect(compoundPlugin).not.toHaveTextContent("Compound Engineering"); }); diff --git a/packages/dashboard/app/components/__tests__/MemoryView.test.tsx b/packages/dashboard/app/components/__tests__/MemoryView.test.tsx index 6be03de03b..c11bf98aba 100644 --- a/packages/dashboard/app/components/__tests__/MemoryView.test.tsx +++ b/packages/dashboard/app/components/__tests__/MemoryView.test.tsx @@ -23,7 +23,7 @@ vi.mock("../FileEditor", () => ({ vi.mock("lucide-react", () => ({ Loader2: () => , - // Brain backs the shared ViewHeader icon for the Memory view header (FNXC:Navigation 2026-06-22). + // Brain backs the shared ViewHeader icon for the Memory view header (FNXC:Navigation 2026-06-22-12:00). Brain: () => , })); diff --git a/packages/dashboard/app/components/__tests__/RightDock.test.tsx b/packages/dashboard/app/components/__tests__/RightDock.test.tsx index b7dc5ec91c..6db541e437 100644 --- a/packages/dashboard/app/components/__tests__/RightDock.test.tsx +++ b/packages/dashboard/app/components/__tests__/RightDock.test.tsx @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { fireEvent, render, screen } from "@testing-library/react"; -import { RightDock, RIGHT_DOCK_OPEN_STORAGE_KEY, RIGHT_DOCK_VIEW_STORAGE_KEY, RIGHT_DOCK_WIDTH_STORAGE_KEY } from "../RightDock"; +import { RightDock, RIGHT_DOCK_VIEW_STORAGE_KEY, RIGHT_DOCK_WIDTH_STORAGE_KEY } from "../RightDock"; import { RightDockExpandModal } from "../RightDockExpandModal"; vi.mock("../../api", async (importOriginal) => { @@ -16,26 +16,31 @@ const renderProps = { projectId: "project-1", }; +/* +FNXC:Navigation 2026-06-22-16:00: +The right dock is now an all-inline tools rail sourced from STATIC_OVERFLOW_VIEW_ENTRIES in overflowViewRegistry. The roster, in registry order, is files, activity-log, git-manager, devserver (gated on devServerView), secrets, todos (gated on todosEnabled), pull-requests. The earlier usage/github-import/automation launcher actions were removed, so every visible tab is an inline view that switches the dock body and can expand into the modal. +*/ const toolTabIds = [ - "right-dock-tab-usage", - "right-dock-tab-activity-log", - "right-dock-tab-github-import", - "right-dock-tab-git-manager", "right-dock-tab-files", - "right-dock-tab-automation", + "right-dock-tab-activity-log", + "right-dock-tab-git-manager", + "right-dock-tab-devserver", + "right-dock-tab-secrets", + "right-dock-tab-todos", + "right-dock-tab-pull-requests", ]; const removedViewTabIds = [ + "right-dock-tab-usage", + "right-dock-tab-github-import", + "right-dock-tab-automation", "right-dock-tab-documents", "right-dock-tab-research", "right-dock-tab-insights", "right-dock-tab-skills", "right-dock-tab-memory", - "right-dock-tab-secrets", "right-dock-tab-evals", "right-dock-tab-goals", - "right-dock-tab-todos", - "right-dock-tab-devserver", "right-dock-tab-stash-recovery", ]; @@ -49,34 +54,39 @@ describe("RightDock", () => { window.localStorage.clear(); }); - it("renders Files by default and restores only persisted inline views", () => { - const { unmount } = render(); + it("renders Files by default and restores the persisted inline view on remount", () => { + const { unmount } = render(); expect(screen.getByTestId("right-dock-tab-files")).toHaveAttribute("aria-selected", "true"); expect(screen.getByTestId("right-dock-files-view")).toBeInTheDocument(); - fireEvent.click(screen.getByTestId("right-dock-tab-automation")); - expect(window.localStorage.getItem(RIGHT_DOCK_VIEW_STORAGE_KEY)).toBeNull(); + /* + FNXC:Navigation 2026-06-22-16:00: + Every right-dock tab is now an inline view, so selecting one (git-manager) persists it and the dock restores that selection on remount instead of snapping back to Files. + */ + fireEvent.click(screen.getByTestId("right-dock-tab-git-manager")); + expect(screen.getByTestId("right-dock-tab-git-manager")).toHaveAttribute("aria-selected", "true"); + expect(window.localStorage.getItem(RIGHT_DOCK_VIEW_STORAGE_KEY)).toBe("git-manager"); unmount(); - render(); - expect(screen.getByTestId("right-dock-tab-files")).toHaveAttribute("aria-selected", "true"); + render(); + expect(screen.getByTestId("right-dock-tab-git-manager")).toHaveAttribute("aria-selected", "true"); }); it("falls back to Files when storage points at a removed right-dock view", () => { window.localStorage.setItem(RIGHT_DOCK_VIEW_STORAGE_KEY, "documents"); - render(); + render(); expect(screen.getByTestId("right-dock-tab-files")).toHaveAttribute("aria-selected", "true"); expect(screen.getByTestId("right-dock-files-view")).toBeInTheDocument(); expect(screen.queryByTestId("right-dock-tab-documents")).toBeNull(); }); - it("renders exactly the six right-dock tool entries and no removed content-view tabs", () => { + it("renders exactly the current right-dock tool entries and no removed content-view tabs", () => { render( { />, ); + /* + FNXC:Navigation 2026-06-22-16:00: + With devServerView and todosEnabled both on, the full seven-entry roster renders in registry order. Files, Activity Log, Git Manager, Dev Server, Secrets, Todos, and Pull Requests are all inline views. + */ expect(screen.getAllByRole("tab").map((tab) => tab.getAttribute("data-testid"))).toEqual(toolTabIds); - expect(screen.getByTestId("right-dock-tab-usage")).toHaveAttribute("aria-label", "Activity"); - expect(screen.getByTestId("right-dock-tab-activity-log")).toHaveAttribute("aria-label", "Activity Log"); - expect(screen.getByTestId("right-dock-tab-github-import")).toHaveAttribute("aria-label", "Import from GitHub"); - expect(screen.getByTestId("right-dock-tab-git-manager")).toHaveAttribute("aria-label", "Git Manager"); expect(screen.getByTestId("right-dock-tab-files")).toHaveAttribute("aria-label", "Files"); - expect(screen.getByTestId("right-dock-tab-automation")).toHaveAttribute("aria-label", "Automation"); + expect(screen.getByTestId("right-dock-tab-activity-log")).toHaveAttribute("aria-label", "Activity Log"); + expect(screen.getByTestId("right-dock-tab-git-manager")).toHaveAttribute("aria-label", "Git Manager"); + expect(screen.getByTestId("right-dock-tab-devserver")).toHaveAttribute("aria-label", "Dev Server"); + expect(screen.getByTestId("right-dock-tab-secrets")).toHaveAttribute("aria-label", "Secrets"); + expect(screen.getByTestId("right-dock-tab-todos")).toHaveAttribute("aria-label", "Todos"); + expect(screen.getByTestId("right-dock-tab-pull-requests")).toHaveAttribute("aria-label", "Pull Requests"); for (const removedId of removedViewTabIds) { expect(screen.queryByTestId(removedId)).toBeNull(); } }); - it("clicking action tabs invokes handlers without replacing the inline Files body", () => { - const onOpenUsage = vi.fn(); - const onOpenActivityLog = vi.fn(); - const onOpenGitHubImport = vi.fn(); - const onOpenGitManager = vi.fn(); - const onOpenSchedules = vi.fn(); - render( - , - ); + it("gates devserver and todos tabs behind their visibility flags", () => { + /* + FNXC:Navigation 2026-06-22-16:00: + devserver is gated on experimentalFeatures.devServerView and todos on todosEnabled. With both unset (default renderProps), the dock renders only the five always-on inline tools. + */ + render(); + expect(screen.getAllByRole("tab").map((tab) => tab.getAttribute("data-testid"))).toEqual([ + "right-dock-tab-files", + "right-dock-tab-activity-log", + "right-dock-tab-git-manager", + "right-dock-tab-secrets", + "right-dock-tab-pull-requests", + ]); + expect(screen.queryByTestId("right-dock-tab-devserver")).toBeNull(); + expect(screen.queryByTestId("right-dock-tab-todos")).toBeNull(); + }); - const actionAssertions: Array<[string, () => void, unknown[]]> = [ - ["right-dock-tab-usage", onOpenUsage, [null]], - ["right-dock-tab-activity-log", onOpenActivityLog, []], - ["right-dock-tab-github-import", onOpenGitHubImport, []], - ["right-dock-tab-git-manager", onOpenGitManager, []], - ["right-dock-tab-automation", onOpenSchedules, []], - ]; + it("clicking an inline tool tab switches the dock body and selection, and Files returns home", () => { + /* + FNXC:Navigation 2026-06-22-16:00: + The right dock no longer hosts launcher-action tabs that fire Header handlers; every tab is an inline view. Clicking a non-Files tab selects it (aria-selected flips, Files deselects) and replaces the body, and the Files tab restores the inline Files view. + */ + render(); - for (const [tabId, handler, args] of actionAssertions) { + expect(screen.getByTestId("right-dock-tab-files")).toHaveAttribute("aria-selected", "true"); + expect(screen.getByTestId("right-dock-files-view")).toBeInTheDocument(); + + for (const tabId of ["right-dock-tab-activity-log", "right-dock-tab-git-manager", "right-dock-tab-secrets"]) { fireEvent.click(screen.getByTestId(tabId)); - expect(handler).toHaveBeenCalledWith(...args); - expect(screen.getByTestId("right-dock-files-view")).toBeInTheDocument(); - expect(screen.getByTestId("right-dock-tab-files")).toHaveAttribute("aria-selected", "true"); + expect(screen.getByTestId(tabId)).toHaveAttribute("aria-selected", "true"); + expect(screen.getByTestId("right-dock-tab-files")).toHaveAttribute("aria-selected", "false"); + expect(screen.queryByTestId("right-dock-files-view")).toBeNull(); } fireEvent.click(screen.getByTestId("right-dock-tab-files")); + expect(screen.getByTestId("right-dock-tab-files")).toHaveAttribute("aria-selected", "true"); expect(screen.getByTestId("right-dock-files-view")).toBeInTheDocument(); }); - it("collapses internally and clamps then persists resize width", () => { - const onOpenChange = vi.fn(); - render(); - - fireEvent.click(screen.getByTestId("right-dock-collapse-toggle")); - expect(onOpenChange).toHaveBeenCalledWith(false); - expect(window.localStorage.getItem(RIGHT_DOCK_OPEN_STORAGE_KEY)).toBe("false"); + it("clamps then persists resize width while open", () => { + render(); const handle = screen.getByTestId("right-dock-resize-handle"); fireEvent.pointerDown(handle, { pointerId: 1, clientX: 900 }); @@ -165,30 +174,32 @@ describe("RightDock", () => { it("restores persisted width on mount", () => { window.localStorage.setItem(RIGHT_DOCK_WIDTH_STORAGE_KEY, "400"); - render(); + render(); expect(screen.getByTestId("right-dock")).toHaveStyle({ width: "400px" }); expect(screen.getByTestId("right-dock-resize-handle")).toHaveAttribute("aria-valuenow", "400"); }); - it("shows an in-dock collapse toggle and keeps the collapsed rail persistent", () => { - const onOpenChange = vi.fn(); - const { rerender } = render(); + // FNXC:Navigation 2026-06-22-09:00: Show/hide is owned by the canonical Header right-sidebar toggle. The dock no longer renders an in-dock collapse toggle or a collapsed rail; when open=false it renders nothing so the main content reclaims the space. + it("renders nothing when closed and renders the dock content when open", () => { + const { rerender } = render(); - expect(screen.getByTestId("right-dock-collapse-toggle")).toHaveAttribute("aria-expanded", "true"); + // Show/hide invariant only — the exact tab set is owned by overflowViewRegistry, not asserted here. + expect(screen.getByTestId("right-dock")).toBeInTheDocument(); expect(screen.getByTestId("right-dock-body")).toBeInTheDocument(); expect(screen.getByTestId("right-dock-resize-handle")).toBeInTheDocument(); - expect(screen.getAllByRole("tab").map((tab) => tab.getAttribute("data-testid"))).toEqual(toolTabIds); + expect(screen.getAllByRole("tab").length).toBeGreaterThan(0); + expect(screen.queryByTestId("right-dock-collapse-toggle")).toBeNull(); - rerender(); - expect(screen.getByTestId("right-dock")).toHaveClass("right-dock--collapsed"); - expect(screen.getByTestId("right-dock-collapse-toggle")).toHaveAttribute("aria-expanded", "false"); + rerender(); + expect(screen.queryByTestId("right-dock")).toBeNull(); expect(screen.queryByTestId("right-dock-body")).toBeNull(); expect(screen.queryByTestId("right-dock-resize-handle")).toBeNull(); - expect(screen.getAllByRole("tab").map((tab) => tab.getAttribute("data-testid"))).toEqual(toolTabIds); - fireEvent.click(screen.getByTestId("right-dock-collapse-toggle")); - expect(onOpenChange).toHaveBeenLastCalledWith(true); - expect(window.localStorage.getItem(RIGHT_DOCK_OPEN_STORAGE_KEY)).toBe("true"); + expect(screen.queryAllByRole("tab")).toHaveLength(0); + + rerender(); + expect(screen.getByTestId("right-dock")).toBeInTheDocument(); + expect(screen.getByTestId("right-dock-body")).toBeInTheDocument(); }); it("renders the expanded modal through the same registry and restores focus on close", async () => { @@ -243,11 +254,15 @@ describe("RightDock", () => { }); }); - it("fires expand for the selected inline entry only", () => { + it("fires expand for the currently selected inline entry", () => { + /* + FNXC:Navigation 2026-06-22-16:00: + Every tab is inline, so the expand button fires onExpand with whichever inline entry is selected (here git-manager after switching away from the default Files). + */ const onExpand = vi.fn(); - render(); - fireEvent.click(screen.getByTestId("right-dock-tab-automation")); + render(); + fireEvent.click(screen.getByTestId("right-dock-tab-git-manager")); fireEvent.click(screen.getByTestId("right-dock-expand")); - expect(onExpand).toHaveBeenCalledWith("files"); + expect(onExpand).toHaveBeenCalledWith("git-manager"); }); }); diff --git a/packages/dashboard/app/components/__tests__/navigation-history.test.tsx b/packages/dashboard/app/components/__tests__/navigation-history.test.tsx index 93468d469c..127c4e1bba 100644 --- a/packages/dashboard/app/components/__tests__/navigation-history.test.tsx +++ b/packages/dashboard/app/components/__tests__/navigation-history.test.tsx @@ -181,7 +181,7 @@ vi.mock("../../components/SettingsModal", () => ({ ), - // FNXC:Settings 2026-06-22: Settings now opens as an embedded main-content view (presentation="embedded"). + // FNXC:Settings 2026-06-22-12:00: Settings now opens as an embedded main-content view (presentation="embedded"). SettingsView: ({ onClose }: { onClose: () => void }) => (

Settings

@@ -429,7 +429,7 @@ describe("Navigation history integration", () => { } // 1. Desktop: opening Settings pushes a history entry - // FNXC:Settings 2026-06-22: Settings opens as an embedded main-content view (settings-view), not a modal overlay. + // FNXC:Settings 2026-06-22-12:00: Settings opens as an embedded main-content view (settings-view), not a modal overlay. it("pushes history entry when opening Settings view on desktop", async () => { await renderAppAndWait(); diff --git a/packages/dashboard/app/components/command-center/CommandCenter.css b/packages/dashboard/app/components/command-center/CommandCenter.css index b684495481..2d055bb1ed 100644 --- a/packages/dashboard/app/components/command-center/CommandCenter.css +++ b/packages/dashboard/app/components/command-center/CommandCenter.css @@ -554,3 +554,17 @@ The Command Center subtree previously had no tablet tier, so at 769px–1024px t opacity: 0.9; } } + +/* +FNXC:CommandCenter 2026-06-22-15:30: +Overview "View Board" / "View Agents" shortcut row sits directly under the Live activity snapshot. Self-styled here (OverviewTab does not pull in areas.css) using theme tokens only; mirrors the .cc-team-engine-nav wrapping-row look. Buttons grow to share the row and wrap on narrow widths. +*/ +.cc-overview-engine-nav { + display: flex; + flex-wrap: wrap; + gap: var(--space-sm); +} + +.cc-overview-engine-nav-btn { + flex: 1 1 auto; +} diff --git a/packages/dashboard/app/components/command-center/CommandCenter.tsx b/packages/dashboard/app/components/command-center/CommandCenter.tsx index c065bb6f47..0b862236b1 100644 --- a/packages/dashboard/app/components/command-center/CommandCenter.tsx +++ b/packages/dashboard/app/components/command-center/CommandCenter.tsx @@ -108,8 +108,8 @@ interface CommandCenterProps { addToast?: (message: string, type?: ToastType) => void; nodesEnabled?: boolean; /* - FNXC:CommandCenter 2026-06-22-00:00: - The AI engine card (Team-tab Heartbeat control) offers "View Board"/"View Agents" shortcuts. Navigation is owned by App's view router, so thread an optional onChangeView down to TeamArea rather than letting the Command Center mutate routing state itself. + FNXC:CommandCenter 2026-06-22-15:30: + The Overview (Command Center landing) surfaces "View Board"/"View Agents" shortcuts directly under the Live activity snapshot (the engine-activity strip, the closest "AI engine" element on Overview). Navigation is owned by App's view router, so thread an optional onChangeView down to OverviewTab rather than letting the Command Center mutate routing state itself. Moved here from the Team-tab Heartbeat card (FN earlier). */ onChangeView?: (view: TaskView) => void; } @@ -124,6 +124,7 @@ function OverviewTab({ onColorThemeChange = () => {}, onThemeModeChange = () => {}, onShadcnCustomColorsChange = () => {}, + onChangeView, }: { range: DateRange } & CommandCenterProps) { const { t } = useTranslation("app"); const tokens = useAnalyticsArea("/command-center/tokens?groupBy=model", range, { @@ -373,6 +374,28 @@ 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: @@ -528,6 +551,7 @@ export function CommandCenter({ onColorThemeChange={onColorThemeChange} onThemeModeChange={onThemeModeChange} onShadcnCustomColorsChange={onShadcnCustomColorsChange} + onChangeView={onChangeView} /> ); case "tokens": @@ -539,7 +563,7 @@ export function CommandCenter({ case "productivity": return ; case "team": - return ; + return ; case "ecosystem": return ; case "github": diff --git a/packages/dashboard/app/components/command-center/areas/TeamArea.tsx b/packages/dashboard/app/components/command-center/areas/TeamArea.tsx index 10b2bed10a..42a7518b62 100644 --- a/packages/dashboard/app/components/command-center/areas/TeamArea.tsx +++ b/packages/dashboard/app/components/command-center/areas/TeamArea.tsx @@ -11,7 +11,6 @@ import { getErrorMessage } from "@fusion/core"; import { fetchExecutorStats, fetchOrgTree, fetchSettings, updateSettings } from "../../../api/legacy"; import { useAppSettings } from "../../../hooks/useAppSettings"; import type { ToastType } from "../../../hooks/useToast"; -import type { TaskView } from "../../../hooks/useViewState"; import { AgentAvatar } from "../../AgentAvatar"; import { LoadingSpinner } from "../../LoadingSpinner"; import type { DateRange } from "../DateRangePicker"; @@ -169,12 +168,10 @@ export function TeamArea({ range, projectId, addToast, - onChangeView, }: { range: DateRange; projectId?: string; addToast?: (message: string, type?: ToastType) => void; - onChangeView?: (view: TaskView) => void; }) { const { t } = useTranslation("app"); const { @@ -190,6 +187,19 @@ export function TeamArea({ const [isSavingMultiplier, setIsSavingMultiplier] = useState(false); const [orgTreeState, setOrgTreeState] = useState>({ status: "loading", data: null, error: null }); const [executorStatsState, setExecutorStatsState] = useState>({ status: "loading", data: null, error: null }); + /* + FNXC:CommandCenter 2026-06-22-09:00: + The heartbeat slider fires onChange on every input event. Persist the network write through a 300ms debounce (the local optimistic value updates immediately) so dragging the slider does not spray updateSettings calls. mountedRef guards the post-await setState/addToast so they never fire after unmount. + */ + const heartbeatPersistTimeoutRef = useRef | null>(null); + const mountedRef = useRef(true); + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + if (heartbeatPersistTimeoutRef.current) clearTimeout(heartbeatPersistTimeoutRef.current); + }; + }, []); const orgChartViewportRef = useRef(null); const orgChartDragStateRef = useRef(null); const orgChartDidPanRef = useRef(false); @@ -288,18 +298,29 @@ export function TeamArea({ }, [projectId]); const handleHeartbeatMultiplierChange = useCallback( - async (multiplier: number) => { + (multiplier: number) => { const clampedValue = Number.isFinite(multiplier) && multiplier > 0 ? multiplier : 1; + // Optimistic local update is immediate; the network persist is debounced. setHeartbeatMultiplier(clampedValue); - setIsSavingMultiplier(true); - try { - await updateSettings({ heartbeatMultiplier: clampedValue }, projectId); - addToast?.(t("agents.heartbeatSpeedSet", "Heartbeat speed set to ×{{value}}", { value: clampedValue.toFixed(1) }), "success"); - } catch (err) { - addToast?.(t("agents.heartbeatSpeedSaveFailed", "Failed to save heartbeat multiplier: {{error}}", { error: getErrorMessage(err) }), "error"); - } finally { - setIsSavingMultiplier(false); - } + if (heartbeatPersistTimeoutRef.current) clearTimeout(heartbeatPersistTimeoutRef.current); + heartbeatPersistTimeoutRef.current = setTimeout(() => { + heartbeatPersistTimeoutRef.current = null; + if (mountedRef.current) setIsSavingMultiplier(true); + void (async () => { + try { + await updateSettings({ heartbeatMultiplier: clampedValue }, projectId); + if (mountedRef.current) { + addToast?.(t("agents.heartbeatSpeedSet", "Heartbeat speed set to ×{{value}}", { value: clampedValue.toFixed(1) }), "success"); + } + } catch (err) { + if (mountedRef.current) { + addToast?.(t("agents.heartbeatSpeedSaveFailed", "Failed to save heartbeat multiplier: {{error}}", { error: getErrorMessage(err) }), "error"); + } + } finally { + if (mountedRef.current) setIsSavingMultiplier(false); + } + })(); + }, 300); }, [projectId, addToast, t], ); @@ -478,7 +499,8 @@ export function TeamArea({ -
+ {/* FNXC:CommandCenter 2026-06-22-15:30: Heartbeat card spans the full Team grid width; its controls space out and wrap (see .cc-team-ops-card--heartbeat). */} +

{t("commandCenter.controls.heartbeat.title", "Heartbeat control")}

@@ -518,28 +540,9 @@ export function TeamArea({ ) : null} {/* - FNXC:CommandCenter 2026-06-22-14:30: - AI engine card shortcuts (View Board / View Agents) sit directly UNDER the engine toggle button so they read as engine-card navigation, above the heartbeat-speed slider. Navigation is owned by App (onChangeView), so these only render when wired up. + FNXC:CommandCenter 2026-06-22-15:30: + The "View Board" / "View Agents" engine-nav shortcuts moved OUT of this Heartbeat card to the Command Center Overview tab (under the Live activity snapshot). The Heartbeat card keeps only its pause control and the heartbeat-speed slider. */} - {onChangeView ? ( -
- - -
- ) : null} - {/* FNXC:CommandCenter 2026-06-22-00:00: Heartbeat-speed multiplier slider replicated from the Agents page (range 0.1–10, step 0.1, ×0.1–×10 presets) so users can scale all agent heartbeat intervals from the dashboard's AI engine card. Wired to the same settings.heartbeatMultiplier endpoint. diff --git a/packages/dashboard/app/components/command-center/areas/areas.css b/packages/dashboard/app/components/command-center/areas/areas.css index d5009876b5..70f45794ef 100644 --- a/packages/dashboard/app/components/command-center/areas/areas.css +++ b/packages/dashboard/app/components/command-center/areas/areas.css @@ -312,22 +312,31 @@ Team owns the Agent org chart and Heartbeat control. Org nodes must be self-styl } /* -FNXC:CommandCenter 2026-06-22-00:00: -AI engine card (Team Heartbeat control) gains a heartbeat-speed multiplier and View Board / View Agents shortcuts. Space the multiplier from the pause control and lay the nav buttons in a wrapping row using theme tokens only. +FNXC:CommandCenter 2026-06-22-15:30: +AI engine card (Team Heartbeat control) gains a heartbeat-speed multiplier slider. The View Board / View Agents shortcuts moved to the Command Center Overview tab (see CommandCenter.css .cc-overview-engine-nav). Item 3: the Heartbeat card spans the full Team grid width and its controls space out and wrap. */ -.cc-team-heartbeat-multiplier { - margin-block-start: var(--space-sm); +.cc-team-ops-card--heartbeat { + grid-column: 1 / -1; } -.cc-team-engine-nav { +.cc-team-heartbeat-multiplier { + margin-block-start: var(--space-md); +} + +/* +FNXC:CommandCenter 2026-06-22-15:30: +Within the full-width Heartbeat card, space the heartbeat controls (label, slider, value, preset) further apart and let them wrap on narrow widths; the slider grows to take the slack. Scoped under .cc-team-heartbeat-multiplier so the shared AgentsView .heartbeat-multiplier-controls layout is untouched. +*/ +.cc-team-heartbeat-multiplier .heartbeat-multiplier-controls { display: flex; flex-wrap: wrap; - gap: var(--space-xs); - margin-block-start: var(--space-sm); + align-items: center; + gap: var(--space-md); } -.cc-team-engine-nav-btn { - flex: 1 1 auto; +.cc-team-heartbeat-multiplier .heartbeat-multiplier-slider { + flex: 1 1 calc(var(--space-2xl) * 5); + min-inline-size: calc(var(--space-2xl) * 4); } /* @@ -567,6 +576,7 @@ Tablet Command Center areas share the FN-6679 overflow fix with the shell: area } .cc-team-ops-card--org, + .cc-team-ops-card--heartbeat, .cc-team-spark-panel { grid-column: auto; } diff --git a/packages/dashboard/app/components/overflowViewRegistry.tsx b/packages/dashboard/app/components/overflowViewRegistry.tsx index 29f049b516..fead4b2a0c 100644 --- a/packages/dashboard/app/components/overflowViewRegistry.tsx +++ b/packages/dashboard/app/components/overflowViewRegistry.tsx @@ -52,6 +52,12 @@ export interface OverflowViewFeatureState { export interface OverflowViewRenderProps { projectId?: string; + /* + FNXC:RightDockFiles 2026-06-22-15:00: + `surface` tells a registry render function which host it is mounting into so it can pick a deterministic layout instead of relying on a fragile CSS container query. + The compact right-dock body leaves this undefined ("dock"); the RightDockExpandModal sets `surface="expand"` so DockFilesView forces its LEFT|RIGHT two-pane layout regardless of measured container width. + */ + surface?: "dock" | "expand"; addToast: (message: string, type?: ToastType) => void; settingsLoaded?: boolean; readinessVersion?: number; @@ -120,7 +126,17 @@ export const STATIC_OVERFLOW_VIEW_ENTRIES: readonly OverflowViewEntry[] = [ label: "Files", icon: Folder, testId: "right-dock-tab-files", - render: (props) => wrapOverflowView(), + /* + FNXC:RightDockFiles 2026-06-22-15:00: + Map the host surface to a deterministic DockFilesView layout. The expand pop-out gets `layout="two-pane"` so the tree+viewer render LEFT|RIGHT without depending on the @container query matching inside the modal body. The compact dock keeps `layout="auto"` (the container-query single-panel stack). + */ + render: (props) => wrapOverflowView( + , + ), }, { key: "activity-log", diff --git a/packages/dashboard/app/components/useRightDockController.tsx b/packages/dashboard/app/components/useRightDockController.tsx index cbae25fc9f..c7fb85d9b4 100644 --- a/packages/dashboard/app/components/useRightDockController.tsx +++ b/packages/dashboard/app/components/useRightDockController.tsx @@ -53,11 +53,6 @@ export function useRightDockController(input: RightDockControllerInput): RightDo const [open, setOpen] = useState(readStoredRightDockOpen); const [expandedView, setExpandedView] = useState(null); - const setPersistedOpen = useCallback((nextOpen: boolean) => { - setOpen(nextOpen); - persistRightDockOpen(nextOpen); - if (!nextOpen) setExpandedView(null); - }, []); const toggle = useCallback(() => { setOpen((current) => { const next = !current; @@ -130,7 +125,7 @@ export function useRightDockController(input: RightDockControllerInput): RightDo return { open, toggle, - dock: input.active ? : null, + dock: input.active ? : null, modal: input.active ? setExpandedView(null)} /> : null, }; } diff --git a/packages/dashboard/app/hooks/__tests__/useViewState.test.ts b/packages/dashboard/app/hooks/__tests__/useViewState.test.ts index bc0b02ce39..a2cec87a83 100644 --- a/packages/dashboard/app/hooks/__tests__/useViewState.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useViewState.test.ts @@ -66,6 +66,17 @@ describe("useViewState", () => { }); }); + // FNXC:ViewState 2026-06-22-15:30: Persisted Command Center ("Dashboard") must not be the auto-restored landing view; it lands on the Board instead. + it("lands on board when the persisted taskView is command-center", async () => { + localStorage.setItem("kb-dashboard-task-view", "command-center"); + + const { result } = renderHook(() => useViewState(createOptions())); + + await waitFor(() => { + expect(result.current.taskView).toBe("board"); + }); + }); + it("migrates legacy reliability taskView from localStorage to Command Center", async () => { localStorage.setItem("kb-dashboard-task-view", "reliability"); diff --git a/packages/dashboard/app/hooks/useViewState.ts b/packages/dashboard/app/hooks/useViewState.ts index 449d826171..a5436aae3b 100644 --- a/packages/dashboard/app/hooks/useViewState.ts +++ b/packages/dashboard/app/hooks/useViewState.ts @@ -73,6 +73,14 @@ function normalizeTaskView(value: TaskView): TaskView { return value === "devserver" ? "dev-server" : value; } +/* +FNXC:ViewState 2026-06-22-15:30: +Fusion must land on the Board on load, never the Command Center "Dashboard" view. A persisted/normalized `command-center` value resolves to `board` for the auto-restored landing view only (initializer + project-hydration effect). Deep links (`?view=command-center`) and explicit user navigation still reach the Command Center — this only governs the restored landing surface. +*/ +function resolveLandingTaskView(value: TaskView): TaskView { + return value === "command-center" ? "board" : value; +} + function migrateLegacyRoadmapsView(value: string): TaskView { if (value !== "roadmaps") { return "board"; @@ -145,7 +153,7 @@ export function useViewState(options: UseViewStateOptions): UseViewStateResult { const retiredStashRecoveryView = migrateRetiredStashRecoveryView(saved); if (retiredStashRecoveryView) return retiredStashRecoveryView; if (saved === "roadmaps") return migrateLegacyRoadmapsView(saved); - if (isTaskView(saved)) return saved; + if (isTaskView(saved)) return resolveLandingTaskView(normalizeTaskView(saved)); return "board"; }); const hasHydratedScopedTaskViewRef = useRef(false); @@ -168,7 +176,9 @@ export function useViewState(options: UseViewStateOptions): UseViewStateResult { const preserveLegacyOnFirstScopedHydration = !hasHydratedScopedTaskViewRef.current && saved === "devserver"; - setTaskView(preserveLegacyOnFirstScopedHydration ? "devserver" : normalizeTaskView(saved)); + setTaskView( + preserveLegacyOnFirstScopedHydration ? "devserver" : resolveLandingTaskView(normalizeTaskView(saved)), + ); } else { setTaskView("board"); } diff --git a/plugins/fusion-plugin-compound-engineering/src/dashboard/__tests__/theme-tokens.test.ts b/plugins/fusion-plugin-compound-engineering/src/dashboard/__tests__/theme-tokens.test.ts index 789aba090c..4b2f79b3df 100644 --- a/plugins/fusion-plugin-compound-engineering/src/dashboard/__tests__/theme-tokens.test.ts +++ b/plugins/fusion-plugin-compound-engineering/src/dashboard/__tests__/theme-tokens.test.ts @@ -108,7 +108,7 @@ describe("CompoundEngineeringView theme tokens", () => { const tokenizedDeclarations = [ // Spacing moved onto the scrolling .ce-view-body when the shared ViewHeader - // took over the root header row (FNXC:CompoundEngineeringUI 2026-06-22). + // took over the root header row (FNXC:CompoundEngineeringUI 2026-06-22-12:00). [".ce-view-body", "gap", spacingToken], [".ce-view-body", "padding", spacingToken], [".ce-group", "gap", spacingToken],