FN-7169: Open board tasks in the right sidebar

Add a default-off project setting that routes board task-card opens into the right dock when available.

- Add the openTasksInRightSidebar project setting, defaults, docs, and release changeset.
- Wire Appearance settings to save the toggle and refresh embedded settings closes.
- Render board-opened task detail inside the right dock with mobile/dock-inactive fallback to the full panel.
- Cover the setting defaults, board routing, right-dock task surface, and Appearance toggle with tests.

Files changed:
 .changeset/fn-7169-open-tasks-in-right-sidebar.md  |   7 ++
 docs/dashboard-guide.md                            |   2 +
 docs/settings-reference.md                         |   1 +
 .../core/src/__tests__/settings-defaults.test.ts   |  13 +++
 packages/core/src/settings-schema.ts               |   1 +
 packages/core/src/types.ts                         |   7 ++
 packages/dashboard/app/App.tsx                     |  29 +++++-
 .../__tests__/App.openTasksInRightSidebar.test.ts  |  16 +++
 packages/dashboard/app/components/RightDock.tsx    |  41 ++++++--
 .../dashboard/app/components/SettingsModal.tsx     |   1 +
 .../app/components/__tests__/RightDock.test.tsx    | 109 +++++++++++++++++++++
 .../app/components/dashboard/MainContent.tsx       |  13 ++-
 .../__tests__/MainContent.graph-popout.test.tsx    |  29 ++++++
 .../dashboard/app/components/dashboard/types.ts    |   2 +
 .../settings/sections/AppearanceSection.tsx        |   9 +-
 .../sections/__tests__/AppearanceSection.test.tsx  |  63 ++++++++++++
 .../app/components/useRightDockController.tsx      |  64 +++++++++++-
 packages/dashboard/app/hooks/useAppSettings.ts     |   5 +
 packages/dashboard/vitest.config.ts                |   1 +
 19 files changed, 397 insertions(+), 16 deletions(-)

Fusion-Task-Id: FN-7169
Fusion-Task-Lineage: b135a1ac-b661-4774-9a04-91aaf5184394
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-28 02:17:25 -07:00
parent 9050ee1a42
commit a0602d0d26
19 changed files with 397 additions and 16 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Add a project setting to open task details in the right sidebar instead of the full panel.
category: feature
dev: New project setting `openTasksInRightSidebar` (default false). When true and the right dock is available, board card clicks render the task in the right dock; falls back to the full-panel view on mobile / when the dock is inactive.

View File

@@ -52,6 +52,8 @@ The **Right Dock Panel** experiment is enabled by default. To disable it, open *
When enabled on desktop or tablet project screens, the right dock is a persistent far-right tools sidebar in the project content row. By default it opens as an overlay so the main content does not reflow. Use the dock toolbar pin action to switch into push mode, where the dock becomes an in-flow pane that shrinks the main content beside it; unpinning returns to overlay mode. The selected tool, open/closed state, pinned push-mode state, width, and expanded modal size persist across reloads. When enabled on desktop or tablet project screens, the right dock is a persistent far-right tools sidebar in the project content row. By default it opens as an overlay so the main content does not reflow. Use the dock toolbar pin action to switch into push mode, where the dock becomes an in-flow pane that shrinks the main content beside it; unpinning returns to overlay mode. The selected tool, open/closed state, pinned push-mode state, width, and expanded modal size persist across reloads.
If **Settings → Appearance → Open tasks in the right sidebar** is enabled, board task-card clicks open task detail inside this right dock and keep the board visible. The setting is default off; mobile or hidden/inactive dock states automatically fall back to the existing full-panel task detail, and non-board task-open paths keep their existing behavior.
<!-- FNXC:DashboardNavigationDocs 2026-06-27-00:00: The right dock now hosts Chat as an inline tool panel; keep this user-facing roster aligned with STATIC_OVERFLOW_VIEW_ENTRIES so users know Chat can also pop out from the dock. --> <!-- FNXC:DashboardNavigationDocs 2026-06-27-00:00: The right dock now hosts Chat as an inline tool panel; keep this user-facing roster aligned with STATIC_OVERFLOW_VIEW_ENTRIES so users know Chat can also pop out from the dock. -->
The dock toolbar has built-in inline tool panels for **Files**, **Chat**, **Activity Log**, **Git Manager**, **Dev Server** when enabled, **Secrets**, **Todos** when enabled, and **Pull Requests**. These tools render in embedded mode inside the dock instead of opening fixed popup overlays; **Files** opens by default and is the fallback when browser storage points at a removed dock key. Inline dock views have an expand button that opens the same view in a resizable modal for more room. The right-dock **Files** viewer and its expanded pop-out match the Files modal for browser-previewable file types: image, video/movie, audio, and PDF selections render as native browser previews, while editable text files keep the editor and save flow. Plugin overflow views may add additional right-dock tool tabs, except plugin destinations that explicitly belong in the left sidebar. The dock toolbar has built-in inline tool panels for **Files**, **Chat**, **Activity Log**, **Git Manager**, **Dev Server** when enabled, **Secrets**, **Todos** when enabled, and **Pull Requests**. These tools render in embedded mode inside the dock instead of opening fixed popup overlays; **Files** opens by default and is the fallback when browser storage points at a removed dock key. Inline dock views have an expand button that opens the same view in a resizable modal for more room. The right-dock **Files** viewer and its expanded pop-out match the Files modal for browser-previewable file types: image, video/movie, audio, and PDF selections render as native browser previews, while editable text files keep the editor and save flow. Plugin overflow views may add additional right-dock tool tabs, except plugin destinations that explicitly belong in the left sidebar.

View File

