diff --git a/packages/dashboard/app/components/GitHubImportModal.css b/packages/dashboard/app/components/GitHubImportModal.css index f149af8295..87a5336227 100644 --- a/packages/dashboard/app/components/GitHubImportModal.css +++ b/packages/dashboard/app/components/GitHubImportModal.css @@ -1144,9 +1144,16 @@ body. Let the content take its full intrinsic height and hand vertical scrolling flex: 1 1 auto; } - /* Side-by-side again: bound the list pane to a share of the CONTAINER width, overriding the inline viewport width. */ + /* + FNXC:GitHubImport 2026-06-23-00:30: + Wide (two-pane) embedded layout: the list pane must honor the user's inline `flex: 0 0 px` from the resize + handle — the prior `flex: ... !important` clamp silently overrode it, which is why the embedded Import Tasks list "couldn't + be made smaller" and the preview stayed cramped. Reset only the stacked-mode overrides (width/max-height/padding) here and let + the inline flex win. The preview pane is `flex: 1 1 auto; min-width: 0` (below) so the freed width flows to the preview. + */ .github-import-modal--embedded .github-import-list-pane { - flex: 0 1 clamp(240px, 38cqi, 420px) !important; + /* `!important` here only neutralizes the stacked rule's own `!important`; the width comes from the inline CSS var. */ + flex: 0 0 var(--gh-import-list-width, clamp(160px, 30cqi, 480px)) !important; width: auto; max-height: none; padding-right: var(--space-md); diff --git a/packages/dashboard/app/components/GitHubImportModal.tsx b/packages/dashboard/app/components/GitHubImportModal.tsx index 4b4dd1e72f..d69e190d5a 100644 --- a/packages/dashboard/app/components/GitHubImportModal.tsx +++ b/packages/dashboard/app/components/GitHubImportModal.tsx @@ -1,5 +1,5 @@ import "./GitHubImportModal.css"; -import { useState, useEffect, useCallback, useRef, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent } from "react"; +import { useState, useEffect, useCallback, useRef, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent } from "react"; import { useTranslation } from "react-i18next"; import type { Task } from "@fusion/core"; import { getErrorMessage } from "@fusion/core"; @@ -20,6 +20,7 @@ import { useModalResizePersist } from "../hooks/useModalResizePersist"; import { useMobileScrollLock } from "../hooks/useMobileScrollLock"; import { useOverlayDismiss } from "../hooks/useOverlayDismiss"; import { useEmbeddedPresentation, type ModalPresentation } from "../hooks/useEmbeddedPresentation"; +import { getScopedItem, setScopedItem } from "../utils/projectStorage"; interface GitHubImportModalProps { isOpen: boolean; @@ -38,15 +39,32 @@ interface GitHubImportModalProps { // Mobile and two-pane breakpoints in pixels const MOBILE_BREAKPOINT = 640; const TWO_PANE_BREAKPOINT = 860; -const GITHUB_IMPORT_LIST_PANE_MIN_WIDTH = 240; -const GITHUB_IMPORT_LIST_PANE_MAX_WIDTH = 640; -const GITHUB_IMPORT_LIST_PANE_DEFAULT_WIDTH = 360; -const GITHUB_IMPORT_LIST_PANE_STORAGE_KEY = "fusion:github-import-list-pane-width"; +/* +FNXC:GitHubImport 2026-06-23-00:30: +The Import Tasks two-pane split (Issues AND Pull Requests share the same workspace/list/preview structure) must let the user +shrink the LEFT list far below its old fixed share so the RIGHT preview gets the freed space. Default the list narrow (256px), +clamp to [160px, min(480px, 50% of container)] so the preview always keeps at least half. Width is user-resizable via a drag +handle and persisted per-project through projectStorage (key `kb-dashboard-github-import-list-width`) so each repo context keeps +its own split. The freed width flows to the preview because the preview is `flex: 1 1 auto; min-width: 0` (fills remainder). +*/ +const GITHUB_IMPORT_LIST_PANE_MIN_WIDTH = 160; +const GITHUB_IMPORT_LIST_PANE_MAX_WIDTH = 480; +const GITHUB_IMPORT_LIST_PANE_MAX_RATIO = 0.5; +const GITHUB_IMPORT_LIST_PANE_DEFAULT_WIDTH = 256; +const GITHUB_IMPORT_LIST_PANE_KEYBOARD_STEP = 16; +const GITHUB_IMPORT_LIST_WIDTH_STORAGE_KEY = "kb-dashboard-github-import-list-width"; type TabType = "issues" | "pulls"; -function clampListPaneWidth(width: number) { - return Math.max(GITHUB_IMPORT_LIST_PANE_MIN_WIDTH, Math.min(GITHUB_IMPORT_LIST_PANE_MAX_WIDTH, width)); +/** + * Clamp the list-pane width to [MIN, min(MAX, container * MAX_RATIO)]. + * The container-relative cap guarantees the preview pane keeps at least half the workspace even on narrow screens. + * `containerWidth <= 0` (e.g. unmeasured/test) falls back to the absolute MAX so the static bound still applies. + */ +function clampListPaneWidth(width: number, containerWidth = 0) { + const ratioMax = containerWidth > 0 ? containerWidth * GITHUB_IMPORT_LIST_PANE_MAX_RATIO : Number.POSITIVE_INFINITY; + const maxWidth = Math.min(GITHUB_IMPORT_LIST_PANE_MAX_WIDTH, ratioMax); + return Math.max(GITHUB_IMPORT_LIST_PANE_MIN_WIDTH, Math.min(maxWidth, width)); } /* @@ -95,13 +113,15 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, const [isMobile, setIsMobile] = useState(false); const [canResizePanes, setCanResizePanes] = useState(false); const [mobileView, setMobileView] = useState<"list" | "preview">("list"); + // Workspace flex-row container; used to measure available width for the container-relative resize clamp. + const workspaceRef = useRef(null); + // Parks the active drag teardown (release capture + remove listeners) so it runs once on pointerup/cancel/unmount. + const listResizeTeardownRef = useRef<(() => void) | null>(null); + // rAF handle so pointermove width updates are batched to one state write per frame. + const listResizeFrameRef = useRef(null); const [listPaneWidth, setListPaneWidth] = useState(() => { - if (typeof window === "undefined") { - return GITHUB_IMPORT_LIST_PANE_DEFAULT_WIDTH; - } - try { - const stored = window.localStorage.getItem(GITHUB_IMPORT_LIST_PANE_STORAGE_KEY); + const stored = getScopedItem(GITHUB_IMPORT_LIST_WIDTH_STORAGE_KEY, projectId); if (!stored) { return GITHUB_IMPORT_LIST_PANE_DEFAULT_WIDTH; } @@ -328,60 +348,119 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, return () => window.removeEventListener("resize", checkViewportBands); }, [isOpen]); + // Persist the (already clamped) width per-project; best-effort, scoped so each repo context keeps its own split. useEffect(() => { - if (typeof window === "undefined") { - return; - } try { - window.localStorage.setItem(GITHUB_IMPORT_LIST_PANE_STORAGE_KEY, String(listPaneWidth)); + setScopedItem(GITHUB_IMPORT_LIST_WIDTH_STORAGE_KEY, String(listPaneWidth), projectId); } catch { // Ignore storage write failures. } - }, [listPaneWidth]); + }, [listPaneWidth, projectId]); + /* + FNXC:GitHubImport 2026-06-23-00:30: + Mirror the proven MailboxView split drag. Pointer events + setPointerCapture keep the drag tracking even when the cursor + leaves the thin handle. Each move maps the pointer X to a list-pane width relative to the workspace's left edge, clamped to + [MIN, min(MAX, container * MAX_RATIO)] so the preview keeps at least half. Updates are rAF-batched (one state write per + frame). The teardown (release capture + remove listeners + cancel frame) runs once on pointerup/pointercancel and is parked + in listResizeTeardownRef for unmount safety. Resize only applies in the wide two-pane band (canResizePanes). + */ const handleListPaneResizeStart = useCallback((event: ReactPointerEvent) => { if (!canResizePanes) { return; } + event.preventDefault(); + listResizeTeardownRef.current?.(); + + const handle = event.currentTarget; + const pointerId = event.pointerId; + const workspaceRect = workspaceRef.current?.getBoundingClientRect(); + // Fall back to pointer-relative delta math when the workspace is unmeasured (e.g. jsdom layout-less tests). const startX = event.clientX; const startWidth = listPaneWidth; - const target = event.currentTarget; - target.setPointerCapture(event.pointerId); - const handlePointerMove = (moveEvent: globalThis.PointerEvent) => { - const deltaX = moveEvent.clientX - startX; - setListPaneWidth(clampListPaneWidth(startWidth + deltaX)); + // Latest pointer X awaiting a frame; flushed on the next rAF or synchronously at teardown so the final drag position is never dropped. + let pendingClientX: number | null = null; + + const applyWidth = (clientX: number) => { + const containerWidth = workspaceRect?.width ?? 0; + const proposed = workspaceRect ? clientX - workspaceRect.left : startWidth + (clientX - startX); + setListPaneWidth(clampListPaneWidth(proposed, containerWidth)); }; - const handlePointerUp = () => { - document.removeEventListener("pointermove", handlePointerMove); - document.removeEventListener("pointerup", handlePointerUp); - if (target.hasPointerCapture(event.pointerId)) { - target.releasePointerCapture(event.pointerId); + const flushPending = () => { + listResizeFrameRef.current = null; + if (pendingClientX !== null) { + const clientX = pendingClientX; + pendingClientX = null; + applyWidth(clientX); } }; - document.addEventListener("pointermove", handlePointerMove); - document.addEventListener("pointerup", handlePointerUp); + const onPointerMove = (moveEvent: globalThis.PointerEvent) => { + if (moveEvent.pointerId !== pointerId) return; + pendingClientX = moveEvent.clientX; + if (listResizeFrameRef.current !== null) return; + const schedule = typeof window !== "undefined" && typeof window.requestAnimationFrame === "function" + ? window.requestAnimationFrame + : (cb: FrameRequestCallback) => { cb(0); return 0; }; + listResizeFrameRef.current = schedule(flushPending); + }; + + const teardown = () => { + document.removeEventListener("pointermove", onPointerMove); + document.removeEventListener("pointerup", teardown); + document.removeEventListener("pointercancel", teardown); + if (listResizeFrameRef.current !== null && typeof window !== "undefined" && typeof window.cancelAnimationFrame === "function") { + window.cancelAnimationFrame(listResizeFrameRef.current); + listResizeFrameRef.current = null; + } + // Apply any width queued for a frame that never fired so the final drag position sticks (and tests stay deterministic). + flushPending(); + try { + handle.releasePointerCapture(pointerId); + } catch { + // Pointer capture may already be released; ignore. + } + listResizeTeardownRef.current = null; + }; + + listResizeTeardownRef.current = teardown; + + try { + handle.setPointerCapture(pointerId); + } catch { + // setPointerCapture can throw in non-DOM test environments; drag still works via listeners. + } + // Listen on document so the drag keeps tracking even when the pointer leaves the thin handle. + document.addEventListener("pointermove", onPointerMove); + document.addEventListener("pointerup", teardown); + document.addEventListener("pointercancel", teardown); }, [canResizePanes, listPaneWidth]); + // Detach any in-flight drag on unmount. + useEffect(() => () => { + listResizeTeardownRef.current?.(); + }, []); + const handleListPaneResizeKeyDown = useCallback((event: ReactKeyboardEvent) => { if (!canResizePanes) { return; } - const step = event.shiftKey ? 50 : 10; + const containerWidth = workspaceRef.current?.clientWidth ?? 0; + const step = event.shiftKey ? GITHUB_IMPORT_LIST_PANE_KEYBOARD_STEP * 4 : GITHUB_IMPORT_LIST_PANE_KEYBOARD_STEP; if (event.key === "ArrowLeft") { event.preventDefault(); - setListPaneWidth((current) => clampListPaneWidth(current - step)); + setListPaneWidth((current) => clampListPaneWidth(current - step, containerWidth)); return; } if (event.key === "ArrowRight") { event.preventDefault(); - setListPaneWidth((current) => clampListPaneWidth(current + step)); + setListPaneWidth((current) => clampListPaneWidth(current + step, containerWidth)); return; } @@ -393,7 +472,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, if (event.key === "End") { event.preventDefault(); - setListPaneWidth(GITHUB_IMPORT_LIST_PANE_MAX_WIDTH); + setListPaneWidth(clampListPaneWidth(GITHUB_IMPORT_LIST_PANE_MAX_WIDTH, containerWidth)); } }, [canResizePanes]); @@ -662,11 +741,20 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, )} {/* Two-pane workspace */} -
+
{/* Left pane: Issue/PR list */}
@@ -806,7 +894,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, {canResizePanes && (
{ fireEvent.pointerUp(document, { pointerId: 1, clientX: endX }); }; + /* + * FNXC:GitHubImport 2026-06-23-00:30: + * The list pane defaults narrow (256px) and clamps to [160px, 480px] (the absolute cap; a 50%-of-container cap also + * applies once the workspace is measured, which jsdom reports as 0 so the absolute cap governs here). Width persists + * per-project via projectStorage under the unscoped key `kb-dashboard-github-import-list-width` (no projectId in tests). + * Pointer drags map absolute pointer X to the list width relative to the workspace left edge (jsdom rect is all-zeros). + */ + const LIST_WIDTH_KEY = "kb-dashboard-github-import-list-width"; + beforeEach(() => { - window.localStorage.removeItem("fusion:github-import-list-pane-width"); + window.localStorage.removeItem(LIST_WIDTH_KEY); setViewportWidth(1200); }); afterEach(() => { - window.localStorage.removeItem("fusion:github-import-list-pane-width"); + window.localStorage.removeItem(LIST_WIDTH_KEY); setViewportWidth(originalInnerWidth); }); it("renders handle only in the side-by-side two-pane band", async () => { await renderWithIssues(); expect(screen.getByTestId("github-import-resize-handle")).toBeTruthy(); - expect(screen.getByTestId("github-import-list-pane").getAttribute("style")).toContain("flex: 0 0 360px"); + expect(screen.getByTestId("github-import-list-pane").getAttribute("style")).toContain("flex: 0 0 256px"); setViewportWidth(800); @@ -1118,41 +1127,54 @@ describe("GitHubImportModal", () => { const handle = screen.getByTestId("github-import-resize-handle"); const listPane = screen.getByTestId("github-import-list-pane"); - dragHandle(handle, 100, 160); - expect(handle.getAttribute("aria-valuenow")).toBe("420"); - expect(listPane.getAttribute("style")).toContain("flex: 0 0 420px"); + // jsdom workspace rect is all-zeros, so the pane width equals the clamped absolute pointer X. + dragHandle(handle, 256, 300); + expect(handle.getAttribute("aria-valuenow")).toBe("300"); + expect(listPane.getAttribute("style")).toContain("flex: 0 0 300px"); - dragHandle(handle, 160, 120); - expect(handle.getAttribute("aria-valuenow")).toBe("380"); - expect(listPane.getAttribute("style")).toContain("flex: 0 0 380px"); + dragHandle(handle, 300, 200); + expect(handle.getAttribute("aria-valuenow")).toBe("200"); + expect(listPane.getAttribute("style")).toContain("flex: 0 0 200px"); - dragHandle(handle, 120, -200); - expect(handle.getAttribute("aria-valuenow")).toBe("240"); - expect(listPane.getAttribute("style")).toContain("flex: 0 0 240px"); + // Below the 160px minimum clamps up. + dragHandle(handle, 200, 40); + expect(handle.getAttribute("aria-valuenow")).toBe("160"); + expect(listPane.getAttribute("style")).toContain("flex: 0 0 160px"); - dragHandle(handle, -200, 700); - expect(handle.getAttribute("aria-valuenow")).toBe("640"); - expect(listPane.getAttribute("style")).toContain("flex: 0 0 640px"); + // Above the 480px maximum clamps down. + dragHandle(handle, 40, 900); + expect(handle.getAttribute("aria-valuenow")).toBe("480"); + expect(listPane.getAttribute("style")).toContain("flex: 0 0 480px"); + }); + + it("exposes the resize width as an inline CSS var for the embedded container query", async () => { + await renderWithIssues(); + const listPane = screen.getByTestId("github-import-list-pane"); + expect(listPane.getAttribute("style")).toContain("--gh-import-list-width: 256px"); + + const handle = screen.getByTestId("github-import-resize-handle"); + dragHandle(handle, 256, 320); + expect(listPane.getAttribute("style")).toContain("--gh-import-list-width: 320px"); }); it("renders the desktop handle regardless of list content or active tab", async () => { const mounted = await renderWithEmptyIssues(); expect(screen.getByTestId("github-import-resize-handle")).toBeTruthy(); - expect(screen.getByTestId("github-import-list-pane").getAttribute("style")).toContain("flex: 0 0 360px"); + expect(screen.getByTestId("github-import-list-pane").getAttribute("style")).toContain("flex: 0 0 256px"); mounted.unmount(); vi.clearAllMocks(); - window.localStorage.removeItem("fusion:github-import-list-pane-width"); + window.localStorage.removeItem(LIST_WIDTH_KEY); setViewportWidth(1200); await renderWithPulls(); expect(screen.getByTestId("github-import-resize-handle")).toBeTruthy(); - expect(screen.getByTestId("github-import-list-pane").getAttribute("style")).toContain("flex: 0 0 360px"); + expect(screen.getByTestId("github-import-list-pane").getAttribute("style")).toContain("flex: 0 0 256px"); }); it.each([ - [{ key: "ArrowRight" }, 370], - [{ key: "ArrowLeft" }, 350], - [{ key: "ArrowRight", shiftKey: true }, 410], + [{ key: "ArrowRight" }, 272], + [{ key: "ArrowLeft" }, 240], + [{ key: "ArrowRight", shiftKey: true }, 320], ])("handles keyboard nudge %#", async (eventInit, expected) => { await renderWithIssues(); const handle = screen.getByTestId("github-import-resize-handle"); @@ -1167,10 +1189,10 @@ describe("GitHubImportModal", () => { const handle = screen.getByTestId("github-import-resize-handle"); fireEvent.keyDown(handle, { key: "Home" }); - expect(handle.getAttribute("aria-valuenow")).toBe("240"); + expect(handle.getAttribute("aria-valuenow")).toBe("160"); fireEvent.keyDown(handle, { key: "End" }); - expect(handle.getAttribute("aria-valuenow")).toBe("640"); + expect(handle.getAttribute("aria-valuenow")).toBe("480"); }); it("clamps keyboard resizing to min and max bounds", async () => { @@ -1180,12 +1202,12 @@ describe("GitHubImportModal", () => { for (let i = 0; i < 30; i += 1) { fireEvent.keyDown(handle, { key: "ArrowLeft" }); } - expect(handle.getAttribute("aria-valuenow")).toBe("240"); + expect(handle.getAttribute("aria-valuenow")).toBe("160"); for (let i = 0; i < 60; i += 1) { fireEvent.keyDown(handle, { key: "ArrowRight" }); } - expect(handle.getAttribute("aria-valuenow")).toBe("640"); + expect(handle.getAttribute("aria-valuenow")).toBe("480"); }); it("persists width across remounts", async () => { @@ -1193,20 +1215,29 @@ describe("GitHubImportModal", () => { let handle = screen.getByTestId("github-import-resize-handle"); fireEvent.keyDown(handle, { key: "ArrowRight", shiftKey: true }); - expect(handle.getAttribute("aria-valuenow")).toBe("410"); + expect(handle.getAttribute("aria-valuenow")).toBe("320"); + // Persisted under the projectStorage key (unscoped without a projectId). + expect(window.localStorage.getItem(LIST_WIDTH_KEY)).toBe("320"); mounted.unmount(); await renderWithIssues(); handle = await screen.findByTestId("github-import-resize-handle"); - expect(handle.getAttribute("aria-valuenow")).toBe("410"); + expect(handle.getAttribute("aria-valuenow")).toBe("320"); + }); + + it("clamps an out-of-range stored width back into bounds on mount", async () => { + window.localStorage.setItem(LIST_WIDTH_KEY, "9000"); + await renderWithIssues(); + // Stored value above the 480px max is clamped down on read. + expect(screen.getByTestId("github-import-resize-handle").getAttribute("aria-valuenow")).toBe("480"); }); it("falls back to default width for invalid stored values", async () => { - window.localStorage.setItem("fusion:github-import-list-pane-width", "not-a-number"); + window.localStorage.setItem(LIST_WIDTH_KEY, "not-a-number"); await renderWithIssues(); - expect(screen.getByTestId("github-import-resize-handle").getAttribute("aria-valuenow")).toBe("360"); + expect(screen.getByTestId("github-import-resize-handle").getAttribute("aria-valuenow")).toBe("256"); }); }); diff --git a/packages/dashboard/app/utils/__tests__/projectStorage.test.ts b/packages/dashboard/app/utils/__tests__/projectStorage.test.ts index 79edf12c11..6b5519b38d 100644 --- a/packages/dashboard/app/utils/__tests__/projectStorage.test.ts +++ b/packages/dashboard/app/utils/__tests__/projectStorage.test.ts @@ -85,6 +85,7 @@ describe("projectStorage", () => { "kb-dashboard-list-sidebar-width", "kb-dashboard-mailbox-sidebar-width", "kb-dashboard-agents-sidebar-width", + "kb-dashboard-github-import-list-width", "kb-quick-entry-text", "kb-inline-create-text", "fn-agent-view", @@ -101,10 +102,11 @@ describe("projectStorage", () => { "kb-dashboard-base-branch-filter", "kb-capacity-risk-banner-dismissed", "kb-files-line-numbers", + "kb-dashboard-dock-files-current", "fusion-plugin-dependency-graph:positions", ]), ); - expect(PROJECT_STORAGE_KEYS).toHaveLength(26); + expect(PROJECT_STORAGE_KEYS).toHaveLength(28); }); it("stores branch filter values as scoped strings per project", () => { diff --git a/packages/dashboard/app/utils/projectStorage.ts b/packages/dashboard/app/utils/projectStorage.ts index fde88d1d67..f10d26a180 100644 --- a/packages/dashboard/app/utils/projectStorage.ts +++ b/packages/dashboard/app/utils/projectStorage.ts @@ -18,6 +18,7 @@ export const PROJECT_STORAGE_KEYS: string[] = [ "kb-dashboard-list-sidebar-width", "kb-dashboard-mailbox-sidebar-width", "kb-dashboard-agents-sidebar-width", + "kb-dashboard-github-import-list-width", "kb-quick-entry-text", "kb-inline-create-text", "fn-agent-view",