diff --git a/frontend/src/shell/AppShell.session-mgmt.test.tsx b/frontend/src/shell/AppShell.session-mgmt.test.tsx index 8f18be4c..1ffdd474 100644 --- a/frontend/src/shell/AppShell.session-mgmt.test.tsx +++ b/frontend/src/shell/AppShell.session-mgmt.test.tsx @@ -1,4 +1,4 @@ -import { act, render, screen, waitFor, within } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { http, HttpResponse } from "msw"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; @@ -6,6 +6,7 @@ import { server } from "../test/msw"; import { FakeEventSource } from "../test/fakeEventSource"; import { AppShell } from "./AppShell"; import { useSessionStore } from "../store/sessionStore"; +import { ACTIVITY_PANEL_STORAGE_KEY } from "./useActivityPanelResize"; function wrap() { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); @@ -27,6 +28,7 @@ function deferred() { } beforeEach(() => { + localStorage.clear(); FakeEventSource.instances = []; (globalThis as any).EventSource = FakeEventSource; (globalThis as any).PointerEvent = MouseEvent; @@ -779,21 +781,58 @@ test("renaming a group reassigns its members via setSessionGroup", async () => { }); -test("opening Model activity replaces the session rail with a 40/60 activity and chat layout", async () => { +test("opens an accessible resizable activity split and persists pointer width", async () => { server.use(http.post("http://localhost:8787/sessions/:id/resume", () => resumeResult("s1"))); wrap(); await userEvent.click(await screen.findByText("Attiva uno")); await userEvent.click(await screen.findByRole("button", { name: /resume/i })); - - expect(screen.getByText("Sessions")).toBeInTheDocument(); await userEvent.click(screen.getByRole("button", { name: /show model activity/i })); const shell = screen.getByTestId("app-shell"); - expect(shell).toHaveAttribute("data-activity-layout", "open"); - expect(screen.getByRole("heading", { name: "Model activity" }).closest("aside")).toHaveClass("w-2/5"); - expect(screen.queryByText("Sessions")).not.toBeInTheDocument(); + const separator = screen.getByRole("separator", { name: /resize model activity/i }); + expect(shell.style.getPropertyValue("--activity-panel-width")).toBe("384px"); + expect(separator).toHaveAttribute("aria-orientation", "vertical"); + expect(separator).toHaveAttribute("aria-valuemin", "288"); + expect(separator).toHaveAttribute("aria-valuenow", "384"); - await userEvent.click(screen.getByRole("button", { name: /hide model activity/i })); - expect(shell).toHaveAttribute("data-activity-layout", "closed"); - expect(screen.getByText("Sessions")).toBeInTheDocument(); + Object.assign(separator, { + setPointerCapture: vi.fn(), + releasePointerCapture: vi.fn(), + hasPointerCapture: vi.fn(() => true), + }); + fireEvent.pointerDown(separator, { pointerId: 1, clientX: 384 }); + fireEvent.pointerMove(separator, { pointerId: 1, clientX: 484 }); + fireEvent.pointerUp(separator, { pointerId: 1, clientX: 484 }); + + expect(shell.style.getPropertyValue("--activity-panel-width")).toBe("484px"); + expect(localStorage.getItem(ACTIVITY_PANEL_STORAGE_KEY)).toBe("484"); + expect(screen.getByTestId("conversation-column")).toHaveClass("flex-1"); +}); + +test("resizes the activity split with keyboard and exposes responsive drawer classes", async () => { + localStorage.setItem(ACTIVITY_PANEL_STORAGE_KEY, "448"); + server.use(http.post("http://localhost:8787/sessions/:id/resume", () => resumeResult("s1"))); + wrap(); + await userEvent.click(await screen.findByText("Attiva uno")); + await userEvent.click(await screen.findByRole("button", { name: /resume/i })); + await userEvent.click(screen.getByRole("button", { name: /show model activity/i })); + + const shell = screen.getByTestId("app-shell"); + const separator = screen.getByRole("separator", { name: /resize model activity/i }); + expect(shell.style.getPropertyValue("--activity-panel-width")).toBe("448px"); + fireEvent.keyDown(separator, { key: "ArrowLeft" }); + expect(shell.style.getPropertyValue("--activity-panel-width")).toBe("432px"); + fireEvent.keyDown(separator, { key: "ArrowRight", shiftKey: true }); + expect(shell.style.getPropertyValue("--activity-panel-width")).toBe("480px"); + fireEvent.keyDown(separator, { key: "Home" }); + expect(shell.style.getPropertyValue("--activity-panel-width")).toBe("288px"); + + const panel = screen.getByRole("heading", { name: "Model activity" }).closest("aside"); + expect(panel).toHaveClass( + "fixed", + "w-[min(90vw,24rem)]", + "lg:static", + "lg:w-[var(--activity-panel-width)]", + ); + expect(separator).toHaveClass("touch-none", "cursor-col-resize"); }); diff --git a/frontend/src/shell/AppShell.tsx b/frontend/src/shell/AppShell.tsx index 12963056..83b4a662 100644 --- a/frontend/src/shell/AppShell.tsx +++ b/frontend/src/shell/AppShell.tsx @@ -3,6 +3,7 @@ import { useSessionStore } from "../store/sessionStore"; import { WidgetHost } from "./WidgetHost"; import { CentralStatus } from "./CentralStatus"; import { ModelActivityPanel } from "./ModelActivityPanel"; +import { useActivityPanelResize } from "./useActivityPanelResize"; import { NavSessions } from "./NavSessions"; import { SessionMenu } from "./SessionMenu"; import { SessionDocumentsPanel } from "./SessionDocumentsPanel"; @@ -23,6 +24,7 @@ import { import type { SessionSummary } from "../api/types"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect, useMemo, useRef, useState } from "react"; +import type { CSSProperties } from "react"; /** * The page is designed to live INSIDE the Omics Portal chrome (its left sidebar @@ -31,6 +33,10 @@ import { useEffect, useMemo, useRef, useState } from "react"; * portal's left sidebar, and the workflow phases ride a slim strip above the chat. */ export function AppShell() { + const { containerRef, panelWidth, resizing, separatorProps } = useActivityPanelResize(); + const activityWidthStyle = { + "--activity-panel-width": `${panelWidth}px`, + } as CSSProperties; const [activeSessionId, setActiveSessionId] = useState(null); const activeSessionIdRef = useRef(null); const resumeInvocationRef = useRef(0); @@ -364,9 +370,15 @@ export function AppShell() { return (
{panelSession && ( )} {showActivity && setShowActivity(false)} />} + {showActivity && ( +
+
+ +
+
+ )} {/* Conversation column */} -
+
{activeSessionId && (
{/* Arrow toggle for the left Model-activity panel: → opens it, ← closes it. */} diff --git a/frontend/src/shell/ModelActivityPanel.tsx b/frontend/src/shell/ModelActivityPanel.tsx index 2e8609b6..27cbee54 100644 --- a/frontend/src/shell/ModelActivityPanel.tsx +++ b/frontend/src/shell/ModelActivityPanel.tsx @@ -70,7 +70,7 @@ export function ModelActivityPanel({ onClose }: { onClose: () => void }) { }, [visibleCount, visibleTail]); return ( -