fix(dashboard): unify movable task detail header
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
<>
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user