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:
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user