fix(dashboard): unify movable task detail header

This commit is contained in:
gsxdsm
2026-06-22 12:16:07 -07:00
parent aa74f96234
commit 915d4b028a
12 changed files with 209 additions and 46 deletions

View File

@@ -13,7 +13,6 @@ import { Header, useViewportMode } from "./components/Header";
import { Board } from "./components/Board";
import { TaskCard } from "./components/TaskCard";
import { ListView } from "./components/ListView";
import { Maximize2 } from "lucide-react";
import { TaskDetailContent } from "./components/TaskDetailModal";
import { FloatingWindow } from "./components/FloatingWindow";
import { ProjectOverview } from "./components/ProjectOverview";
@@ -1755,6 +1754,7 @@ function AppInner() {
projectId={currentProject?.id}
addToast={addToast}
onOpenDetail={openDetailTask}
onOpenArtifactTaskDetail={popOutTaskDetail}
onSendSelectionToTask={modalManager.openNewTaskWithDescription}
/>
</Suspense>
@@ -2516,6 +2516,9 @@ function AppInner() {
{/*
FNXC:FloatingWindow 2026-06-22-20:45:
One movable, resizable, non-blocking FloatingWindow per popped-out task. Each hosts the same embedded TaskDetailContent List/Board use, wired to the same App task handlers. Live row preferred by id; falls back to the snapshot. Terminal/destructive actions and the window close button both remove the entry. Multiple entries → multiple coexisting windows; FloatingWindow's per-window z-counter handles focus-to-front so the clicked one comes on top.
FNXC:TaskDetail 2026-06-22-12:20:
Task pop-outs use TaskDetailContent's own gray header as the only visible header, matching the one-header fixed task modal while keeping FloatingWindow drag/resize. The generic Maximize title chrome is hidden; close now lives beside edit inside the task header.
*/}
{poppedOutTasks.map((snapshot) => {
const liveTask = tasks.find((candidate) => candidate.id === snapshot.id) ?? snapshot;
@@ -2524,13 +2527,10 @@ function AppInner() {
<FloatingWindow
key={snapshot.id}
windowKey={`task-detail-${snapshot.id}`}
title={
<>
<Maximize2 size={14} aria-hidden="true" />
<span>{liveTask.id}</span>
</>
}
title={liveTask.id}
onClose={close}
hideHeader
dragHandleSelector=".task-detail-content--embedded > .modal-header"
>
<TaskDetailContent
task={liveTask}

View File

@@ -24,6 +24,7 @@ export interface DocumentsViewProps {
projectId?: string;
addToast: (message: string, type?: ToastType) => void;
onOpenDetail: (task: TaskDetail) => void;
onOpenArtifactTaskDetail?: (task: TaskDetail) => void;
onSendSelectionToTask?: (description: string) => void;
}
@@ -248,7 +249,7 @@ function ArtifactCard({ artifact, projectId, onOpenTask, onExpandMedia }: Artifa
);
}
export function DocumentsView({ projectId, addToast, onOpenDetail, onSendSelectionToTask }: DocumentsViewProps) {
export function DocumentsView({ projectId, addToast, onOpenDetail, onOpenArtifactTaskDetail, onSendSelectionToTask }: DocumentsViewProps) {
const { t } = useTranslation("app");
const [activeTab, setActiveTab] = useState<DocumentsTab>("project");
const [searchQuery, setSearchQuery] = useState("");
@@ -420,6 +421,22 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onSendSelecti
}
}, [projectId, onOpenDetail, addToast]);
/*
FNXC:ArtifactRegistry 2026-06-22-12:00:
Artifact cards should open their parent task in the same movable task popup
used by board/list pop-out flows, not the fixed task-detail modal. Keep task
document groups on the existing onOpenDetail path so only artifact-origin
task opens change surface.
*/
const handleOpenArtifactTask = useCallback(async (taskId: string) => {
try {
const task = await fetchTaskDetail(taskId, projectId);
(onOpenArtifactTaskDetail ?? onOpenDetail)(task);
} catch {
addToast(`Failed to open task ${taskId}`, "error");
}
}, [projectId, onOpenArtifactTaskDetail, onOpenDetail, addToast]);
const handleSelectProjectFile = useCallback(async (file: MarkdownFileEntry) => {
setSelectedFile(file);
setFileLoading(true);
@@ -763,7 +780,7 @@ export function DocumentsView({ projectId, addToast, onOpenDetail, onSendSelecti
key={artifact.id}
artifact={artifact}
projectId={projectId}
onOpenTask={handleOpenTask}
onOpenTask={handleOpenArtifactTask}
onExpandMedia={handleExpandArtifact}
/>
))}

View File

@@ -54,6 +54,29 @@ Header is the drag handle. `touch-action: none` (matching the resize handles) ha
cursor: grabbing;
}
/*
FNXC:FloatingWindow 2026-06-22-12:20:
Headerless task pop-outs still need a visible drag affordance. The embedded task-detail modal header becomes the grab handle, matching the one-header "Open task" modal while preserving FloatingWindow's drag and resize behavior.
*/
.floating-window--headerless .floating-window__body {
overflow: hidden;
}
.floating-window--headerless .task-detail-content--embedded {
border-radius: inherit;
overflow: hidden;
}
.floating-window--headerless .task-detail-content--embedded > .modal-header {
cursor: grab;
user-select: none;
touch-action: none;
}
.floating-window--headerless .task-detail-content--embedded > .modal-header:active {
cursor: grabbing;
}
.floating-window__title {
display: flex;
flex: 1;

View File

@@ -38,6 +38,12 @@ export interface FloatingWindowProps {
defaultSize?: FloatingWindowSize;
defaultPosition?: FloatingWindowPosition;
minSize?: FloatingWindowSize;
/*
FNXC:FloatingWindow 2026-06-22-12:20:
Task detail pop-outs should look like the fixed "Open task" modal: one task header containing task id, status badge, edit, and close. `hideHeader` removes the generic window chrome, while `dragHandleSelector` lets that task header remain the drag handle so the modal stays movable and resizable.
*/
hideHeader?: boolean;
dragHandleSelector?: string;
}
const DEFAULT_WIDTH = 720;
@@ -100,6 +106,8 @@ export function FloatingWindow({
defaultSize,
defaultPosition,
minSize,
hideHeader = false,
dragHandleSelector,
}: FloatingWindowProps) {
const resolvedMinSize: FloatingWindowSize = minSize ?? { width: DEFAULT_MIN_WIDTH, height: DEFAULT_MIN_HEIGHT };
@@ -183,6 +191,16 @@ export function FloatingWindow({
[bringToFront, position, size]
);
const handlePanelPointerDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (!hideHeader || !dragHandleSelector) return;
const target = event.target as HTMLElement | null;
if (!target?.closest(dragHandleSelector)) return;
handleDragPointerDown(event);
},
[dragHandleSelector, handleDragPointerDown, hideHeader]
);
const handleResizePointerDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>, direction: ResizeDirection) => {
event.preventDefault();
@@ -280,10 +298,11 @@ export function FloatingWindow({
style={{ zIndex }}
>
<div
className="floating-window"
className={`floating-window${hideHeader ? " floating-window--headerless" : ""}`}
style={panelStyle}
data-testid={`floating-window-${windowKey}`}
onPointerDownCapture={bringToFront}
onPointerDown={handlePanelPointerDown}
onFocusCapture={bringToFront}
>
{RESIZE_DIRECTIONS.map((direction) => (
@@ -296,22 +315,24 @@ export function FloatingWindow({
onPointerDown={(event) => handleResizePointerDown(event, direction)}
/>
))}
<div
className="floating-window__header"
data-testid={`floating-window-drag-handle-${windowKey}`}
onPointerDown={handleDragPointerDown}
>
<div className="floating-window__title">{title}</div>
<button
type="button"
className="floating-window__close"
onClick={onClose}
aria-label="Close floating window"
data-testid={`floating-window-close-${windowKey}`}
{!hideHeader && (
<div
className="floating-window__header"
data-testid={`floating-window-drag-handle-${windowKey}`}
onPointerDown={handleDragPointerDown}
>
<X size={18} />
</button>
</div>
<div className="floating-window__title">{title}</div>
<button
type="button"
className="floating-window__close"
onClick={onClose}
aria-label="Close floating window"
data-testid={`floating-window-close-${windowKey}`}
>
<X size={18} />
</button>
</div>
)}
<div className="floating-window__body" data-testid={`floating-window-body-${windowKey}`}>
{children}
</div>

View File

@@ -40,10 +40,15 @@ Bump the top padding from var(--space-lg) to var(--space-xl) so the first card/w
color: var(--text-muted);
}
/*
FNXC:ViewHeader 2026-06-23-05:00:
The Add Goal button rides in the ViewHeader actions row, which is clamped to --view-header-content-row (28px). Base `.btn` padding (8px 16px) + an 18px icon is intrinsically ~36px and made the Goals header 69px. Reduce padding to the btn-sm box (4px 10px) so the button's CONTENT fits inside 28px without clipping (the 18px icon stays centered), bringing the header to the canonical 61px while keeping btn-primary color/border.
*/
.goals-add-button {
display: inline-flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-xs) calc(var(--space-sm) + var(--space-xs) / 2);
}
.goals-add-button:focus-visible,

View File

@@ -866,25 +866,6 @@ export function ListView({
return Object.values(groupedTasks).reduce((sum, group) => sum + group.length, 0);
}, [groupedTasks]);
// Calculate done and archived task counts for stats display
const completedTaskCount = useMemo(() => {
const completedColumns = new Set(
listColumns
.filter((column) => column.flags.complete || column.flags.archived)
.map((column) => column.id),
);
return tasks.filter((task) => {
if (selectedWorkflowTaskIds && !selectedWorkflowTaskIds.has(task.id)) return false;
return completedColumns.has(task.column);
}).length;
}, [listColumns, selectedWorkflowTaskIds, tasks]);
// Calculate hidden done+archived tasks count
const hiddenCompletedCount = useMemo(() => {
if (!hideDoneTasks) return 0;
return completedTaskCount;
}, [hideDoneTasks, completedTaskCount]);
// Selection logic that depends on groupedTasks (must be after groupedTasks definition)
// Toggle all visible tasks
const toggleSelectAll = useCallback(() => {

View File

@@ -422,6 +422,10 @@ export interface TaskDetailModalProps {
export type TaskDetailContentProps = Omit<TaskDetailModalProps, "onClose"> & {
embedded?: boolean;
/*
FNXC:TaskDetail 2026-06-22-12:20:
Embedded task detail can be hosted by a movable FloatingWindow. In that surface the task header is the only visible header, so onRequestClose must render a close icon beside edit instead of relying on separate window chrome.
*/
onRequestClose?: () => void;
/*
FNXC:TaskDetail 2026-06-22-18:40:
@@ -2800,6 +2804,16 @@ export function TaskDetailContent({
<Pencil size={14} />
</button>
)}
{embedded && onRequestClose && !onBackToBoard && (
<button
className="modal-close task-detail-floating-close"
onClick={requestClose}
aria-label={t("common.close", "Close")}
type="button"
>
<X size={16} aria-hidden="true" />
</button>
)}
{!embedded && mobileHeaderMode === "back" && (
<button
className="modal-close task-detail-mobile-back"

View File

@@ -8,6 +8,9 @@ ViewHeader is now THE canonical top header for every left-sidebar/main-content v
/*
FNXC:ViewHeader 2026-06-23-04:15:
Pin a shared min-height (--view-header-min-height ≈ 61px border-box) so headers WITH btn-sm actions and title-only headers render the SAME height. box-sizing:border-box keeps padding+border inside the pinned height; align-items:center vertically centers the title/actions row within it.
FNXC:ViewHeader 2026-06-23-05:00:
min-height alone let DESKTOP headers whose actions were TALLER than the canonical content row grow past 61px: Skills hit 77 via a 44px `touch-target` close button, Goals 69 via a 36px base `.btn` primary, Agents 65 via the 32px `.view-toggle` segmented switch. The desktop clamping rules below (scoped to the non-mobile breakpoint) pin a FIXED `height` and bound every action child to --view-header-content-row (28px) so tall controls collapse into the canonical row instead of stretching it; align-items:center keeps the 16-18px icons centered and nothing clips. The base rule keeps `min-height` (no fixed height) so the MOBILE breakpoint — where controls intentionally grow to 36px touch targets (see AgentsView.css @media max-width:768px) — can still expand the header instead of clipping those targets.
*/
.view-header {
box-sizing: border-box;
@@ -53,3 +56,44 @@ Pin a shared min-height (--view-header-min-height ≈ 61px border-box) so header
gap: var(--space-sm);
margin-left: auto;
}
/*
FNXC:ViewHeader 2026-06-23-05:00:
DESKTOP-ONLY canonical-height clamp. The mobile breakpoint is `(max-width: 768px), (max-height: 480px)` (landscape phones can exceed 768 wide, hence the height arm — see project mobile-breakpoint note), so the non-mobile complement is `(min-width: 769px) and (min-height: 481px)`. We scope the clamp here because mobile intentionally GROWS these controls to 36px touch targets (AgentsView.css @media max-width:768px); clamping there would clip them. On desktop:
- Pin a FIXED header height so it can never exceed the canonical 61px.
- nowrap + a fixed content-row-height actions cluster keep everything on one row (wrapping was a secondary way headers grew past 61px).
- Bound every action child to --view-header-content-row (28px): Goals' base `.btn` primary (intrinsic ~36px → now padding-trimmed to fit), Skills' 44px `touch-target` close button, and Agents' 32px `.view-toggle` all collapse to the row. align-items:center keeps the 16-18px icons centered; nothing clips because each control's own content fits inside 28px.
*/
@media (min-width: 769px) and (min-height: 481px) {
.view-header {
height: var(--view-header-min-height);
}
.view-header__actions {
flex-wrap: nowrap;
height: var(--view-header-content-row);
}
.view-header__actions > * {
min-height: 0;
max-height: var(--view-header-content-row);
white-space: nowrap;
}
/* Skills' close button carries `touch-target` (min-height:44px); force it square at the canonical row so it stops stretching the header to 77px. The 16px X icon stays centered and tappable. */
.view-header__actions .touch-target {
min-width: var(--view-header-content-row);
min-height: var(--view-header-content-row);
width: var(--view-header-content-row);
height: var(--view-header-content-row);
}
/* Agents' `.view-toggle` segmented switch is intrinsically 32px; bound it (and its inner 28px buttons) to the canonical row so it no longer adds 4px. Stays a single horizontal row of toggles. */
.view-header__actions .view-toggle {
height: var(--view-header-content-row);
}
.view-header__actions .view-toggle .view-toggle-btn {
height: 100%;
}
}

View File

@@ -275,6 +275,7 @@ describe("DocumentsView", () => {
});
it("renders artifacts tab counts and all media card paths without non-media expand shells", async () => {
const onOpenArtifactTaskDetail = vi.fn();
mockUseArtifacts.mockReturnValue({
artifacts: mockArtifacts,
loading: false,
@@ -282,7 +283,13 @@ describe("DocumentsView", () => {
refresh: vi.fn().mockResolvedValue(undefined),
});
render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
render(
<DocumentsView
addToast={addToast}
onOpenDetail={onOpenDetail}
onOpenArtifactTaskDetail={onOpenArtifactTaskDetail}
/>
);
const artifactsTab = screen.getByRole("tab", { name: /show artifacts/i });
expect(artifactsTab).toHaveTextContent("5");
@@ -310,8 +317,9 @@ describe("DocumentsView", () => {
fireEvent.click(screen.getByRole("button", { name: /open task KB-001/i }));
await waitFor(() => {
expect(mockFetchTaskDetail).toHaveBeenCalledWith("KB-001", undefined);
expect(onOpenDetail).toHaveBeenCalledWith({ id: "KB-001" });
expect(onOpenArtifactTaskDetail).toHaveBeenCalledWith({ id: "KB-001" });
});
expect(onOpenDetail).not.toHaveBeenCalled();
expect(screen.getAllByRole("button", { name: /open task/i })).toHaveLength(1);
});

View File

@@ -42,6 +42,30 @@ describe("FloatingWindow", () => {
}
});
it("can hide generic chrome and delegate dragging to a child header", () => {
render(
<FloatingWindow
windowKey="task"
title="KB-001"
onClose={() => {}}
hideHeader
dragHandleSelector=".task-detail-content--embedded > .modal-header"
>
<div className="task-detail-content--embedded">
<div className="modal-header">KB-001</div>
<div>task body</div>
</div>
</FloatingWindow>
);
expect(screen.queryByTestId("floating-window-drag-handle-task")).toBeNull();
expect(screen.getByTestId("floating-window-task")).toHaveClass("floating-window--headerless");
expect(screen.getByText("KB-001")).toBeInTheDocument();
for (const dir of ["n", "s", "e", "w", "ne", "nw", "se", "sw"]) {
expect(screen.getByTestId(`floating-window-resize-${dir}`)).toBeTruthy();
}
});
it("focus-to-front: interacting with an older window raises its z-index above the newest", () => {
render(
<>

View File

@@ -707,6 +707,27 @@ describe("TaskDetailModal", () => {
expect(screen.getByRole("button", { name: "Definition" })).toBeInTheDocument();
});
it("renders header close control for embedded floating task details", () => {
const onRequestClose = vi.fn();
render(
<TaskDetailContent
task={makeTask()}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
embedded
onRequestClose={onRequestClose}
/>,
);
const closeButton = screen.getByRole("button", { name: "Close" });
expect(closeButton).toHaveClass("task-detail-floating-close");
fireEvent.click(closeButton);
expect(onRequestClose).toHaveBeenCalledTimes(1);
});
it("styles detail-body scrollbar rules", () => {
const css = readDashboardStylesSource();

View File

@@ -153,6 +153,11 @@ html {
Derivation (border-box): vertical padding var(--space-lg)*2 = 32px + 1px bottom divider + ~28px tallest content row (btn-sm: 4+4 padding + 1+1 border + ~18px 12px-font line) = 61px.
*/
--view-header-min-height: calc(var(--space-lg) * 2 + 28px + 1px);
/*
FNXC:ViewHeader 2026-06-23-05:00:
Canonical content-row height (28px = btn-sm box: 4+4 padding + 1+1 border + ~18px line). ViewHeader bounds every action child to this so taller controls (touch-target 44px close buttons, view-toggle 32px segmented switches, base .btn 36px primary buttons) collapse to the canonical row instead of stretching the header past --view-header-min-height. Skills (was 77px / 44px touch-target), Goals (was 69px / 36px primary btn), and Agents (was 65px / 32px view-toggle) all clamp to ~61px without clipping their 16-18px icons, which stay centered.
*/
--view-header-content-row: 28px;
--column-gap: var(--space-md);
--board-padding: var(--space-lg) var(--space-xl);
--icon-size-md: 16px;