feat(dashboard): board task detail opens as a full main-panel view

Clicking a board card opens its detail in a full main-content view (replacing the board) with a 'Back to board' button, instead of the TaskDetailModal overlay. Only the Board entry point changes; list-view embed, right-dock cards, and other openDetail callers keep the modal. New 'task-detail' task view; embedded TaskDetailContent prefers the live task. Works within the mobile shell (swipe-back reverts to board). Tests updated (70 pass).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-22 02:45:01 -07:00
parent 0f98cf941f
commit b45df4c83b
4 changed files with 187 additions and 9 deletions

View File

@@ -13,6 +13,8 @@ import { Header, useViewportMode } from "./components/Header";
import { Board } from "./components/Board";
import { TaskCard } from "./components/TaskCard";
import { ListView } from "./components/ListView";
import { TaskDetailContent } from "./components/TaskDetailModal";
import { ArrowLeft } from "lucide-react";
import { ProjectOverview } from "./components/ProjectOverview";
import { MissionManager } from "./components/MissionManager";
import { MailboxView } from "./components/MailboxView";
@@ -546,6 +548,12 @@ function AppInner() {
}
);
/*
FNXC:Navigation 2026-06-22-00:00:
Snapshot of the task whose detail is shown in the main panel (Board card click → full-panel detail). Kept as a snapshot so the view survives a tasks revalidation; renderMainContent prefers the live row from `tasks` by id and falls back to this snapshot.
*/
const [mainPanelDetailTask, setMainPanelDetailTask] = useState<Task | TaskDetail | null>(null);
const previousTaskViewRef = useRef<TaskView>(taskView);
useEffect(() => {
@@ -1257,6 +1265,21 @@ function AppInner() {
pushNav({ type: "modal", close: modalManager.closeDetailTask });
}, [modalManager, pushNav]);
/*
FNXC:Navigation 2026-06-22-00:00:
Board card clicks open task detail as a full main-content view that replaces the board (design: "Full main panel (replaces board)"), instead of the TaskDetailModal overlay. We store a snapshot of the clicked task and navigate to the registered `task-detail` view; renderMainContent renders TaskDetailContent embedded with a Back-to-board button. Only the Board uses this handler — list-view split-detail, right-dock cards, and other openDetail callers keep the modal behavior.
*/
const openTaskDetailInMainPanel = useCallback((task: Task | TaskDetail) => {
setMainPanelDetailTask(task);
handleTaskViewChange("task-detail");
}, [handleTaskViewChange]);
// FNXC:Navigation 2026-06-22-00:00: Leaving task-detail clears the snapshot so a stale task never lingers if the view is reopened empty.
const closeTaskDetailMainPanel = useCallback(() => {
setMainPanelDetailTask(null);
handleTaskViewChange("board");
}, [handleTaskViewChange]);
/*
FNXC:Settings 2026-06-22-00:00:
Settings is now a main-content destination. The header/sidebar entry points navigate to the embedded `settings` view (carrying the requested deep-link section via setSettingsSection) instead of opening the modal overlay. handleTaskViewChange owns the back-navigation history entry, so no modal nav entry is pushed here.
@@ -1962,6 +1985,103 @@ function AppInner() {
);
}
/*
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.
*/
if (taskView === "task-detail") {
const liveDetailTask = mainPanelDetailTask
? (tasks.find((candidate) => candidate.id === mainPanelDetailTask.id) ?? mainPanelDetailTask)
: null;
if (!liveDetailTask) {
return (
<PageErrorBoundary>
<Board
tasks={filteredBoardTasks}
projectId={currentProject?.id}
maxConcurrent={maxConcurrent}
onMoveTask={moveTask}
onPauseTask={pauseTask}
onOpenDetail={openTaskDetailInMainPanel}
onOpenGroupModal={openGroupModalWithNav}
addToast={addToast}
onQuickCreate={handleBoardQuickCreate}
onNewTask={openNewTaskWithNav}
onPlanningMode={openPlanningWithInitialPlanWithNav}
onSubtaskBreakdown={subtaskBreakdownEnabled ? openSubtaskBreakdownWithNav : undefined}
autoMerge={autoMerge}
onToggleAutoMerge={toggleAutoMerge}
globalPaused={globalPaused}
onUpdateTask={updateTask}
onRetryTask={retryTask}
onArchiveTask={archiveTask}
onUnarchiveTask={unarchiveTask}
onDeleteTask={deleteTask}
onArchiveAllDone={archiveAllDone}
onLoadArchivedTasks={loadArchivedTasks}
searchQuery={searchQuery}
availableModels={availableModels}
onOpenDetailWithTab={handleOpenDetailWithTab}
favoriteProviders={favoriteProviders}
favoriteModels={favoriteModels}
onToggleFavorite={handleToggleFavorite}
onToggleModelFavorite={handleToggleModelFavorite}
taskStuckTimeoutMs={taskStuckTimeoutMs}
staleHighFanoutBlockerAgeThresholdMs={staleHighFanoutBlockerAgeThresholdMs}
onOpenMission={handleOpenMission}
lastFetchTimeMs={lastFetchTimeMs}
prAuthAvailable={prAuthAvailable}
onOpenWorkflowEditor={openWorkflowEditorWithNav}
onCreateWorkflow={openCreateWorkflowWithNav}
workflowColumnsEnabled={experimentalFeatures.workflowColumns === true}
settingsLoaded={settingsLoaded}
workflowControlsInHeader={sidebarActive}
/>
</PageErrorBoundary>
);
}
return (
<PageErrorBoundary>
<div className="task-detail-main-panel">
<div className="task-detail-main-panel-back-row">
<button
type="button"
className="task-detail-main-panel-back-btn"
onClick={closeTaskDetailMainPanel}
>
<ArrowLeft size={16} aria-hidden="true" />
<span>{t("app.taskDetail.backToBoard", "Back to board")}</span>
</button>
</div>
<div className="task-detail-main-panel-body">
<TaskDetailContent
task={liveDetailTask}
projectId={currentProject?.id}
tasks={tasks}
embedded
onOpenDetail={(value) => setMainPanelDetailTask(value)}
onMoveTask={moveTask}
onDeleteTask={deleteTask}
onMergeTask={mergeTask}
onRetryTask={retryTask}
onResetTask={resetTask}
onDuplicateTask={duplicateTask}
onTaskUpdated={(updatedTask) => {
setMainPanelDetailTask((previous) => {
if (!previous || previous.id !== updatedTask.id) return previous;
return { ...previous, ...updatedTask };
});
}}
addToast={addToast}
prAuthAvailable={prAuthAvailable}
autoMergeEnabled={autoMerge}
/>
</div>
</div>
</PageErrorBoundary>
);
}
if (taskView === "board") {
return (
<PageErrorBoundary>
@@ -1974,7 +2094,7 @@ function AppInner() {
maxConcurrent={maxConcurrent}
onMoveTask={moveTask}
onPauseTask={pauseTask}
onOpenDetail={openDetailTask}
onOpenDetail={openTaskDetailInMainPanel}
onOpenGroupModal={openGroupModalWithNav}
addToast={addToast}
onQuickCreate={handleBoardQuickCreate}

View File

@@ -166,6 +166,12 @@ vi.mock("../../components/TaskDetailModal", () => ({
</div>
</div>
),
// FNXC:Navigation 2026-06-22-00:00: Board card clicks now open task detail in the full main panel via TaskDetailContent (not the modal). The mock exposes a stable testid so the embedded-panel popstate tests can assert on the new surface.
TaskDetailContent: ({ task }: { task: { id: string; title?: string } }) => (
<div data-testid="task-detail-main-panel-content">
<h2>{task.title ?? task.id}</h2>
</div>
),
}));
vi.mock("../../components/SettingsModal", () => ({
@@ -572,16 +578,18 @@ describe("Navigation history integration", () => {
await renderMobileAppAndWait();
// FNXC:Navigation 2026-06-22-00:00: Board card click opens the full main-panel task detail (TaskDetailContent), and mobile popstate (swipe back) reverts the pushed `task-detail` view entry back to the board.
fireEvent.click(screen.getByTestId("open-task-FN-1"));
await waitFor(() => {
expect(screen.getByTestId("task-detail-modal")).toBeTruthy();
expect(screen.getByTestId("task-detail-main-panel-content")).toBeTruthy();
});
dispatchPopState({ navIndex: 0 });
await waitFor(() => {
expect(screen.queryByTestId("task-detail-modal")).toBeNull();
expect(screen.queryByTestId("task-detail-main-panel-content")).toBeNull();
expect(screen.getByTestId("board-view")).toBeTruthy();
});
});
@@ -605,31 +613,34 @@ describe("Navigation history integration", () => {
await renderMobileAppAndWait();
// FNXC:Navigation 2026-06-22-00:00: Board card click opens the full main-panel detail; the "Back to board" button reverts to the board, and a subsequent reopen + mobile popstate must also dismiss it (the regression this test guards).
fireEvent.click(screen.getByTestId("open-task-FN-1"));
await waitFor(() => {
expect(screen.getByTestId("task-detail-modal")).toBeTruthy();
expect(screen.getByTestId("task-detail-main-panel-content")).toBeTruthy();
});
fireEvent.click(screen.getByRole("button", { name: "Close" }));
fireEvent.click(screen.getByRole("button", { name: "Back to board" }));
// removeNav drives history.back(); consume the self-triggered popstate
// before reopening so the next popstate represents the user's swipe-back.
dispatchPopState({ navIndex: 0 });
await waitFor(() => {
expect(screen.queryByTestId("task-detail-modal")).toBeNull();
expect(screen.queryByTestId("task-detail-main-panel-content")).toBeNull();
expect(screen.getByTestId("board-view")).toBeTruthy();
});
fireEvent.click(screen.getByTestId("open-task-FN-1"));
await waitFor(() => {
expect(screen.getByTestId("task-detail-modal")).toBeTruthy();
expect(screen.getByTestId("task-detail-main-panel-content")).toBeTruthy();
});
dispatchPopState({ navIndex: 0 });
await waitFor(() => {
expect(screen.queryByTestId("task-detail-modal")).toBeNull();
expect(screen.queryByTestId("task-detail-main-panel-content")).toBeNull();
expect(screen.getByTestId("board-view")).toBeTruthy();
});
});

View File

@@ -9,7 +9,7 @@ export type ViewMode = "overview" | "project";
FNXC:ViewState 2026-06-22-00:00:
Workflows, Import Tasks, and Automations are promoted to top-level main-content task views (left-sidebar destinations) instead of modal-only overlays, so they render in the main panel like Command Center.
*/
export type BuiltInTaskView = "board" | "list" | "graph" | "agents" | "missions" | "chat" | "documents" | "research" | "evals" | "goalsView" | "todos" | "planning" | "skills" | "mailbox" | "insights" | "memory" | "command-center" | "secrets" | "devserver" | "dev-server" | "pull-requests" | "workflows" | "import-tasks" | "automations" | "settings";
export type BuiltInTaskView = "board" | "list" | "graph" | "agents" | "missions" | "chat" | "documents" | "research" | "evals" | "goalsView" | "todos" | "planning" | "skills" | "mailbox" | "insights" | "memory" | "command-center" | "secrets" | "devserver" | "dev-server" | "pull-requests" | "workflows" | "import-tasks" | "automations" | "settings" | "task-detail";
export type PluginTaskView = `plugin:${string}:${string}`;
export type TaskView = BuiltInTaskView | PluginTaskView;
@@ -52,6 +52,11 @@ const BUILT_IN_TASK_VIEWS: readonly BuiltInTaskView[] = [
Settings is promoted from a modal-only overlay into a top-level main-content task view so the header/sidebar Settings entry points dock it in the main panel like Command Center, while preserving deep-link section navigation.
*/
"settings",
/*
FNXC:Navigation 2026-06-22-00:00:
Clicking a task card on the Board opens its detail as a full main-content view ("Full main panel (replaces board)") with a Back-to-board button, instead of the TaskDetailModal overlay. The detail is hosted under this registered `task-detail` task view so navigation/persistence treat it like any other docked main-panel destination.
*/
"task-detail",
];
function isBuiltInTaskView(value: string | null): value is BuiltInTaskView {

View File

@@ -3836,3 +3836,45 @@ Toast text must contrast its status background across every dashboard theme and
font-size: 11px;
color: var(--text-muted);
}
/*
FNXC:Navigation 2026-06-22-00:00:
Board card clicks open task detail as a full main-content view that replaces the board ("Full main panel" design). This layout fills the main content area: a fixed Back-to-board row over a scrollable embedded TaskDetailContent body. Theme tokens only; mobile shell renders it unchanged because the panel just fills its host.
*/
.task-detail-main-panel {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.task-detail-main-panel-back-row {
flex: 0 0 auto;
padding: var(--space-lg);
border-bottom: 1px solid var(--border);
background: var(--bg);
}
.task-detail-main-panel-back-btn {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-xs) var(--space-sm);
font-size: 13px;
color: var(--text-muted);
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
}
.task-detail-main-panel-back-btn:hover {
color: var(--text);
background: var(--card-hover);
}
.task-detail-main-panel-body {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
}