feat(FN-3190): fix ListView split-pane UX follow-up issues

Fixes FN-3190 addresses follow-up UX feedback on the split-pane component in ListView, updating the layout styles and component logic with accompanying tests.

Fusion-Task-Id: FN-3190
This commit is contained in:
Fusion
2026-05-02 04:45:17 -07:00
committed by gsxdsm
parent f76a6755d9
commit 3e747f2190
5 changed files with 441 additions and 35 deletions

View File

@@ -38,6 +38,10 @@
font-style: italic;
}
.list-clear-column-filter-btn {
margin-left: var(--space-sm);
}
/* Selection stats */
.list-selection-stats {
display: flex;
@@ -262,14 +266,14 @@
.list-drop-zone.active {
border-color: var(--todo);
background: rgba(88, 166, 255, 0.15);
background: color-mix(in srgb, var(--todo) 15%, transparent);
box-shadow: 0 0 0 1px var(--todo);
}
.list-drop-zone.drag-over {
border-color: var(--todo);
box-shadow: 0 0 0 1px var(--todo);
background: rgba(88, 166, 255, 0.1);
background: color-mix(in srgb, var(--todo) 10%, transparent);
}
.drop-zone-dot {
@@ -302,6 +306,52 @@
padding: 0;
}
.list-split-layout {
display: grid;
grid-template-columns: auto var(--space-xs) minmax(0, 1fr);
height: 100%;
min-height: 0;
}
.list-split-sidebar {
min-width: 0;
min-height: 0;
overflow: auto;
}
.list-split-resize-handle {
cursor: col-resize;
background: color-mix(in srgb, var(--border) 70%, transparent);
transition: background var(--transition-fast);
}
.list-split-resize-handle:hover,
.list-split-resize-handle:focus-visible {
background: color-mix(in srgb, var(--todo) 35%, transparent);
}
.list-split-resize-handle:focus-visible {
box-shadow: var(--focus-ring-strong);
outline: none;
}
.list-split-detail {
min-width: 0;
min-height: 0;
border-left: 1px solid var(--border);
padding: var(--space-xl);
color: var(--text-muted);
display: flex;
align-items: center;
justify-content: center;
text-align: center;
}
.list-split-detail p {
margin-block: 0;
margin-inline: 0;
}
.list-table {
width: 100%;
border-collapse: collapse;
@@ -332,7 +382,7 @@
/* Reduce padding on ID column header to match data cells */
.list-table th:first-child.list-header-cell {
padding-right: 8px;
padding-right: var(--space-sm);
}
.list-header-cell:hover {
@@ -361,6 +411,11 @@
background: var(--card-hover);
}
.list-row--selected {
background: color-mix(in srgb, var(--todo) 12%, transparent);
box-shadow: inset 0 0 0 1px var(--todo);
}
.list-row.dragging {
opacity: 0.4;
}
@@ -653,7 +708,7 @@
@keyframes section-expand {
from {
opacity: 0;
transform: translateY(-4px);
transform: translateY(calc(var(--space-xs) * -1));
}
to {
opacity: 1;
@@ -662,7 +717,7 @@
}
.list-section-header ~ tr {
animation: section-expand 0.2s ease-out;
animation: section-expand var(--transition-normal) ease-out;
}
/* Empty section row */
@@ -716,6 +771,22 @@
overflow-x: auto;
}
.list-split-layout {
display: block;
}
.list-split-sidebar,
.list-split-detail {
overflow: visible;
border: none;
padding: 0;
}
.list-split-resize-handle,
.list-split-detail {
display: none;
}
.list-table {
min-width: auto;
}

View File

@@ -11,7 +11,7 @@ import { NodeHealthDot } from "./NodeHealthDot";
import { isTaskStuck } from "../utils/taskStuck";
import type { ToastType } from "../hooks/useToast";
import { useViewportMode } from "../hooks/useViewportMode";
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage";
import { getUnifiedTaskProgress } from "../utils/taskProgress";
const COLUMN_COLOR_MAP: Record<Column, string> = {
@@ -111,6 +111,48 @@ function readSelectedTaskIds(projectId?: string): Set<string> {
return new Set<string>();
}
function readSelectedTaskId(projectId?: string): string | null {
try {
const saved = getScopedItem("kb-dashboard-list-selected-task", projectId);
if (typeof saved === "string" && saved.trim().length > 0) {
return saved;
}
} catch {
// Invalid localStorage data - fall through to default
}
return null;
}
function readSidebarWidth(projectId?: string): number {
const fallbackWidth = 400;
try {
const saved = getScopedItem("kb-dashboard-list-sidebar-width", projectId);
if (!saved) return fallbackWidth;
const parsed = Number(saved);
if (Number.isFinite(parsed) && parsed > 0) {
return parsed;
}
} catch {
// Invalid localStorage data - fall through to default
}
return fallbackWidth;
}
const LIST_SIDEBAR_MIN_WIDTH = 280;
const LIST_SIDEBAR_MAX_RATIO = 0.65;
const LIST_SIDEBAR_KEYBOARD_STEP = 16;
function getSidebarMaxWidth(containerWidth: number): number {
return Math.max(LIST_SIDEBAR_MIN_WIDTH, containerWidth * LIST_SIDEBAR_MAX_RATIO);
}
function clampSidebarWidth(width: number, containerWidth: number): number {
const maxWidth = getSidebarMaxWidth(containerWidth);
return Math.min(Math.max(width, LIST_SIDEBAR_MIN_WIDTH), maxWidth);
}
interface ListViewProps {
tasks: Task[];
onMoveTask: (id: string, column: Column) => Promise<Task>;
@@ -235,12 +277,18 @@ export function ListView({
// Selection state - initialize from localStorage
const [selectedTaskIds, setSelectedTaskIds] = useState<Set<string>>(() => readSelectedTaskIds(projectId));
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(() => readSelectedTaskId(projectId));
const [sidebarWidth, setSidebarWidth] = useState<number>(() => readSidebarWidth(projectId));
const splitLayoutRef = useRef<HTMLDivElement>(null);
const splitSidebarRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setVisibleColumns(readVisibleColumns(projectId));
setHideDoneTasks(readHideDoneTasks(projectId));
setCollapsedSections(readCollapsedSections(projectId));
setSelectedTaskIds(readSelectedTaskIds(projectId));
setSelectedTaskId(readSelectedTaskId(projectId));
setSidebarWidth(readSidebarWidth(projectId));
}, [projectId]);
// Persist selection to localStorage
@@ -250,6 +298,72 @@ export function ListView({
}
}, [projectId, selectedTaskIds]);
useEffect(() => {
if (typeof window === "undefined") return;
if (selectedTaskId) {
setScopedItem("kb-dashboard-list-selected-task", selectedTaskId, projectId);
return;
}
removeScopedItem("kb-dashboard-list-selected-task", projectId);
}, [projectId, selectedTaskId]);
useEffect(() => {
if (!selectedTaskId) return;
if (!tasks.some((task) => task.id === selectedTaskId)) {
setSelectedTaskId(null);
}
}, [selectedTaskId, tasks]);
useEffect(() => {
if (isMobile || typeof ResizeObserver === "undefined") return;
const container = splitLayoutRef.current;
if (!container) return;
const applyClamp = () => {
// Keep width valid when viewport/container size changes.
const clamped = clampSidebarWidth(sidebarWidth, container.clientWidth);
if (clamped !== sidebarWidth) {
setSidebarWidth(clamped);
}
};
applyClamp();
const observer = new ResizeObserver(applyClamp);
observer.observe(container);
return () => observer.disconnect();
}, [isMobile, sidebarWidth]);
useEffect(() => {
if (isMobile || typeof ResizeObserver === "undefined") return;
const sidebar = splitSidebarRef.current;
const container = splitLayoutRef.current;
if (!sidebar || !container) return;
let saveTimer: ReturnType<typeof setTimeout> | null = null;
let lastSavedWidth = sidebar.offsetWidth;
const observer = new ResizeObserver(() => {
const nextWidth = clampSidebarWidth(sidebar.offsetWidth, container.clientWidth);
if (nextWidth === lastSavedWidth) return;
lastSavedWidth = nextWidth;
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
try {
setScopedItem("kb-dashboard-list-sidebar-width", String(nextWidth), projectId);
} catch {
// localStorage persistence is best-effort.
}
}, 200);
});
observer.observe(sidebar);
return () => {
observer.disconnect();
if (saveTimer) clearTimeout(saveTimer);
};
}, [isMobile, projectId]);
// Toggle task selection
const toggleTaskSelection = useCallback((taskId: string) => {
setSelectedTaskIds((prev) => {
@@ -601,9 +715,14 @@ export function ListView({
const handleRowClick = useCallback(
(task: Task) => {
onOpenDetail(task);
if (isMobile) {
onOpenDetail(task);
return;
}
setSelectedTaskId(task.id);
},
[onOpenDetail]
[isMobile, onOpenDetail]
);
const handleDragStart = useCallback(
@@ -624,6 +743,54 @@ export function ListView({
setDragOverColumn(null);
}, []);
const handleSplitResizeStart = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
if (isMobile) return;
event.preventDefault();
const container = splitLayoutRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
const onMouseMove = (moveEvent: MouseEvent) => {
const proposedWidth = moveEvent.clientX - rect.left;
setSidebarWidth(clampSidebarWidth(proposedWidth, rect.width));
};
const onMouseUp = () => {
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
};
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
}, [isMobile]);
const handleSplitResizeKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
if (isMobile) return;
const measuredWidth = splitLayoutRef.current?.clientWidth ?? 0;
const fallbackWidth = sidebarWidth / LIST_SIDEBAR_MAX_RATIO + LIST_SIDEBAR_KEYBOARD_STEP;
const containerWidth = Math.max(measuredWidth, fallbackWidth);
const maxWidth = getSidebarMaxWidth(containerWidth);
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
event.preventDefault();
const delta = event.key === "ArrowLeft" ? -LIST_SIDEBAR_KEYBOARD_STEP : LIST_SIDEBAR_KEYBOARD_STEP;
setSidebarWidth((current) => clampSidebarWidth(current + delta, containerWidth));
return;
}
if (event.key === "Home") {
event.preventDefault();
setSidebarWidth(LIST_SIDEBAR_MIN_WIDTH);
return;
}
if (event.key === "End") {
event.preventDefault();
setSidebarWidth(maxWidth);
}
}, [isMobile, sidebarWidth]);
const handleColumnDragOver = useCallback(
(e: React.DragEvent, column: Column) => {
e.preventDefault();
@@ -724,10 +891,9 @@ export function ListView({
)}
{selectedColumn && (
<button
className="btn btn-sm"
className="btn btn-sm list-clear-column-filter-btn"
onClick={clearColumnFilter}
aria-label="Clear column filter"
style={{ marginLeft: "8px" }}
>
Clear
</button>
@@ -835,22 +1001,29 @@ export function ListView({
</div>
<div className="list-table-container">
<div className="list-quick-entry-above-table">
<QuickEntryBox
onCreate={onQuickCreate ?? (async () => addToast("Task creation not available", "error"))}
addToast={addToast}
tasks={tasks}
availableModels={availableModels}
onPlanningMode={onPlanningMode}
onSubtaskBreakdown={onSubtaskBreakdown}
projectId={projectId}
autoExpand={false}
favoriteProviders={favoriteProviders}
favoriteModels={favoriteModels}
onToggleFavorite={onToggleFavorite}
onToggleModelFavorite={onToggleModelFavorite}
/>
</div>
<div className={isMobile ? "" : "list-split-layout"} data-testid={isMobile ? undefined : "list-split-layout"} ref={splitLayoutRef}>
<div
className={isMobile ? "" : "list-split-sidebar"}
data-testid={isMobile ? undefined : "list-split-sidebar"}
ref={splitSidebarRef}
style={isMobile ? undefined : { width: `${sidebarWidth}px` }}
>
<div className="list-quick-entry-above-table">
<QuickEntryBox
onCreate={onQuickCreate ?? (async () => addToast("Task creation not available", "error"))}
addToast={addToast}
tasks={tasks}
availableModels={availableModels}
onPlanningMode={onPlanningMode}
onSubtaskBreakdown={onSubtaskBreakdown}
projectId={projectId}
autoExpand={false}
favoriteProviders={favoriteProviders}
favoriteModels={favoriteModels}
onToggleFavorite={onToggleFavorite}
onToggleModelFavorite={onToggleModelFavorite}
/>
</div>
{filteredCount === 0 ? (
<div className="list-empty">
{searchQuery ? "No tasks match your filter" : "No tasks yet"}
@@ -1101,7 +1274,7 @@ export function ListView({
isStuckState ? " stuck" : ""
}${isAgentActive ? " agent-active" : ""}${
isDragging ? " dragging" : ""
}`}
}${selectedTaskId === task.id ? " list-row--selected" : ""}`}
onClick={() => handleRowClick(task)}
draggable={!isPaused}
onDragStart={(e) => handleDragStart(e, task)}
@@ -1218,6 +1391,37 @@ export function ListView({
</tbody>
</table>
)}
</div>
{!isMobile && (
<>
<div
className="list-split-resize-handle"
data-testid="list-split-resize-handle"
onMouseDown={handleSplitResizeStart}
onKeyDown={handleSplitResizeKeyDown}
role="separator"
tabIndex={0}
aria-orientation="vertical"
aria-label="Resize task list sidebar"
aria-valuemin={LIST_SIDEBAR_MIN_WIDTH}
aria-valuemax={Math.round(
getSidebarMaxWidth(
splitLayoutRef.current?.clientWidth ??
(sidebarWidth / LIST_SIDEBAR_MAX_RATIO + LIST_SIDEBAR_KEYBOARD_STEP)
)
)}
aria-valuenow={Math.round(sidebarWidth)}
/>
<div className="list-split-detail" data-testid="list-split-detail">
{!selectedTaskId ? (
<p>Select a task to view details</p>
) : (
<p>Task selected. Detail view coming soon.</p>
)}
</div>
</>
)}
</div>
</div>
</div>
);

View File

@@ -101,6 +101,18 @@ function mockDesktopViewport() {
describe("ListView", () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
ensureMatchMedia();
vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
});
it("renders without crashing", () => {
@@ -211,7 +223,8 @@ describe("ListView", () => {
expect(screen.getByText("FN-002")).toBeDefined();
});
it("calls onOpenDetail synchronously with Task when row is clicked", async () => {
it("updates selectedTaskId on desktop row click without opening detail", () => {
const viewportSpy = mockDesktopViewport();
const tasks = [createMockTask({ id: "FN-001", title: "Test Task" })];
const mockOnOpenDetail = vi.fn();
@@ -220,21 +233,135 @@ describe("ListView", () => {
const row = screen.getByText("FN-001").closest("tr");
fireEvent.click(row!);
// Should call onOpenDetail synchronously with the Task object (no fetch)
expect(mockOnOpenDetail).toHaveBeenCalledWith(tasks[0]);
expect(mockOnOpenDetail).toHaveBeenCalledTimes(1);
expect(mockOnOpenDetail).not.toHaveBeenCalled();
expect(localStorage.getItem(scopedStorageKey("kb-dashboard-list-selected-task"))).toBe("FN-001");
expect(fetchTaskDetail).not.toHaveBeenCalled();
expect(row?.className).toContain("list-row--selected");
expect(screen.getByText("Task selected. Detail view coming soon.")).toBeInTheDocument();
viewportSpy.mockRestore();
});
it("does not call fetchTaskDetail on row click", () => {
it("calls onOpenDetail on mobile row click", () => {
const viewportSpy = mockMobileViewport();
const tasks = [createMockTask({ id: "FN-001", title: "Test Task" })];
const mockOnOpenDetail = vi.fn();
renderListView({ tasks, onOpenDetail: mockOnOpenDetail });
const row = screen.getByText("FN-001").closest("tr");
fireEvent.click(row!);
const card = document.querySelector('.list-card[data-id="FN-001"]');
fireEvent.click(card!);
expect(mockOnOpenDetail).toHaveBeenCalledWith(tasks[0]);
expect(mockOnOpenDetail).toHaveBeenCalledTimes(1);
expect(fetchTaskDetail).not.toHaveBeenCalled();
viewportSpy.mockRestore();
});
it("keeps selectedTaskIds and selectedTaskId as separate persisted state", async () => {
const viewportSpy = mockDesktopViewport();
const tasks = [createMockTask({ id: "FN-001", title: "Test Task" })];
renderListView({ tasks });
const row = screen.getByText("FN-001").closest("tr")!;
const checkbox = within(row).getByRole("checkbox", { name: "Select FN-001" });
fireEvent.click(checkbox);
fireEvent.click(row);
await waitFor(() => {
expect(localStorage.getItem(scopedStorageKey("kb-dashboard-selected-tasks"))).toContain("FN-001");
expect(localStorage.getItem(scopedStorageKey("kb-dashboard-list-selected-task"))).toBe("FN-001");
});
viewportSpy.mockRestore();
});
it("initializes selectedTaskId from persisted project storage", () => {
const viewportSpy = mockDesktopViewport();
localStorage.setItem(scopedStorageKey("kb-dashboard-list-selected-task"), "FN-001");
const tasks = [createMockTask({ id: "FN-001", title: "Persisted task" })];
renderListView({ tasks });
expect(localStorage.getItem(scopedStorageKey("kb-dashboard-list-selected-task"))).toBe("FN-001");
viewportSpy.mockRestore();
});
it("renders desktop split-pane shell with resize handle and empty detail state", () => {
const viewportSpy = mockDesktopViewport();
const tasks = [createMockTask({ id: "FN-001", title: "Task" })];
renderListView({ tasks });
expect(screen.getByTestId("list-split-layout")).toBeInTheDocument();
expect(screen.getByTestId("list-split-sidebar")).toBeInTheDocument();
expect(screen.getByTestId("list-split-resize-handle")).toBeInTheDocument();
expect(screen.getByTestId("list-split-detail")).toBeInTheDocument();
expect(screen.getByText("Select a task to view details")).toBeInTheDocument();
viewportSpy.mockRestore();
});
it("reloads persisted sidebar width when projectId changes", () => {
const viewportSpy = mockDesktopViewport();
localStorage.setItem(scopedKey("kb-dashboard-list-sidebar-width", "project-a"), "300");
localStorage.setItem(scopedKey("kb-dashboard-list-sidebar-width", "project-b"), "460");
const tasks = [createMockTask({ id: "FN-001", title: "Task" })];
const { rerender } = render(
<ListView
tasks={tasks}
onMoveTask={vi.fn()}
onOpenDetail={vi.fn()}
addToast={mockAddToast}
projectId="project-a"
/>
);
expect(screen.getByTestId("list-split-sidebar")).toHaveStyle({ width: "300px" });
rerender(
<ListView
tasks={tasks}
onMoveTask={vi.fn()}
onOpenDetail={vi.fn()}
addToast={mockAddToast}
projectId="project-b"
/>
);
expect(screen.getByTestId("list-split-sidebar")).toHaveStyle({ width: "460px" });
viewportSpy.mockRestore();
});
it("supports keyboard resizing on the desktop split-pane handle", () => {
const viewportSpy = mockDesktopViewport();
const tasks = [createMockTask({ id: "FN-001", title: "Task" })];
renderListView({ tasks });
const handle = screen.getByTestId("list-split-resize-handle");
const startWidth = Number(handle.getAttribute("aria-valuenow"));
expect(handle).toHaveAttribute("tabindex", "0");
expect(handle).toHaveAttribute("aria-valuemin", "280");
expect(Number(handle.getAttribute("aria-valuemax"))).toBeGreaterThanOrEqual(280);
fireEvent.keyDown(handle, { key: "ArrowRight" });
expect(Number(handle.getAttribute("aria-valuenow"))).toBeGreaterThan(startWidth);
viewportSpy.mockRestore();
});
it("does not render split-pane structure on mobile", () => {
const viewportSpy = mockMobileViewport();
const tasks = [createMockTask({ id: "FN-001", title: "Task" })];
renderListView({ tasks });
expect(screen.queryByTestId("list-split-layout")).toBeNull();
expect(screen.queryByTestId("list-split-resize-handle")).toBeNull();
expect(screen.queryByTestId("list-split-detail")).toBeNull();
viewportSpy.mockRestore();
});
it("sorts tasks by ID when ID header is clicked", () => {

View File

@@ -79,6 +79,8 @@ describe("projectStorage", () => {
"kb-dashboard-hide-done",
"kb-dashboard-list-collapsed",
"kb-dashboard-selected-tasks",
"kb-dashboard-list-selected-task",
"kb-dashboard-list-sidebar-width",
"kb-quick-entry-text",
"kb-inline-create-text",
"fn-agent-view",
@@ -93,7 +95,7 @@ describe("projectStorage", () => {
"kb-chat-active-session",
]),
);
expect(PROJECT_STORAGE_KEYS).toHaveLength(17);
expect(PROJECT_STORAGE_KEYS).toHaveLength(19);
});
it("has no overlap between global and project-scoped keys", () => {

View File

@@ -12,6 +12,8 @@ export const PROJECT_STORAGE_KEYS: string[] = [
"kb-dashboard-hide-done",
"kb-dashboard-list-collapsed",
"kb-dashboard-selected-tasks",
"kb-dashboard-list-selected-task",
"kb-dashboard-list-sidebar-width",
"kb-quick-entry-text",
"kb-inline-create-text",
"fn-agent-view",