@@ -455,6 +455,7 @@ Sandbox backend precedence is:
| `buildCommand` | `string` | `undefined` | Merge-time build command (hard gate). | | `buildCommand` | `string` | `undefined` | Merge-time build command (hard gate). |
| `recycleWorktrees` | `boolean` | `false` | Default: off (opt-in). Reuse worktrees from a pool for faster startup. | | `recycleWorktrees` | `boolean` | `false` | Default: off (opt-in). Reuse worktrees from a pool for faster startup. |
| `showWorktreeGrouping` | `boolean` | `false` | Default: off. When off, WIP/processing columns render plain task cards without worktree group shells or worktree-name labels in both legacy and workflow-mode boards. When on, every WIP/processing column groups tasks by worktree and shows worktree names, including workflow-mode columns flagged as counting toward WIP. | | `showWorktreeGrouping` | `boolean` | `false` | Default: off. When off, WIP/processing columns render plain task cards without worktree group shells or worktree-name labels in both legacy and workflow-mode boards. When on, every WIP/processing column groups tasks by worktree and shows worktree names, including workflow-mode columns flagged as counting toward WIP. |
| `openTasksInRightSidebar` | `boolean` | `false` | Default: off. When off, board task-card clicks keep the existing full-panel task detail that replaces the board. When on and the right dock is active on desktop/tablet, board task-card clicks open the task detail in the right sidebar so the board stays visible; mobile or hidden/inactive right-dock states automatically fall back to the full-panel behavior. Non-board task-open paths, including list split detail, right-dock task cards, floating pop-outs, graph/plugin opens, and deep `changes`/`retries`/`workflow` opens, keep their existing behavior. |
| `executorAllowSiblingBranchRename` | `boolean` | `false` | Opt back into the legacy executor behavior that silently allocates sibling branches (`fusion/<task-id>-2`, `-2-2`, …) when the canonical task branch is already checked out elsewhere. When disabled (default), branch conflicts fail loudly and leave the task in `todo` with `status: "failed"` so operators can resolve conflicting branches/worktrees with git tooling before retrying. See [Task Management → Branch conflict handling](./task-management.md#branch-conflict-handling). The dashboard Settings modal exposes the same toggle with warning copy because this legacy mode is discouraged. | | `executorAllowSiblingBranchRename` | `boolean` | `false` | Opt back into the legacy executor behavior that silently allocates sibling branches (`fusion/<task-id>-2`, `-2-2`, …) when the canonical task branch is already checked out elsewhere. When disabled (default), branch conflicts fail loudly and leave the task in `todo` with `status: "failed"` so operators can resolve conflicting branches/worktrees with git tooling before retrying. See [Task Management → Branch conflict handling](./task-management.md#branch-conflict-handling). The dashboard Settings modal exposes the same toggle with warning copy because this legacy mode is discouraged. |
| `worktreeNaming` | `"random" \| "task-id" \| "task-title"` | `"random"` | Naming mode for new worktree directories. | | `worktreeNaming` | `"random" \| "task-id" \| "task-title"` | `"random"` | Naming mode for new worktree directories. |

View File

@@ -137,6 +137,19 @@ describe("settings defaults invariants", () => {
}); });
}); });
describe("openTasksInRightSidebar default", () => {
it("keeps openTasksInRightSidebar explicitly false in project defaults", () => {
expect(DEFAULT_PROJECT_SETTINGS.openTasksInRightSidebar).toBe(false);
expect("openTasksInRightSidebar" in DEFAULT_PROJECT_SETTINGS).toBe(true);
expect(PROJECT_SETTINGS_KEYS).toContain("openTasksInRightSidebar");
});
it("keeps openTasksInRightSidebar project-scoped only", () => {
expect("openTasksInRightSidebar" in DEFAULT_GLOBAL_SETTINGS).toBe(false);
expect(GLOBAL_SETTINGS_KEYS).not.toContain("openTasksInRightSidebar");
});
});
describe("mergeIntegrationWorktree default", () => { describe("mergeIntegrationWorktree default", () => {
it("defaults project settings to reuse-task-worktree", () => { it("defaults project settings to reuse-task-worktree", () => {
expect(DEFAULT_PROJECT_SETTINGS.mergeIntegrationWorktree).toBe("reuse-task-worktree"); expect(DEFAULT_PROJECT_SETTINGS.mergeIntegrationWorktree).toBe("reuse-task-worktree");

View File

@@ -316,6 +316,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
buildCommand: undefined, buildCommand: undefined,
recycleWorktrees: false, recycleWorktrees: false,
showWorktreeGrouping: false, showWorktreeGrouping: false,
openTasksInRightSidebar: false,
executorAllowSiblingBranchRename: false, executorAllowSiblingBranchRename: false,
worktreeNaming: "random", worktreeNaming: "random",
worktrunk: { worktrunk: {

View File

@@ -3679,6 +3679,13 @@ export interface ProjectSettings {
* This is an explicit show/hide project setting. The default-off state hides worktree grouping and labels in both legacy and workflow-mode WIP columns; when enabled, operators see grouping in every WIP/processing column, including workflow-mode columns flagged as counting toward WIP. * This is an explicit show/hide project setting. The default-off state hides worktree grouping and labels in both legacy and workflow-mode WIP columns; when enabled, operators see grouping in every WIP/processing column, including workflow-mode columns flagged as counting toward WIP.
*/ */
showWorktreeGrouping?: boolean; showWorktreeGrouping?: boolean;
/**
* When true, board task-card clicks open task detail in the right dock when that dock surface is active; otherwise board clicks keep the full main-panel task detail. Default: false.
*
* FNXC:OpenTasksInRightSidebar 2026-06-28-00:00:
* This project-scoped setting is default-off so current board navigation is unchanged. When enabled, only Board card clicks may route to the tablet/desktop right dock; all non-board task-open paths and dock-inactive/mobile states must preserve the full-panel or existing modal behavior.
*/
openTasksInRightSidebar?: boolean;
/** When true, restores the legacy behavior of silently creating sibling /** When true, restores the legacy behavior of silently creating sibling
* branches like `fusion/FN-123-2` when the canonical task branch is already * branches like `fusion/FN-123-2` when the canonical task branch is already
* checked out elsewhere. Default: false. */ * checked out elsewhere. Default: false. */

View File

@@ -181,6 +181,10 @@ function prefetchLazyViews() {
registerBundledPluginViews(); registerBundledPluginViews();
export function shouldOpenBoardTaskInDock(openTasksInRightSidebar: boolean, rightDockActive: boolean, initialTab?: DetailTaskTab): boolean {
return !initialTab && openTasksInRightSidebar && rightDockActive;
}
function AppInner() { function AppInner() {
const { t } = useTranslation("app"); const { t } = useTranslation("app");
const { toasts, addToast, removeToast } = useToast(); const { toasts, addToast, removeToast } = useToast();
@@ -534,6 +538,7 @@ function AppInner() {
staleHighFanoutBlockerAgeThresholdMs, staleHighFanoutBlockerAgeThresholdMs,
capacityRiskBannerEnabled, capacityRiskBannerEnabled,
capacityRiskTodoThreshold, capacityRiskTodoThreshold,
openTasksInRightSidebar,
quickChatButtonMode, quickChatButtonMode,
maxTotalRetriesBeforeFail, maxTotalRetriesBeforeFail,
prAuthAvailable, prAuthAvailable,
@@ -1104,6 +1109,26 @@ function AppInner() {
// Props for the extracted <MainContent> switch (see components/dashboard/MainContent.tsx). // Props for the extracted <MainContent> switch (see components/dashboard/MainContent.tsx).
// Every value is passed by its App name; the switch renders the same subtrees as before. // Every value is passed by its App name; the switch renders the same subtrees as before.
const rightDock = useRightDockController({ active: rightDockActive, projectId: currentProject?.id, addToast, settingsLoaded, researchReadinessVersion, goalAnchorId, tasks: isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks, workflowSteps, subscribePluginEvents, openDetailTask, openFileInBrowser, onMoveTask: moveTask, onDeleteTask: deleteTask, onArchiveTask: archiveTask, onMergeTask: mergeTask, onRetryTask: retryTask, onResetTask: resetTask, onDuplicateTask: duplicateTask, onTaskUpdated: (task: Task) => ingestCreatedTasks([task]), openSettings: (section?: string) => openSettingsWithNav(section as SectionId), onOpenUsage: openUsageWithNav, onOpenActivityLog: openActivityLogWithNav, onOpenGitHubImport: openGitHubImportWithNav, onOpenGitManager: openGitManagerWithNav, onOpenSchedules: openSchedulesWithNav, onSendSelectionToTask: modalManager.openNewTaskWithDescription, onCreateTaskFromInsight: handleInsightTaskCreate, onNavigateToMission: handleOpenMission, onTaskCreated: (task: Task) => ingestCreatedTasks([task]), prAuthAvailable, autoMerge, visibilityOptions: { experimentalFeatures: { insights: insightsEnabled, memoryView: memoryEnabled, devServerView: devServerEnabled, researchView: researchEnabled, evalsView: evalsEnabled, goalsView: goalsEnabled }, showSkillsTab: skillsEnabled, todosEnabled, pluginDashboardViews }, footerVisible: executorFooterVisible });
/*
FNXC:OpenTasksInRightSidebar 2026-06-28-00:00:
Board card clicks are the only task-open path governed by openTasksInRightSidebar. When the project setting is enabled and the tablet/desktop right dock is active, the board keeps its current view and asks the dock controller to render task detail; otherwise the existing full main-panel replacement remains the fallback, including mobile and hidden-footer states.
*/
const openBoardTaskDetail = useCallback((task: Task | TaskDetail, initialTab?: DetailTaskTab) => {
if (!shouldOpenBoardTaskInDock(openTasksInRightSidebar, rightDockActive, initialTab)) {
openTaskDetailInMainPanel(task, initialTab);
return;
}
rightDock.openTaskInDock(task);
}, [openTaskDetailInMainPanel, openTasksInRightSidebar, rightDock, rightDockActive]);
useEffect(() => {
if (!openTasksInRightSidebar) {
rightDock.closeDockTask();
}
}, [openTasksInRightSidebar, rightDock]);
const mainContentProps: MainContentProps = { const mainContentProps: MainContentProps = {
showBackendConnectionErrorPage, showBackendConnectionErrorPage,
projectsError, projectsError,
@@ -1114,6 +1139,7 @@ function AppInner() {
taskView, taskView,
modalManager, modalManager,
handleChangeTaskView, handleChangeTaskView,
refreshAppSettings,
addToast, addToast,
currentProject, currentProject,
themeMode, themeMode,
@@ -1190,6 +1216,7 @@ function AppInner() {
showWorktreeGrouping, showWorktreeGrouping,
moveTask, moveTask,
pauseTask, pauseTask,
openBoardTaskDetail,
openTaskDetailInMainPanel, openTaskDetailInMainPanel,
openGroupModalWithNav, openGroupModalWithNav,
handleBoardQuickCreate, handleBoardQuickCreate,
@@ -1303,8 +1330,6 @@ function AppInner() {
markGitHubStarPromptShown, markGitHubStarPromptShown,
setShowGitHubStarPrompt, setShowGitHubStarPrompt,
}; };
const rightDock = useRightDockController({ active: rightDockActive, projectId: currentProject?.id, addToast, settingsLoaded, researchReadinessVersion, goalAnchorId, tasks: isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks, workflowSteps, subscribePluginEvents, openDetailTask, openFileInBrowser, openSettings: (section?: string) => openSettingsWithNav(section as SectionId), onOpenUsage: openUsageWithNav, onOpenActivityLog: openActivityLogWithNav, onOpenGitHubImport: openGitHubImportWithNav, onOpenGitManager: openGitManagerWithNav, onOpenSchedules: openSchedulesWithNav, onSendSelectionToTask: modalManager.openNewTaskWithDescription, onCreateTaskFromInsight: handleInsightTaskCreate, onNavigateToMission: handleOpenMission, onTaskCreated: (task: Task) => ingestCreatedTasks([task]), prAuthAvailable, autoMerge, visibilityOptions: { experimentalFeatures: { insights: insightsEnabled, memoryView: memoryEnabled, devServerView: devServerEnabled, researchView: researchEnabled, evalsView: evalsEnabled, goalsView: goalsEnabled }, showSkillsTab: skillsEnabled, todosEnabled, pluginDashboardViews }, footerVisible: executorFooterVisible });
return ( return (
<NavigationHistoryProvider value={{ pushNav, replaceCurrent, removeNav }}> <NavigationHistoryProvider value={{ pushNav, replaceCurrent, removeNav }}>
<FileBrowserProvider openFile={openFileInBrowser}> <FileBrowserProvider openFile={openFileInBrowser}>

View File

@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { shouldOpenBoardTaskInDock } from "../App";
describe("openTasksInRightSidebar board routing", () => {
it("opens board card clicks in the dock only when the setting and dock surface are both active", () => {
expect(shouldOpenBoardTaskInDock(true, true)).toBe(true);
expect(shouldOpenBoardTaskInDock(false, true)).toBe(false);
expect(shouldOpenBoardTaskInDock(true, false)).toBe(false);
});
it("keeps deep-tab opens on the existing main-panel path", () => {
expect(shouldOpenBoardTaskInDock(true, true, "changes")).toBe(false);
expect(shouldOpenBoardTaskInDock(true, true, "retries")).toBe(false);
expect(shouldOpenBoardTaskInDock(true, true, "workflow")).toBe(false);
});
});

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from "react";
import { Maximize2, Pin, PinOff } from "lucide-react"; import type { Task, TaskDetail } from "@fusion/core";
import { ArrowLeft, Maximize2, Pin, PinOff } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
findOverflowViewEntry, findOverflowViewEntry,
@@ -99,6 +100,9 @@ export interface RightDockProps {
footerVisible?: boolean; footerVisible?: boolean;
pinned: boolean; pinned: boolean;
onTogglePin: () => void; onTogglePin: () => void;
dockTask?: Task | TaskDetail | null;
dockTaskContent?: ReactNode;
onCloseDockTask?: () => void;
} }
/* /*
@@ -122,6 +126,9 @@ export function RightDock({
footerVisible = false, footerVisible = false,
pinned, pinned,
onTogglePin, onTogglePin,
dockTask = null,
dockTaskContent = null,
onCloseDockTask,
}: RightDockProps) { }: RightDockProps) {
const { t } = useTranslation("app"); const { t } = useTranslation("app");
const entries = useMemo(() => getVisibleOverflowViewEntries(visibilityOptions), [visibilityOptions]); const entries = useMemo(() => getVisibleOverflowViewEntries(visibilityOptions), [visibilityOptions]);
@@ -152,9 +159,14 @@ export function RightDock({
return; return;
} }
if (!entry?.render) return; if (!entry?.render) return;
/*
FNXC:OpenTasksInRightSidebar 2026-06-28-00:00:
Selecting any normal right-dock tab leaves the task-detail overlay surface and restores the last overflow-view body. This avoids stacking task detail over Files/Goals and prevents orphaned task headers after the user intentionally switches dock context.
*/
onCloseDockTask?.();
setSelectedKey(key); setSelectedKey(key);
persistRightDockView(key); persistRightDockView(key);
}, [renderProps, visibilityOptions]); }, [onCloseDockTask, renderProps, visibilityOptions]);
const handleResizeStart = useCallback((event: React.PointerEvent<HTMLDivElement>) => { const handleResizeStart = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
event.preventDefault(); event.preventDefault();
@@ -224,8 +236,10 @@ export function RightDock({
} }
const SelectedIcon = selectedEntry.icon; const SelectedIcon = selectedEntry.icon;
const showingDockTask = Boolean(dockTask && dockTaskContent);
const dockWidth = `${width}px`; const dockWidth = `${width}px`;
const expandSelectedViewLabel = t("rightDock.expandView", "Expand {{label}}", { label: selectedEntry.label }); const expandSelectedViewLabel = t("rightDock.expandView", "Expand {{label}}", { label: selectedEntry.label });
const closeDockTaskLabel = t("rightDock.closeTaskDetail", "Back to right dock views");
const pinLabel = pinned const pinLabel = pinned
? t("rightDock.unpin", "Unpin sidebar (overlay content)") ? t("rightDock.unpin", "Unpin sidebar (overlay content)")
: t("rightDock.pin", "Pin sidebar (push content)"); : t("rightDock.pin", "Pin sidebar (push content)");
@@ -291,7 +305,18 @@ export function RightDock({
> >
<PinIcon size={16} /> <PinIcon size={16} />
</button> </button>
{open && selectedEntry.render ? ( {showingDockTask ? (
<button
type="button"
className="btn-icon right-dock__expand"
aria-label={closeDockTaskLabel}
title={closeDockTaskLabel}
data-testid="right-dock-close-task"
onClick={onCloseDockTask}
>
<ArrowLeft size={16} />
</button>
) : open && selectedEntry.render ? (
<button <button
type="button" type="button"
className="btn-icon right-dock__expand" className="btn-icon right-dock__expand"
@@ -308,15 +333,15 @@ export function RightDock({
{open ? ( {open ? (
<> <>
<div className="right-dock__header"> <div className="right-dock__header">
<SelectedIcon size={16} /> {showingDockTask ? <ArrowLeft size={16} /> : <SelectedIcon size={16} />}
<div className="right-dock__title" role="heading" aria-level={3}>{selectedEntry.label}</div> <div className="right-dock__title" role="heading" aria-level={3}>{showingDockTask ? t("rightDock.taskDetailTitle", "Task detail") : selectedEntry.label}</div>
</div> </div>
<div className="right-dock__body" role="tabpanel" aria-label={selectedEntry.label} data-testid="right-dock-body"> <div className="right-dock__body" role="tabpanel" aria-label={showingDockTask ? t("rightDock.taskDetailTitle", "Task detail") : selectedEntry.label} data-testid="right-dock-body">
{/* {/*
FNXC:RightDockFiles 2026-06-23-00:50: FNXC:RightDockFiles 2026-06-23-00:50:
Thread the live dock width down to registry render functions as `dockWidth` (alongside surface="dock") so a view can deterministically choose its wide layout from the actual dock size. The Files entry uses this to force two-pane when the dock is wide enough, sidestepping the @container query that never reliably fired in the narrow-vs-wide dock body. Thread the live dock width down to registry render functions as `dockWidth` (alongside surface="dock") so a view can deterministically choose its wide layout from the actual dock size. The Files entry uses this to force two-pane when the dock is wide enough, sidestepping the @container query that never reliably fired in the narrow-vs-wide dock body.
*/} */}
{selectedEntry.render?.({ ...renderProps, surface: "dock", dockWidth: width })} {showingDockTask ? dockTaskContent : selectedEntry.render?.({ ...renderProps, surface: "dock", dockWidth: width })}
</div> </div>
</> </>
) : null} ) : null}

View File

@@ -733,6 +733,7 @@ export function SettingsModal({
merger: { mode: "ai", maxReviewPasses: 3, allowDirtyLocalCheckoutSync: true }, merger: { mode: "ai", maxReviewPasses: 3, allowDirtyLocalCheckoutSync: true },
recycleWorktrees: false, recycleWorktrees: false,
showWorktreeGrouping: false, showWorktreeGrouping: false,
openTasksInRightSidebar: false,
executorAllowSiblingBranchRename: false, executorAllowSiblingBranchRename: false,
worktreeNaming: "random", worktreeNaming: "random",
worktreeCopyFiles: [], worktreeCopyFiles: [],

View File

@@ -14,6 +14,12 @@ import { useRightDockController, type RightDockControllerInput } from "../useRig
import { DOCK_FILES_CURRENT_KEY } from "../DockFilesView"; import { DOCK_FILES_CURRENT_KEY } from "../DockFilesView";
import { setScopedItem } from "../../utils/projectStorage"; import { setScopedItem } from "../../utils/projectStorage";
vi.mock("../TaskDetailModal", () => ({
TaskDetailContent: ({ task }: { task: { id: string; title?: string } }) => (
<div data-testid="dock-task-detail">{task.title ?? task.id}</div>
),
}));
vi.mock("../../api", async (importOriginal) => { vi.mock("../../api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../api")>(); const actual = await importOriginal<typeof import("../../api")>();
return { return {
@@ -153,6 +159,109 @@ describe("RightDock", () => {
expect(screen.queryByTestId("right-dock-collapse-toggle")).toBeNull(); expect(screen.queryByTestId("right-dock-collapse-toggle")).toBeNull();
}); });
it("renders dock task detail in the body and returns to overflow views from the close affordance", () => {
const onCloseDockTask = vi.fn();
const { rerender } = render(
<TestRightDock
open={true}
renderProps={renderProps}
dockTask={{ id: "FN-7169", title: "Sidebar task" } as never}
dockTaskContent={<div data-testid="dock-task-detail">Sidebar task</div>}
onCloseDockTask={onCloseDockTask}
/>,
);
expect(screen.getByTestId("right-dock-body")).toHaveTextContent("Sidebar task");
expect(screen.queryByTestId("right-dock-files-view")).toBeNull();
fireEvent.click(screen.getByTestId("right-dock-close-task"));
expect(onCloseDockTask).toHaveBeenCalledTimes(1);
rerender(<TestRightDock open={true} renderProps={renderProps} dockTask={null} dockTaskContent={null} onCloseDockTask={onCloseDockTask} />);
expect(screen.getByTestId("right-dock-files-view")).toBeInTheDocument();
expect(screen.queryByTestId("dock-task-detail")).toBeNull();
});
it("clears dock task detail when a normal right-dock tab is selected", () => {
const onCloseDockTask = vi.fn();
render(
<TestRightDock
open={true}
renderProps={renderProps}
dockTask={{ id: "FN-7169", title: "Sidebar task" } as never}
dockTaskContent={<div data-testid="dock-task-detail">Sidebar task</div>}
onCloseDockTask={onCloseDockTask}
/>,
);
fireEvent.click(screen.getByTestId("right-dock-tab-git-manager"));
expect(onCloseDockTask).toHaveBeenCalledTimes(1);
});
it("controller dock task opens, replaces, and clears on inactive teardown", () => {
const firstTask = { id: "FN-1", title: "First task", column: "todo" };
const secondTask = { id: "FN-2", title: "Second task", column: "todo" };
const controllerInput = {
active: true,
projectId: "project-1",
addToast: vi.fn(),
settingsLoaded: true,
researchReadinessVersion: 0,
tasks: [firstTask, secondTask],
workflowSteps: [],
subscribePluginEvents: () => () => {},
openDetailTask: vi.fn(),
openFileInBrowser: vi.fn(),
onMoveTask: vi.fn(),
onDeleteTask: vi.fn(),
onMergeTask: vi.fn(),
openSettings: vi.fn(),
onSendSelectionToTask: vi.fn(),
onCreateTaskFromInsight: vi.fn(),
onNavigateToMission: vi.fn(),
onTaskCreated: vi.fn(),
prAuthAvailable: false,
autoMerge: false,
visibilityOptions: {},
footerVisible: false,
} as unknown as RightDockControllerInput;
function Harness({ active }: { active: boolean }) {
const controller = useRightDockController({ ...controllerInput, active });
return (
<>
<button type="button" data-testid="open-first" onClick={() => controller.openTaskInDock(firstTask as never)}>open first</button>
<button type="button" data-testid="open-second" onClick={() => controller.openTaskInDock(secondTask as never)}>open second</button>
<button type="button" data-testid="close-dock-task" onClick={controller.closeDockTask}>close task</button>
{controller.dock}
</>
);
}
const { rerender } = render(<Harness active={true} />);
expect(screen.getByTestId("right-dock-files-view")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("open-first"));
expect(screen.getByTestId("dock-task-detail")).toHaveTextContent("First task");
fireEvent.click(screen.getByTestId("open-second"));
expect(screen.getByTestId("dock-task-detail")).toHaveTextContent("Second task");
expect(screen.queryByText("First task")).toBeNull();
fireEvent.click(screen.getByTestId("close-dock-task"));
expect(screen.queryByTestId("dock-task-detail")).toBeNull();
expect(screen.getByTestId("right-dock-files-view")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("open-first"));
expect(screen.getByTestId("dock-task-detail")).toHaveTextContent("First task");
rerender(<Harness active={false} />);
expect(screen.queryByTestId("right-dock")).toBeNull();
rerender(<Harness active={true} />);
expect(screen.queryByTestId("dock-task-detail")).toBeNull();
expect(screen.getByTestId("right-dock-files-view")).toBeInTheDocument();
});
it("renders the pin affordance for both states and delegates the toggle", () => { it("renders the pin affordance for both states and delegates the toggle", () => {
const onTogglePin = vi.fn(); const onTogglePin = vi.fn();
const { rerender } = render(<TestRightDock open={true} renderProps={renderProps} pinned={false} onTogglePin={onTogglePin} />); const { rerender } = render(<TestRightDock open={true} renderProps={renderProps} pinned={false} onTogglePin={onTogglePin} />);

View File

@@ -36,6 +36,7 @@ export function MainContent({
taskView, taskView,
modalManager, modalManager,
handleChangeTaskView, handleChangeTaskView,
refreshAppSettings,
addToast, addToast,
currentProject, currentProject,
themeMode, themeMode,
@@ -112,6 +113,7 @@ export function MainContent({
showWorktreeGrouping, showWorktreeGrouping,
moveTask, moveTask,
pauseTask, pauseTask,
openBoardTaskDetail,
openTaskDetailInMainPanel, openTaskDetailInMainPanel,
openGroupModalWithNav, openGroupModalWithNav,
handleBoardQuickCreate, handleBoardQuickCreate,
@@ -188,11 +190,15 @@ export function MainContent({
/* /*
FNXC:Settings 2026-06-22-00:00: FNXC:Settings 2026-06-22-00:00:
Settings renders ahead of the overview branch so the header gear opens the embedded Settings view even when no project is selected (viewMode === "overview"), matching the prior modal which opened regardless of view mode. Settings renders ahead of the overview branch so the header gear opens the embedded Settings view even when no project is selected (viewMode === "overview"), matching the prior modal which opened regardless of view mode.
FNXC:OpenTasksInRightSidebar 2026-06-28-00:00:
Embedded Settings closes must refresh App-scoped settings before returning to the board. The openTasksInRightSidebar routing hook reads project settings through useAppSettings, so saving the Appearance toggle needs the same refresh path as the modal settings close to make board-card routing change immediately without a reload.
*/ */
if (taskView === "settings") { if (taskView === "settings") {
const closeSettingsView = () => { const closeSettingsView = () => {
modalManager.closeSettings(); modalManager.closeSettings();
handleChangeTaskView("board"); handleChangeTaskView("board");
void refreshAppSettings();
}; };
return ( return (
<PageErrorBoundary> <PageErrorBoundary>
@@ -662,6 +668,9 @@ export function MainContent({
/* /*
FNXC:Navigation 2026-06-22-00:00: FNXC:Navigation 2026-06-22-00:00:
Board-opened task detail renders as a full main-content view that replaces the board. A Back-to-board button sits above an embedded TaskDetailContent (same props ListView passes to its split-detail pane). The live task is preferred from `tasks` by id so the detail updates on revalidation; the stored snapshot is the fallback. If neither resolves (snapshot cleared), fall back to the board so the panel is never blank. Board-opened task detail renders as a full main-content view that replaces the board. A Back-to-board button sits above an embedded TaskDetailContent (same props ListView passes to its split-detail pane). The live task is preferred from `tasks` by id so the detail updates on revalidation; the stored snapshot is the fallback. If neither resolves (snapshot cleared), fall back to the board so the panel is never blank.
FNXC:OpenTasksInRightSidebar 2026-06-28-00:00:
Both Board render sites use App's setting-aware board-open handler. That keeps this switch presentational while ensuring only Board card clicks can route into the right dock; deep-tab, list, plugin, and modal task-open paths continue to call their existing handlers.
*/ */
if (taskView === "task-detail") { if (taskView === "task-detail") {
const liveDetailTask = mainPanelDetailTask const liveDetailTask = mainPanelDetailTask
@@ -677,7 +686,7 @@ export function MainContent({
showWorktreeGrouping={showWorktreeGrouping} showWorktreeGrouping={showWorktreeGrouping}
onMoveTask={moveTask} onMoveTask={moveTask}
onPauseTask={pauseTask} onPauseTask={pauseTask}
onOpenDetail={openTaskDetailInMainPanel} onOpenDetail={openBoardTaskDetail}
onOpenGroupModal={openGroupModalWithNav} onOpenGroupModal={openGroupModalWithNav}
addToast={addToast} addToast={addToast}
onQuickCreate={handleBoardQuickCreate} onQuickCreate={handleBoardQuickCreate}
@@ -773,7 +782,7 @@ export function MainContent({
showWorktreeGrouping={showWorktreeGrouping} showWorktreeGrouping={showWorktreeGrouping}
onMoveTask={moveTask} onMoveTask={moveTask}
onPauseTask={pauseTask} onPauseTask={pauseTask}
onOpenDetail={openTaskDetailInMainPanel} onOpenDetail={openBoardTaskDetail}
onOpenGroupModal={openGroupModalWithNav} onOpenGroupModal={openGroupModalWithNav}
addToast={addToast} addToast={addToast}
onQuickCreate={handleBoardQuickCreate} onQuickCreate={handleBoardQuickCreate}

View File

@@ -51,6 +51,9 @@ const otherTask = {
} as unknown as Task; } as unknown as Task;
const LazyStub = lazy(async () => ({ default: () => null })); const LazyStub = lazy(async () => ({ default: () => null }));
const LazySettingsCloseStub = lazy(async () => ({
default: ({ onClose }: { onClose: () => void }) => <button type="button" onClick={onClose}>Close settings view</button>,
}));
function mainContentProps(overrides: Partial<MainContentProps> = {}): MainContentProps { function mainContentProps(overrides: Partial<MainContentProps> = {}): MainContentProps {
return { return {
@@ -67,6 +70,7 @@ function mainContentProps(overrides: Partial<MainContentProps> = {}): MainConten
openWorkflowEditor: vi.fn(), openWorkflowEditor: vi.fn(),
} as unknown as MainContentProps["modalManager"], } as unknown as MainContentProps["modalManager"],
handleChangeTaskView: vi.fn(), handleChangeTaskView: vi.fn(),
refreshAppSettings: vi.fn(async () => undefined),
addToast: vi.fn(), addToast: vi.fn(),
currentProject: { id: "project-1", name: "Project 1" } as MainContentProps["currentProject"], currentProject: { id: "project-1", name: "Project 1" } as MainContentProps["currentProject"],
themeMode: "system", themeMode: "system",
@@ -205,6 +209,31 @@ function mainContentProps(overrides: Partial<MainContentProps> = {}): MainConten
} }
describe("MainContent graph task pop-out wiring", () => { describe("MainContent graph task pop-out wiring", () => {
it("refreshes app settings when the embedded Settings view closes", async () => {
const closeSettings = vi.fn();
const handleChangeTaskView = vi.fn();
const refreshAppSettings = vi.fn(async () => undefined);
render(
<MainContent
{...mainContentProps({
taskView: "settings",
modalManager: { closeSettings, settingsInitialSection: undefined, openWorkflowEditor: vi.fn() } as unknown as MainContentProps["modalManager"],
handleChangeTaskView,
refreshAppSettings,
_SettingsView: LazySettingsCloseStub as MainContentProps["_SettingsView"],
})}
/>,
);
await screen.findByText("Close settings view");
screen.getByText("Close settings view").click();
expect(closeSettings).toHaveBeenCalledTimes(1);
expect(handleChangeTaskView).toHaveBeenCalledWith("board");
expect(refreshAppSettings).toHaveBeenCalledTimes(1);
});
it("routes dependency-graph bridge and rendered task-card opens to the shared pop-out", () => { it("routes dependency-graph bridge and rendered task-card opens to the shared pop-out", () => {
hostContexts.length = 0; hostContexts.length = 0;
const openDetailTask = vi.fn(); const openDetailTask = vi.fn();

View File

@@ -69,6 +69,7 @@ export interface MainContentProps {
taskView: TaskView; taskView: TaskView;
modalManager: ModalManager; modalManager: ModalManager;
handleChangeTaskView: (newView: TaskView) => void; handleChangeTaskView: (newView: TaskView) => void;
refreshAppSettings: () => Promise<void>;
addToast: (message: string, type?: ToastType) => void; addToast: (message: string, type?: ToastType) => void;
currentProject: ProjectInfo | null; currentProject: ProjectInfo | null;
themeMode: ThemeMode; themeMode: ThemeMode;
@@ -156,6 +157,7 @@ export interface MainContentProps {
optionsOrPosition?: { preserveProgress?: boolean } | number, optionsOrPosition?: { preserveProgress?: boolean } | number,
) => Promise<Task>; ) => Promise<Task>;
pauseTask: (id: string) => Promise<Task>; pauseTask: (id: string) => Promise<Task>;
openBoardTaskDetail: (task: Task | TaskDetail, initialTab?: DetailTaskTab) => void;
openTaskDetailInMainPanel: (task: Task | TaskDetail, initialTab?: DetailTaskTab) => void; openTaskDetailInMainPanel: (task: Task | TaskDetail, initialTab?: DetailTaskTab) => void;
openGroupModalWithNav: (groupId: string) => void; openGroupModalWithNav: (groupId: string) => void;
handleBoardQuickCreate: (input: TaskCreateInput) => Promise<Task>; handleBoardQuickCreate: (input: TaskCreateInput) => Promise<Task>;

View File

@@ -18,7 +18,7 @@ export interface AppearanceSectionProps extends SectionBaseProps {
sessionBannersHidden: boolean; sessionBannersHidden: boolean;
setSessionBannersHidden: (hidden: boolean) => void; setSessionBannersHidden: (hidden: boolean) => void;
} }
export function AppearanceSection({ scopeBanner, setForm, themeMode, colorTheme, dashboardFontScalePct, shadcnCustomColors = {}, resolvedThemeMode, onThemeModeChange, onColorThemeChange, onDashboardFontScaleChange, onShadcnCustomColorsChange, sessionBannersHidden, setSessionBannersHidden, }: AppearanceSectionProps) { export function AppearanceSection({ scopeBanner, form, setForm, themeMode, colorTheme, dashboardFontScalePct, shadcnCustomColors = {}, resolvedThemeMode, onThemeModeChange, onColorThemeChange, onDashboardFontScaleChange, onShadcnCustomColorsChange, sessionBannersHidden, setSessionBannersHidden, }: AppearanceSectionProps) {
const { t } = useTranslation("app"); const { t } = useTranslation("app");
return (<> return (<>
{scopeBanner} {scopeBanner}
@@ -37,6 +37,13 @@ export function AppearanceSection({ scopeBanner, setForm, themeMode, colorTheme,
onShadcnCustomColorsChange?.(colors); onShadcnCustomColorsChange?.(colors);
}}/> }}/>
<LanguageSelector /> <LanguageSelector />
<div className="form-group">
<label className="checkbox-label">
<input type="checkbox" checked={form.openTasksInRightSidebar === true} onChange={(e) => setForm((f) => ({ ...f, openTasksInRightSidebar: e.target.checked }))}/>
<span>{t("settings.appearance.openTasksInRightSidebar", "Open tasks in the right sidebar")}</span>
</label>
<small className="form-text text-muted">{t("settings.appearance.openTasksInRightSidebarHelp", "When enabled, board task cards open detail in the right sidebar when it is available; mobile and hidden-sidebar states keep the full task panel.")}</small>
</div>
<div className="form-group"> <div className="form-group">
<label className="checkbox-label"> <label className="checkbox-label">
<input type="checkbox" checked={sessionBannersHidden} onChange={(e) => setSessionBannersHidden(e.target.checked)}/> <input type="checkbox" checked={sessionBannersHidden} onChange={(e) => setSessionBannersHidden(e.target.checked)}/>

View File

@@ -0,0 +1,63 @@
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import type { Settings } from "@fusion/core";
import { AppearanceSection } from "../AppearanceSection";
import type { SettingsFormState } from "../context";
vi.mock("../../ThemeSelector", () => ({
ThemeSelector: () => <div data-testid="theme-selector" />,
}));
vi.mock("../../LanguageSelector", () => ({
LanguageSelector: () => <div data-testid="language-selector" />,
}));
function renderAppearanceSection(formOverrides: Partial<Settings> = {}) {
let form: SettingsFormState = {
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: true,
autoMerge: true,
openTasksInRightSidebar: false,
...formOverrides,
} as SettingsFormState;
const setForm = vi.fn((updater: SettingsFormState | ((previous: SettingsFormState) => SettingsFormState)) => {
form = typeof updater === "function" ? updater(form) : updater;
});
render(
<AppearanceSection
scopeBanner={<div data-testid="scope-banner" />}
form={form}
setForm={setForm}
themeMode="dark"
colorTheme="ocean"
dashboardFontScalePct={100}
sessionBannersHidden={false}
setSessionBannersHidden={vi.fn()}
/>,
);
return { setForm, getForm: () => form };
}
describe("AppearanceSection", () => {
it("renders and updates the open-tasks-in-right-sidebar checkbox", () => {
const { setForm, getForm } = renderAppearanceSection();
const checkbox = screen.getByLabelText("Open tasks in the right sidebar");
expect(checkbox).not.toBeChecked();
fireEvent.click(checkbox);
expect(setForm).toHaveBeenCalledTimes(1);
expect(getForm().openTasksInRightSidebar).toBe(true);
});
it("reflects a persisted enabled value", () => {
renderAppearanceSection({ openTasksInRightSidebar: true });
expect(screen.getByLabelText("Open tasks in the right sidebar")).toBeChecked();
});
});

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import type { Task, TaskDetail, WorkflowStep } from "@fusion/core"; import type { ColumnId, GithubIssueAction, MergeResult, Task, TaskDetail, WorkflowStep } from "@fusion/core";
import { isNearDuplicateCanonicalInactive } from "../../../core/src/near-duplicate-canonical"; import { isNearDuplicateCanonicalInactive } from "../../../core/src/near-duplicate-canonical";
import type { ToastType } from "../hooks/useToast"; import type { ToastType } from "../hooks/useToast";
import type { DetailTaskTab } from "../hooks/useModalManager"; import type { DetailTaskTab } from "../hooks/useModalManager";
@@ -7,6 +7,7 @@ import { fetchTaskDetail } from "../api";
import { getScopedItem } from "../utils/projectStorage"; import { getScopedItem } from "../utils/projectStorage";
import { DOCK_FILES_CURRENT_KEY } from "./DockFilesView"; import { DOCK_FILES_CURRENT_KEY } from "./DockFilesView";
import { TaskCard } from "./TaskCard"; import { TaskCard } from "./TaskCard";
import { TaskDetailContent } from "./TaskDetailModal";
import { RightDock, persistRightDockOpen, persistRightDockPinned, readStoredRightDockOpen, readStoredRightDockPinned } from "./RightDock"; import { RightDock, persistRightDockOpen, persistRightDockPinned, readStoredRightDockOpen, readStoredRightDockPinned } from "./RightDock";
import { RightDockExpandModal } from "./RightDockExpandModal"; import { RightDockExpandModal } from "./RightDockExpandModal";
import type { OverflowViewKey, OverflowViewRenderProps, OverflowViewVisibilityOptions } from "./overflowViewRegistry"; import type { OverflowViewKey, OverflowViewRenderProps, OverflowViewVisibilityOptions } from "./overflowViewRegistry";
@@ -23,6 +24,14 @@ export interface RightDockControllerInput {
subscribePluginEvents: (pluginId: string, onEvent: (event: { event: string; payload: unknown }) => void) => () => void; subscribePluginEvents: (pluginId: string, onEvent: (event: { event: string; payload: unknown }) => void) => () => void;
openDetailTask: (task: Task | TaskDetail, initialTab?: DetailTaskTab) => void; openDetailTask: (task: Task | TaskDetail, initialTab?: DetailTaskTab) => void;
openFileInBrowser: (path: string, opts?: { workspace?: string; line?: number; col?: number }) => void; openFileInBrowser: (path: string, opts?: { workspace?: string; line?: number; col?: number }) => void;
onMoveTask: (id: string, column: ColumnId, optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise<Task>;
onDeleteTask: (id: string, options?: { removeDependencyReferences?: boolean; removeLineageReferences?: boolean; githubIssueAction?: GithubIssueAction; allowResurrection?: boolean }) => Promise<Task>;
onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise<Task>;
onMergeTask: (id: string) => Promise<MergeResult>;
onRetryTask?: (id: string) => Promise<Task>;
onResetTask?: (id: string) => Promise<Task>;
onDuplicateTask?: (id: string) => Promise<Task>;
onTaskUpdated?: (task: Task) => void;
openSettings: (section?: string) => void; openSettings: (section?: string) => void;
onOpenUsage?: (anchorRect?: DOMRect | null) => void; onOpenUsage?: (anchorRect?: DOMRect | null) => void;
onOpenActivityLog?: () => void; onOpenActivityLog?: () => void;
@@ -46,6 +55,8 @@ export interface RightDockController {
togglePin: () => void; togglePin: () => void;
dock: ReactNode; dock: ReactNode;
modal: ReactNode; modal: ReactNode;
openTaskInDock: (task: Task | TaskDetail) => void;
closeDockTask: () => void;
} }
/* /*
@@ -63,6 +74,22 @@ export function useRightDockController(input: RightDockControllerInput): RightDo
*/ */
const [pinned, setPinned] = useState(readStoredRightDockPinned); const [pinned, setPinned] = useState(readStoredRightDockPinned);
const [expandedView, setExpandedView] = useState<OverflowViewKey | null>(null); const [expandedView, setExpandedView] = useState<OverflowViewKey | null>(null);
const [dockTaskSnapshot, setDockTaskSnapshot] = useState<Task | TaskDetail | null>(null);
const closeDockTask = useCallback(() => {
setDockTaskSnapshot(null);
}, []);
const openTaskInDock = useCallback((task: Task | TaskDetail) => {
setDockTaskSnapshot(task);
setOpen(true);
persistRightDockOpen(true);
}, []);
const resolvedDockTask = useMemo(() => {
if (!dockTaskSnapshot) return null;
return input.tasks.find((candidate) => candidate.id === dockTaskSnapshot.id) ?? dockTaskSnapshot;
}, [dockTaskSnapshot, input.tasks]);
const toggle = useCallback(() => { const toggle = useCallback(() => {
setOpen((current) => { setOpen((current) => {
@@ -109,7 +136,10 @@ export function useRightDockController(input: RightDockControllerInput): RightDo
}, [input]); }, [input]);
useEffect(() => { useEffect(() => {
if (!input.active) setExpandedView(null); if (!input.active) {
setExpandedView(null);
setDockTaskSnapshot(null);
}
}, [input.active]); }, [input.active]);
const renderTaskCard = useCallback((task: Task | TaskDetail) => ( const renderTaskCard = useCallback((task: Task | TaskDetail) => (
@@ -167,12 +197,40 @@ export function useRightDockController(input: RightDockControllerInput): RightDo
openFile: input.openFileInBrowser, openFile: input.openFileInBrowser,
}), [input, renderTaskCard]); }), [input, renderTaskCard]);
const dockTaskContent = resolvedDockTask ? (
/*
FNXC:OpenTasksInRightSidebar 2026-06-28-00:00:
Board-routed right-sidebar task detail reuses the embedded TaskDetailContent surface so task actions, dependency links, and pop-out semantics stay aligned with the full-panel and list split-detail hosts. The controller resolves a live task row by id and falls back to the clicked snapshot so revalidation never blanks the dock.
*/
<TaskDetailContent
task={resolvedDockTask}
projectId={input.projectId}
tasks={input.tasks as Task[]}
embedded
onRequestClose={closeDockTask}
onOpenDetail={(value) => input.openDetailTask(value, "chat")}
onMoveTask={input.onMoveTask}
onDeleteTask={input.onDeleteTask}
onArchiveTask={input.onArchiveTask}
onMergeTask={input.onMergeTask}
onRetryTask={input.onRetryTask}
onResetTask={input.onResetTask}
onDuplicateTask={input.onDuplicateTask}
onTaskUpdated={input.onTaskUpdated}
addToast={input.addToast}
prAuthAvailable={input.prAuthAvailable}
autoMergeEnabled={input.autoMerge}
/>
) : null;
return { return {
open, open,
toggle, toggle,
pinned, pinned,
togglePin, togglePin,
dock: input.active ? <RightDock open={open} renderProps={renderProps} visibilityOptions={input.visibilityOptions} footerVisible={input.footerVisible} pinned={pinned} onTogglePin={togglePin} onExpand={handleExpand} /> : null, openTaskInDock,
closeDockTask,
dock: input.active ? <RightDock open={open} renderProps={renderProps} visibilityOptions={input.visibilityOptions} footerVisible={input.footerVisible} pinned={pinned} onTogglePin={togglePin} onExpand={handleExpand} dockTask={resolvedDockTask} dockTaskContent={dockTaskContent} onCloseDockTask={closeDockTask} /> : null,
modal: input.active ? <RightDockExpandModal viewKey={expandedView} renderProps={renderProps} visibilityOptions={input.visibilityOptions} onClose={() => setExpandedView(null)} /> : null, modal: input.active ? <RightDockExpandModal viewKey={expandedView} renderProps={renderProps} visibilityOptions={input.visibilityOptions} onClose={() => setExpandedView(null)} /> : null,
}; };
} }

View File

@@ -20,6 +20,7 @@ export interface UseAppSettingsResult {
staleHighFanoutBlockerAgeThresholdMs: number; staleHighFanoutBlockerAgeThresholdMs: number;
capacityRiskBannerEnabled: boolean; capacityRiskBannerEnabled: boolean;
capacityRiskTodoThreshold: number; capacityRiskTodoThreshold: number;
openTasksInRightSidebar: boolean;
quickChatButtonMode: QuickChatButtonMode; quickChatButtonMode: QuickChatButtonMode;
showQuickChatFAB: boolean; showQuickChatFAB: boolean;
maxTotalRetriesBeforeFail: number; maxTotalRetriesBeforeFail: number;
@@ -58,6 +59,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
const [staleHighFanoutBlockerAgeThresholdMs, setStaleHighFanoutBlockerAgeThresholdMs] = useState(2 * 60 * 60 * 1000); const [staleHighFanoutBlockerAgeThresholdMs, setStaleHighFanoutBlockerAgeThresholdMs] = useState(2 * 60 * 60 * 1000);
const [capacityRiskBannerEnabled, setCapacityRiskBannerEnabled] = useState(false); const [capacityRiskBannerEnabled, setCapacityRiskBannerEnabled] = useState(false);
const [capacityRiskTodoThreshold, setCapacityRiskTodoThreshold] = useState(20); const [capacityRiskTodoThreshold, setCapacityRiskTodoThreshold] = useState(20);
const [openTasksInRightSidebar, setOpenTasksInRightSidebar] = useState(false);
const [quickChatButtonMode, setQuickChatButtonMode] = useState<QuickChatButtonMode>("off"); const [quickChatButtonMode, setQuickChatButtonMode] = useState<QuickChatButtonMode>("off");
const [showQuickChatFAB, setShowQuickChatFAB] = useState(false); const [showQuickChatFAB, setShowQuickChatFAB] = useState(false);
const [maxTotalRetriesBeforeFail, setMaxTotalRetriesBeforeFail] = useState(25); const [maxTotalRetriesBeforeFail, setMaxTotalRetriesBeforeFail] = useState(25);
@@ -113,6 +115,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
setMaxTotalRetriesBeforeFail(settings.maxTotalRetriesBeforeFail ?? 25); setMaxTotalRetriesBeforeFail(settings.maxTotalRetriesBeforeFail ?? 25);
setCapacityRiskBannerEnabled(settings.capacityRiskBannerEnabled === true); setCapacityRiskBannerEnabled(settings.capacityRiskBannerEnabled === true);
setCapacityRiskTodoThreshold(settings.capacityRiskTodoThreshold ?? 20); setCapacityRiskTodoThreshold(settings.capacityRiskTodoThreshold ?? 20);
setOpenTasksInRightSidebar(settings.openTasksInRightSidebar === true);
setExperimentalFeatures(settings.experimentalFeatures ?? {}); setExperimentalFeatures(settings.experimentalFeatures ?? {});
const features = settings.experimentalFeatures ?? {}; const features = settings.experimentalFeatures ?? {};
/* /*
@@ -139,6 +142,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
setInsightsEnabled(true); setInsightsEnabled(true);
setMemoryEnabled(true); setMemoryEnabled(true);
setDevServerEnabled(false); setDevServerEnabled(false);
setOpenTasksInRightSidebar(false);
setTodosEnabled(true); setTodosEnabled(true);
setGoalsEnabled(true); setGoalsEnabled(true);
void refresh(); void refresh();
@@ -238,6 +242,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
staleHighFanoutBlockerAgeThresholdMs, staleHighFanoutBlockerAgeThresholdMs,
capacityRiskBannerEnabled, capacityRiskBannerEnabled,
capacityRiskTodoThreshold, capacityRiskTodoThreshold,
openTasksInRightSidebar,
quickChatButtonMode, quickChatButtonMode,
showQuickChatFAB, showQuickChatFAB,
maxTotalRetriesBeforeFail, maxTotalRetriesBeforeFail,

View File

@@ -172,6 +172,7 @@ const qualityAppComponentTests = [
"QuickChatFAB.shared-cache", "QuickChatFAB.shared-cache",
"ReliabilityView", "ReliabilityView",
"ResearchView", "ResearchView",
"RightDock",
"SecretsView", "SecretsView",
"SecretsView.mobile", "SecretsView.mobile",
"SettingsModal.testMode", "SettingsModal.testMode",