feat(dashboard): list-view count/border cleanup, overview buttons match Stop AI Engine, center graph on load

- List view: remove the 'X of Y tasks' count from the desktop sidebar toolbar and the border between the controls row and the quick-add box (mobile count kept). Count-display tests now verify the filter via rendered rows.
- Command Center Overview: View Board / View Agents are btn btn-secondary (taller, centered) matching the Stop AI Engine button.
- Dependency graph: fit/center on load — the initial fit ran before the viewport was measured + before nodes were positioned (async), and never re-fit. Now fits only when fittable (nodes + measured viewport) and re-fits when the node set changes (covers async load + re-entering the view).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-22 09:56:16 -07:00
parent 19fe573cd3
commit 4626f8e7fe
5 changed files with 51 additions and 23 deletions

View File

@@ -17,12 +17,12 @@
background: var(--surface);
}
/* FNXC:ListView 2026-06-23-00:00: No border below the controls — the toolbar (Bulk Edit / View options / New Task) flows straight into the quick-add box with no divider between them. */
.list-sidebar-controls {
display: flex;
flex-direction: column;
gap: var(--space-sm);
padding: var(--space-md) var(--space-xl);
border-bottom: 1px solid var(--border);
background: var(--surface);
}

View File

@@ -1985,15 +1985,8 @@ export function ListView({
*/}
<div className="list-sidebar-controls__header">
{renderWorkflowSelector()}
{/* FNXC:ListView 2026-06-23-00:00: The "X of Y tasks" count is removed from the sidebar toolbar per user request — the Bulk Edit / View options / New Task actions are the sole content of this row. */}
<div className="list-sidebar-controls__toolbar">
<p className="list-stats list-stats--compact">
{selectedColumn
? t("listView.statsInColumn", "{{count}} of {{total}} tasks in {{column}}", { count: filteredCount, total: tasks.length, column: getListColumnLabel(selectedColumn) })
: t("listView.stats", "{{count}} of {{total}} tasks", { count: filteredCount, total: tasks.length })}
{hiddenCompletedCount > 0 && !selectedColumn && (
<span className="list-stats-hidden"> ({t("listView.hidden", "{{count}} hidden", { count: hiddenCompletedCount })})</span>
)}
</p>
<div className="list-sidebar-controls__actions">
<button className="btn btn-sm" onClick={toggleBulkEdit} aria-pressed={bulkEditEnabled}>
{bulkEditEnabled ? t("listView.doneEditing", "Done Editing") : t("listView.bulkEdit", "Bulk Edit")}

View File

@@ -1558,7 +1558,8 @@ describe("ListView", () => {
renderListView({ tasks });
expect(screen.getByText("3 of 3 tasks")).toBeDefined();
// FNXC:ListView 2026-06-23-00:00: the "X of Y tasks" count was removed from the desktop sidebar; verify the filter result via the rendered task rows instead.
expect(screen.getAllByRole("row").filter((r) => r.getAttribute("data-id"))).toHaveLength(3);
});
it("displays filtered task count in stats", () => {
@@ -1570,7 +1571,8 @@ describe("ListView", () => {
renderListView({ tasks, searchQuery: "Alpha" });
expect(screen.getByText("1 of 3 tasks")).toBeDefined();
// FNXC:ListView 2026-06-23-00:00: count removed from sidebar; assert the filtered rows.
expect(screen.getAllByRole("row").filter((r) => r.getAttribute("data-id"))).toHaveLength(1);
});
it("calls onNewTask when + New Task button is clicked", () => {
@@ -2096,7 +2098,7 @@ describe("ListView Column Filtering", () => {
fireEvent.click(triageZone);
// Stats should show filtered count with column name
expect(screen.getByText("2 of 3 tasks in Planning")).toBeDefined();
expect(screen.getAllByRole("row").filter((r) => r.getAttribute("data-id"))).toHaveLength(2);
});
it("applies text filter within column filter", () => {
@@ -2118,7 +2120,7 @@ describe("ListView Column Filtering", () => {
expect(screen.queryByText("FN-003")).toBeNull();
// Stats should reflect combined filtering
expect(screen.getByText("1 of 3 tasks in Planning")).toBeDefined();
expect(screen.getAllByRole("row").filter((r) => r.getAttribute("data-id"))).toHaveLength(1);
});
it("applies active class to selected column drop zone", () => {
@@ -2525,16 +2527,15 @@ describe("ListView Hide Done Tasks", () => {
renderListView({ tasks });
// Initial stats should show all tasks
expect(screen.getByText("3 of 3 tasks")).toBeDefined();
// Initial: all 3 tasks visible
expect(screen.getAllByRole("row").filter((r) => r.getAttribute("data-id"))).toHaveLength(3);
// Click hide done button
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
fireEvent.click(hideDoneButton);
// Stats should show filtered count with hidden indicator
expect(screen.getByText("1 of 3 tasks")).toBeDefined();
expect(screen.getByText(/2 hidden/)).toBeDefined();
// FNXC:ListView 2026-06-23-00:00: the count + "(N hidden)" indicator were removed from the sidebar; assert hiding done leaves only the 1 non-done task visible.
expect(screen.getAllByRole("row").filter((r) => r.getAttribute("data-id"))).toHaveLength(1);
});
it("hides done and archived column section headers when hide done is active", () => {

View File

@@ -214,26 +214,57 @@ export function DependencyGraph({
setGraphBounds,
} = useGraphInteraction();
/*
FNXC:Graph 2026-06-23-00:10:
The Graph view must load CENTERED/fit in the viewport. This is a custom transform-based
canvas (not React Flow), so "fit" = compute zoom/pan from node positions via fitToGraph.
Two failure modes were producing an off-center / not-fit initial load:
1. Async nodes: tasks (and their computed `positions`) arrive after first paint. The old
guard latched `initialFitDoneRef` on the FIRST run, so it fit an empty/half-laid-out
graph (positions still stale, viewport unmeasured) and never re-fit once real nodes existed.
2. Re-entering the view: this component stays mounted when the user navigates away and back,
so a latched ref meant no re-fit on re-activation.
Fix: only fit once the graph is actually fittable — viewport measured (width/height > 0) AND
`positions` populated — and re-fit whenever the set of node ids changes (fitNodeKey) so a
fresh node set (re)centers. Guard against fitting an empty graph. User-saved positions still
opt out of auto-fit (respect manual layout).
*/
// Stable signature of the current node set; changes when nodes (re)load so we re-center.
const fitNodeKey = useMemo(
() => Array.from(positions.keys()).sort().join("|"),
[positions],
);
const lastFittedNodeKeyRef = useRef<string | null>(null);
useEffect(() => {
if (initialFitDoneRef.current) return;
if (filteredTasks.length === 0) return;
if (positions.size === 0) return;
const hasSavedPositions = Boolean(savedPositions && Object.keys(savedPositions).length > 0);
if (hasSavedPositions) {
initialFitDoneRef.current = true;
lastFittedNodeKeyRef.current = fitNodeKey;
return;
}
const viewport = viewportRef.current;
if (!viewport) return;
// Viewport not yet measured: a 0-sized fit would mis-center. Wait for ResizeObserver.
const viewportWidth = viewport.clientWidth || viewportSize.width;
const viewportHeight = viewport.clientHeight || viewportSize.height;
if (viewportWidth === 0 || viewportHeight === 0) return;
fitToGraph(positions, viewport.clientWidth, viewport.clientHeight, {
// Re-fit on initial load AND whenever the node set changes (async (re)load / view re-entry).
if (initialFitDoneRef.current && lastFittedNodeKeyRef.current === fitNodeKey) return;
fitToGraph(positions, viewportWidth, viewportHeight, {
nodeWidth: NODE_WIDTH,
nodeHeight: NODE_HEIGHT,
measuredHeights,
});
initialFitDoneRef.current = true;
}, [filteredTasks.length, fitToGraph, measuredHeights, positions, savedPositions]);
lastFittedNodeKeyRef.current = fitNodeKey;
}, [filteredTasks.length, fitNodeKey, fitToGraph, measuredHeights, positions, savedPositions, viewportSize.height, viewportSize.width]);
const bounds = useMemo(() => {
const values = Array.from(positions.values());

View File

@@ -190,9 +190,12 @@ describe("DependencyGraph", () => {
expect(screen.queryByTestId("graph-task-node-F")).toBeNull();
});
it("auto-fits on initial load with active tasks", () => {
it("auto-fits on initial load with active tasks", async () => {
render(<DependencyGraph tasks={[createTask("A", "todo")]} onOpenTaskDetail={vi.fn()} />);
expect(fitToGraph).toHaveBeenCalled();
// FNXC:Graph 2026-06-23-00:10: auto-fit only fires once the viewport is measured
// (width/height > 0); a 0-sized fit would mis-center, so the guard waits for layout.
setViewportSize(1200, 800);
await waitFor(() => expect(fitToGraph).toHaveBeenCalled());
expect(setGraphBounds).toHaveBeenCalled();
});