feat: redesign persisted session summaries
This commit is contained in:
@@ -183,6 +183,62 @@ test("opening the panel shows the session documents", async () => {
|
||||
expect(await screen.findByText("Domanda originale")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("resizes the session summary to exactly 50% and persists pointer width independently", async () => {
|
||||
wrap();
|
||||
await userEvent.click(await screen.findByText("Attiva uno"));
|
||||
await screen.findByText("Domanda originale");
|
||||
|
||||
const shell = screen.getByTestId("app-shell");
|
||||
setContainerWidth(shell, 1600);
|
||||
const separator = screen.getByRole("separator", { name: /resize session summary/i });
|
||||
expect(separator).toHaveAttribute("aria-orientation", "vertical");
|
||||
expect(separator).toHaveAttribute("aria-valuemin", "320");
|
||||
expect(separator).toHaveAttribute("aria-valuemax", "800");
|
||||
expect(shell.style.getPropertyValue("--session-panel-width")).toBe("384px");
|
||||
|
||||
Object.assign(separator, {
|
||||
setPointerCapture: vi.fn(),
|
||||
releasePointerCapture: vi.fn(),
|
||||
hasPointerCapture: vi.fn(() => true),
|
||||
});
|
||||
fireEvent.pointerDown(separator, { pointerId: 2, clientX: 384 });
|
||||
fireEvent.pointerMove(separator, { pointerId: 2, clientX: 900 });
|
||||
fireEvent.pointerUp(separator, { pointerId: 2, clientX: 900 });
|
||||
|
||||
expect(shell.style.getPropertyValue("--session-panel-width")).toBe("800px");
|
||||
expect(localStorage.getItem("thothii:session-documents-panel-width")).toBe("800");
|
||||
expect(screen.getByRole("complementary", { name: /session summary/i })).toHaveClass(
|
||||
"w-[var(--session-panel-width)]",
|
||||
"max-w-[50%]",
|
||||
);
|
||||
expect(screen.queryByRole("separator", { name: /resize model activity/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("supports keyboard session resizing and hides its divider when the split is unusable", async () => {
|
||||
localStorage.setItem("thothii:session-documents-panel-width", "448");
|
||||
wrap();
|
||||
await userEvent.click(await screen.findByText("Attiva uno"));
|
||||
await screen.findByText("Domanda originale");
|
||||
|
||||
const shell = screen.getByTestId("app-shell");
|
||||
setContainerWidth(shell, 1200);
|
||||
const separator = screen.getByRole("separator", { name: /resize session summary/i });
|
||||
expect(shell.style.getPropertyValue("--session-panel-width")).toBe("448px");
|
||||
fireEvent.keyDown(separator, { key: "ArrowRight", shiftKey: true });
|
||||
expect(shell.style.getPropertyValue("--session-panel-width")).toBe("496px");
|
||||
fireEvent.keyDown(separator, { key: "End" });
|
||||
expect(shell.style.getPropertyValue("--session-panel-width")).toBe("600px");
|
||||
fireEvent.keyDown(separator, { key: "Home" });
|
||||
expect(shell.style.getPropertyValue("--session-panel-width")).toBe("320px");
|
||||
|
||||
setContainerWidth(shell, 800);
|
||||
expect(screen.queryByRole("separator", { name: /resize session summary/i })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("complementary", { name: /session summary/i })).toHaveClass(
|
||||
"w-[30vw]",
|
||||
"max-w-[30vw]",
|
||||
);
|
||||
});
|
||||
|
||||
test("clicking a session with a live runtime reconnects to its gate instead of the panel", async () => {
|
||||
let resumed: string | null = null;
|
||||
server.use(
|
||||
|
||||
@@ -4,6 +4,7 @@ import { WidgetHost } from "./WidgetHost";
|
||||
import { CentralStatus } from "./CentralStatus";
|
||||
import { ModelActivityPanel } from "./ModelActivityPanel";
|
||||
import { useActivityPanelResize } from "./useActivityPanelResize";
|
||||
import { useSessionPanelResize } from "./useSessionPanelResize";
|
||||
import { NavSessions } from "./NavSessions";
|
||||
import { SessionMenu } from "./SessionMenu";
|
||||
import { SessionDocumentsPanel } from "./SessionDocumentsPanel";
|
||||
@@ -37,7 +38,20 @@ import type { CSSProperties } from "react";
|
||||
* portal's left sidebar, and the workflow phases ride a slim strip above the chat.
|
||||
*/
|
||||
export function AppShell() {
|
||||
const { containerRef, panelWidth, desktopSplit, resizing, separatorProps } = useActivityPanelResize();
|
||||
const [panelSession, setPanelSession] = useState<SessionSummary | null>(null);
|
||||
const {
|
||||
containerRef,
|
||||
panelWidth,
|
||||
desktopSplit,
|
||||
resizing: activityResizing,
|
||||
separatorProps: activitySeparatorProps,
|
||||
} = useActivityPanelResize();
|
||||
const {
|
||||
panelWidth: sessionPanelWidth,
|
||||
desktopSplit: sessionDesktopSplit,
|
||||
resizing: sessionResizing,
|
||||
separatorProps: sessionSeparatorProps,
|
||||
} = useSessionPanelResize(containerRef, panelSession !== null);
|
||||
// Publish the app area's horizontal geometry as CSS vars on <html>: when the app
|
||||
// is embedded beside the portal's left sidebar, viewport-fixed dialogs (dialog.tsx)
|
||||
// must center on the ThothII area, not on the whole browser window.
|
||||
@@ -61,8 +75,9 @@ export function AppShell() {
|
||||
doc.style.removeProperty("--app-area-width");
|
||||
};
|
||||
}, [containerRef]);
|
||||
const activityWidthStyle = {
|
||||
const panelWidthsStyle = {
|
||||
"--activity-panel-width": `${panelWidth}px`,
|
||||
"--session-panel-width": `${sessionPanelWidth}px`,
|
||||
} as CSSProperties;
|
||||
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
||||
const activeSessionIdRef = useRef<string | null>(null);
|
||||
@@ -83,7 +98,6 @@ export function AppShell() {
|
||||
const composerRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const [panelSession, setPanelSession] = useState<SessionSummary | null>(null);
|
||||
const [showActivity, setShowActivity] = useState(false);
|
||||
const [activeOpen, setActiveOpen] = useState(true);
|
||||
const [archiveOpen, setArchiveOpen] = useState(false);
|
||||
@@ -448,13 +462,14 @@ export function AppShell() {
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={activityWidthStyle}
|
||||
style={panelWidthsStyle}
|
||||
data-testid="app-shell"
|
||||
data-activity-layout={showActivity ? "open" : "closed"}
|
||||
data-activity-resizing={resizing}
|
||||
data-activity-resizing={activityResizing}
|
||||
data-session-resizing={sessionResizing}
|
||||
className={[
|
||||
"flex h-screen bg-background text-foreground",
|
||||
resizing ? "select-none cursor-col-resize" : "",
|
||||
activityResizing || sessionResizing ? "select-none cursor-col-resize" : "",
|
||||
].join(" ")}
|
||||
>
|
||||
{panelSession && (
|
||||
@@ -462,15 +477,28 @@ export function AppShell() {
|
||||
session={panelSession}
|
||||
onClose={() => setPanelSession(null)}
|
||||
onResume={doResume}
|
||||
desktopSplit={sessionDesktopSplit}
|
||||
/>
|
||||
)}
|
||||
{panelSession && sessionDesktopSplit && (
|
||||
<div className="relative w-0 shrink-0">
|
||||
<div
|
||||
{...sessionSeparatorProps}
|
||||
data-testid="session-resize-handle"
|
||||
data-dragging={sessionResizing}
|
||||
className="group absolute inset-y-0 left-1/2 z-30 w-3 -translate-x-1/2 touch-none cursor-col-resize outline-none"
|
||||
>
|
||||
<span className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-border transition-colors group-hover:bg-primary/60 group-focus-visible:bg-primary group-data-[dragging=true]:bg-primary" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showActivity && <ModelActivityPanel desktopSplit={desktopSplit} onClose={() => setShowActivity(false)} />}
|
||||
{showActivity && desktopSplit && (
|
||||
<div className="relative w-0 shrink-0">
|
||||
<div
|
||||
{...separatorProps}
|
||||
{...activitySeparatorProps}
|
||||
data-testid="activity-resize-handle"
|
||||
data-dragging={resizing}
|
||||
data-dragging={activityResizing}
|
||||
className="group absolute inset-y-0 left-1/2 z-20 w-3 -translate-x-1/2 touch-none cursor-col-resize outline-none"
|
||||
>
|
||||
<span className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-border transition-colors group-hover:bg-primary/60 group-focus-visible:bg-primary group-data-[dragging=true]:bg-primary" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { server } from "../test/msw";
|
||||
@@ -73,3 +73,104 @@ test("a malformed document renders a fallback without taking down its siblings",
|
||||
expect(screen.getByText(/couldn't be displayed/i)).toBeInTheDocument();
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("renders the canonical summary order and formats human text as Markdown", async () => {
|
||||
server.resetHandlers(
|
||||
http.get("http://localhost:8787/sessions/s1/documents", () =>
|
||||
HttpResponse.json([
|
||||
{ phase: "—", key: "question", title: "Original question", format: "text", content: "Original **question**" },
|
||||
{ phase: "F7", key: "sql", title: "Final SQL", format: "sql", content: "SELECT 1" },
|
||||
{ phase: "finalize", key: "preview", title: "Data preview", format: "markdown", content: "| count |\n|---|\n| 42 |" },
|
||||
{ phase: "F3", key: "revised_question", title: "Revised question", format: "markdown", content: "# Domanda\n\nConta i pazienti **attivi**." },
|
||||
{ phase: "F3", key: "assumptions", title: "Assumptions", format: "markdown", content: "## Assunzioni\n\n- Solo adulti" },
|
||||
{ phase: "F8", key: "memories", title: "Memories", format: "memories", content: "[]" },
|
||||
{ phase: "F4", key: "schema_linking", title: "Schema linking", format: "schema-linking", content: '{"question":"q","candidates":[],"joins":[],"excluded":[],"open_questions":[]}' },
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
wrap(<SessionDocumentsPanel session={base} onClose={vi.fn()} onResume={vi.fn()} />);
|
||||
|
||||
const panel = await screen.findByRole("complementary", { name: /session summary/i });
|
||||
await within(panel).findByRole("heading", { name: "Original question", level: 3 });
|
||||
expect(within(panel).getAllByRole("heading", { level: 3 }).map((heading) => heading.textContent)).toEqual([
|
||||
"Original question",
|
||||
"Final SQL",
|
||||
"Data preview",
|
||||
"Revised question",
|
||||
"Assumptions",
|
||||
"Memories",
|
||||
"Schema linking",
|
||||
]);
|
||||
expect(screen.getByText("question").tagName).toBe("STRONG");
|
||||
expect(screen.getByText("attivi").tagName).toBe("STRONG");
|
||||
expect(screen.getByRole("heading", { name: "Domanda", level: 1 })).toBeInTheDocument();
|
||||
expect(screen.getByText("Solo adulti").closest("li")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders one approved-then-declined memory list with Markdown details", async () => {
|
||||
server.resetHandlers(
|
||||
http.get("http://localhost:8787/sessions/s1/documents", () =>
|
||||
HttpResponse.json([
|
||||
{
|
||||
phase: "F8",
|
||||
key: "memories",
|
||||
title: "Memories",
|
||||
format: "memories",
|
||||
content: JSON.stringify([
|
||||
{
|
||||
status: "approved",
|
||||
subject: "ablazione **valida**",
|
||||
detail: "Stato `completed`.",
|
||||
rationale: "*Confermato* dal reviewer.",
|
||||
},
|
||||
{
|
||||
status: "declined",
|
||||
subject: "paziente attivo",
|
||||
detail: "Usa `flag_attivo`.",
|
||||
rationale: "",
|
||||
},
|
||||
]),
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
wrap(<SessionDocumentsPanel session={base} onClose={vi.fn()} onResume={vi.fn()} />);
|
||||
|
||||
const list = await screen.findByRole("list", { name: /memories/i });
|
||||
const items = within(list).getAllByRole("listitem");
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[0]).toHaveTextContent(/approved/i);
|
||||
expect(items[1]).toHaveTextContent(/declined/i);
|
||||
expect(within(items[0]).getByText("valida").tagName).toBe("STRONG");
|
||||
expect(within(items[0]).getByText("completed").tagName).toBe("CODE");
|
||||
expect(within(items[0]).getByText("Confermato").tagName).toBe("EM");
|
||||
expect(screen.queryByText(/memory promoted/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("never renders technical approval, promotion, or memory decisions", async () => {
|
||||
const lines = [
|
||||
{ type: "phase_approved", subject: "phase:1" },
|
||||
{ type: "table_approved", subject: "fact_a" },
|
||||
{ type: "table_promoted", subject: "fact_a" },
|
||||
{ type: "column_promoted", subject: "fact_a.id" },
|
||||
{ type: "memory_promoted", subject: "concetto" },
|
||||
{ type: "column_excluded", subject: "fact_a.note", detail: "Non **pertinente**" },
|
||||
].map((decision) => JSON.stringify(decision)).join("\n");
|
||||
server.resetHandlers(
|
||||
http.get("http://localhost:8787/sessions/s1/documents", () =>
|
||||
HttpResponse.json([
|
||||
{ phase: "—", key: "decisions", title: "Decisions", format: "decisions", content: lines },
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
wrap(<SessionDocumentsPanel session={base} onClose={vi.fn()} onResume={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText("column excluded")).toBeInTheDocument();
|
||||
expect(screen.getByText("pertinente").tagName).toBe("STRONG");
|
||||
for (const hidden of ["phase approved", "table approved", "table promoted", "column promoted", "memory promoted"]) {
|
||||
expect(screen.queryByText(hidden)).not.toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ interface Props {
|
||||
session: SessionSummary;
|
||||
onClose: () => void;
|
||||
onResume: (id: string) => void;
|
||||
desktopSplit?: boolean;
|
||||
}
|
||||
|
||||
function statusLabel(s: SessionSummary): string {
|
||||
@@ -20,6 +21,25 @@ function statusLabel(s: SessionSummary): string {
|
||||
return `In progress · ${s.status}`;
|
||||
}
|
||||
|
||||
type MemoryItem = {
|
||||
status: "approved" | "declined";
|
||||
subject: string;
|
||||
detail: string;
|
||||
rationale: string;
|
||||
};
|
||||
|
||||
const HIDDEN_DECISION_TYPES = new Set([
|
||||
"concept_clarified",
|
||||
"memory_promoted",
|
||||
"memory_promotion_declined",
|
||||
"memory_rejected",
|
||||
"phase_approved",
|
||||
"phase_auto_approved",
|
||||
"table_approved",
|
||||
"table_promoted",
|
||||
"column_promoted",
|
||||
]);
|
||||
|
||||
function DocBody({ doc }: { doc: SessionDocument }) {
|
||||
if (doc.format === "sql") return <SqlViewer blocks={[{ name: doc.title, sql: doc.content }]} />;
|
||||
if (doc.format === "markdown") return <MarkdownView source={doc.content} />;
|
||||
@@ -31,6 +51,53 @@ function DocBody({ doc }: { doc: SessionDocument }) {
|
||||
return <pre className="whitespace-pre-wrap text-xs">{doc.content}</pre>;
|
||||
}
|
||||
}
|
||||
if (doc.format === "memories") {
|
||||
const raw = JSON.parse(doc.content) as unknown;
|
||||
const items = Array.isArray(raw)
|
||||
? raw.filter((item): item is MemoryItem => {
|
||||
if (!item || typeof item !== "object") return false;
|
||||
const candidate = item as Partial<MemoryItem>;
|
||||
return (
|
||||
(candidate.status === "approved" || candidate.status === "declined")
|
||||
&& typeof candidate.subject === "string"
|
||||
&& typeof candidate.detail === "string"
|
||||
&& typeof candidate.rationale === "string"
|
||||
);
|
||||
})
|
||||
: [];
|
||||
if (items.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">No memories recorded.</p>;
|
||||
}
|
||||
return (
|
||||
<ul aria-label="Memories" className="flex flex-col divide-y divide-border/50">
|
||||
{items.map((item, index) => (
|
||||
<li key={`${item.status}:${item.subject}:${index}`} className="py-4 first:pt-0">
|
||||
<span className={[
|
||||
"inline-flex rounded-md px-2 py-0.5 text-[0.62rem] font-semibold uppercase tracking-wide",
|
||||
item.status === "approved"
|
||||
? "bg-[oklch(var(--success)/0.15)] text-[oklch(0.47_0.12_165)]"
|
||||
: "bg-muted text-muted-foreground",
|
||||
].join(" ")}>
|
||||
{item.status === "approved" ? "Approved" : "Declined"}
|
||||
</span>
|
||||
<div className="mt-2 font-medium text-foreground">
|
||||
<MarkdownView source={item.subject} />
|
||||
</div>
|
||||
{item.detail && (
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
<MarkdownView source={item.detail} />
|
||||
</div>
|
||||
)}
|
||||
{item.rationale && (
|
||||
<div className="mt-1 border-l-2 border-border pl-3 text-sm text-muted-foreground">
|
||||
<MarkdownView source={item.rationale} />
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
if (doc.format === "decisions") {
|
||||
const items = doc.content
|
||||
.split("\n")
|
||||
@@ -39,7 +106,9 @@ function DocBody({ doc }: { doc: SessionDocument }) {
|
||||
try { return JSON.parse(line) as { type?: string; subject?: string; detail?: string }; }
|
||||
catch { return null; }
|
||||
})
|
||||
.filter((d): d is { type?: string; subject?: string; detail?: string } => d !== null);
|
||||
.filter((d): d is { type?: string; subject?: string; detail?: string } => (
|
||||
d !== null && !HIDDEN_DECISION_TYPES.has(d.type ?? "")
|
||||
));
|
||||
if (items.length === 0) return <p className="text-sm text-muted-foreground">No decisions recorded.</p>;
|
||||
return (
|
||||
<ul className="flex flex-col divide-y divide-border/50">
|
||||
@@ -48,14 +117,22 @@ function DocBody({ doc }: { doc: SessionDocument }) {
|
||||
<span className={`inline-block rounded-md px-2 py-0.5 text-[0.62rem] font-semibold uppercase tracking-wide ${decisionChipClass(d.type ?? "")}`}>
|
||||
{(d.type ?? "decision").replace(/_/g, " ")}
|
||||
</span>
|
||||
{d.subject && <p className="mt-1.5 text-sm font-medium text-foreground">{d.subject}</p>}
|
||||
{d.detail && <p className="mt-0.5 text-sm leading-relaxed text-muted-foreground">{d.detail}</p>}
|
||||
{d.subject && (
|
||||
<div className="mt-1.5 text-sm font-medium text-foreground">
|
||||
<MarkdownView source={d.subject} />
|
||||
</div>
|
||||
)}
|
||||
{d.detail && (
|
||||
<div className="mt-0.5 text-sm leading-relaxed text-muted-foreground">
|
||||
<MarkdownView source={d.detail} />
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
return <p className="whitespace-pre-wrap text-sm leading-relaxed text-foreground/90">{doc.content}</p>;
|
||||
return <MarkdownView source={doc.content} />;
|
||||
}
|
||||
|
||||
/** Semantic tint for a decision-type chip: approvals/promotions read as progress
|
||||
@@ -69,7 +146,7 @@ function decisionChipClass(type: string): string {
|
||||
return "bg-[oklch(var(--primary)/0.1)] text-primary";
|
||||
}
|
||||
|
||||
export function SessionDocumentsPanel({ session, onClose, onResume }: Props) {
|
||||
export function SessionDocumentsPanel({ session, onClose, onResume, desktopSplit = false }: Props) {
|
||||
const { data: docs = [], isLoading } = useQuery<SessionDocument[]>({
|
||||
queryKey: ["session-documents", session.id],
|
||||
queryFn: () => getSessionDocuments(session.id),
|
||||
@@ -77,7 +154,15 @@ export function SessionDocumentsPanel({ session, onClose, onResume }: Props) {
|
||||
const resumable = session.status !== "finalized" && !session.archived;
|
||||
|
||||
return (
|
||||
<aside className="flex w-[30vw] max-w-[30vw] shrink-0 flex-col border-r border-border bg-sidebar">
|
||||
<aside
|
||||
aria-label="Session summary"
|
||||
className={[
|
||||
"flex shrink-0 flex-col border-r border-border bg-sidebar",
|
||||
desktopSplit
|
||||
? "w-[var(--session-panel-width)] max-w-[50%]"
|
||||
: "w-[30vw] max-w-[30vw]",
|
||||
].join(" ")}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 border-b border-border/60 px-3 py-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate font-heading text-sm font-semibold text-foreground">
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useCallback, useLayoutEffect, useRef, useState } from "react";
|
||||
import type { KeyboardEventHandler, PointerEventHandler, RefObject } from "react";
|
||||
|
||||
export const SESSION_PANEL_DEFAULT_WIDTH = 384;
|
||||
export const SESSION_PANEL_MIN_WIDTH = 320;
|
||||
export const SESSION_PANEL_FALLBACK_MAX_WIDTH = 576;
|
||||
export const SESSION_PANEL_RIGHT_MIN_WIDTH = 512;
|
||||
export const SESSION_PANEL_STORAGE_KEY = "thothii:session-documents-panel-width";
|
||||
|
||||
type Bounds = { min: number; max: number };
|
||||
|
||||
export function getSessionPanelBounds(containerWidth: number): Bounds {
|
||||
return {
|
||||
min: SESSION_PANEL_MIN_WIDTH,
|
||||
max: Math.max(
|
||||
SESSION_PANEL_MIN_WIDTH,
|
||||
Math.min(containerWidth * 0.5, containerWidth - SESSION_PANEL_RIGHT_MIN_WIDTH),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function clampWidth(width: number, bounds: Bounds): number {
|
||||
const candidate = Number.isFinite(width) ? width : SESSION_PANEL_DEFAULT_WIDTH;
|
||||
return Math.min(bounds.max, Math.max(bounds.min, candidate));
|
||||
}
|
||||
|
||||
function browserStorage(): Storage | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function loadWidth(storage: Pick<Storage, "getItem"> | null): number {
|
||||
if (!storage) return SESSION_PANEL_DEFAULT_WIDTH;
|
||||
try {
|
||||
const raw = storage.getItem(SESSION_PANEL_STORAGE_KEY);
|
||||
if (!raw?.trim()) return SESSION_PANEL_DEFAULT_WIDTH;
|
||||
const parsed = Number(raw);
|
||||
return Number.isFinite(parsed) ? parsed : SESSION_PANEL_DEFAULT_WIDTH;
|
||||
} catch {
|
||||
return SESSION_PANEL_DEFAULT_WIDTH;
|
||||
}
|
||||
}
|
||||
|
||||
function saveWidth(width: number, storage: Pick<Storage, "setItem"> | null): void {
|
||||
if (!storage) return;
|
||||
try {
|
||||
storage.setItem(SESSION_PANEL_STORAGE_KEY, String(Math.round(width)));
|
||||
} catch {
|
||||
// Storage may be unavailable in privacy-restricted browser contexts.
|
||||
}
|
||||
}
|
||||
|
||||
function widthFromKey(width: number, key: string, shiftKey: boolean, bounds: Bounds) {
|
||||
const step = shiftKey ? 48 : 16;
|
||||
if (key === "ArrowLeft") return clampWidth(width - step, bounds);
|
||||
if (key === "ArrowRight") return clampWidth(width + step, bounds);
|
||||
if (key === "Home") return bounds.min;
|
||||
if (key === "End") return bounds.max;
|
||||
return null;
|
||||
}
|
||||
|
||||
type DragState = {
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startWidth: number;
|
||||
target: HTMLDivElement;
|
||||
};
|
||||
|
||||
export function useSessionPanelResize(
|
||||
containerRef: RefObject<HTMLDivElement>,
|
||||
enabled: boolean,
|
||||
) {
|
||||
const storageRef = useRef<Storage | null>(browserStorage());
|
||||
const initialWidthRef = useRef(loadWidth(storageRef.current));
|
||||
const widthRef = useRef(initialWidthRef.current);
|
||||
const boundsRef = useRef<Bounds>({
|
||||
min: SESSION_PANEL_MIN_WIDTH,
|
||||
max: SESSION_PANEL_FALLBACK_MAX_WIDTH,
|
||||
});
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
const [panelWidth, setPanelWidth] = useState(initialWidthRef.current);
|
||||
const [bounds, setBounds] = useState(boundsRef.current);
|
||||
const [desktopSplit, setDesktopSplit] = useState(false);
|
||||
const [resizing, setResizing] = useState(false);
|
||||
|
||||
const applyWidth = useCallback((rawWidth: number, persist: boolean) => {
|
||||
const next = clampWidth(rawWidth, boundsRef.current);
|
||||
widthRef.current = next;
|
||||
setPanelWidth(next);
|
||||
if (persist) saveWidth(next, storageRef.current);
|
||||
return next;
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const cancelDrag = () => {
|
||||
const drag = dragRef.current;
|
||||
if (drag?.target.hasPointerCapture?.(drag.pointerId)) {
|
||||
drag.target.releasePointerCapture?.(drag.pointerId);
|
||||
}
|
||||
dragRef.current = null;
|
||||
setResizing(false);
|
||||
};
|
||||
const syncLayout = () => {
|
||||
const viewportIsDesktop = window.matchMedia?.("(min-width: 1024px)").matches
|
||||
?? window.innerWidth >= 1024;
|
||||
const usableWidth = SESSION_PANEL_MIN_WIDTH + SESSION_PANEL_RIGHT_MIN_WIDTH;
|
||||
const nextDesktopSplit = enabled && viewportIsDesktop && container.clientWidth >= usableWidth;
|
||||
setDesktopSplit(nextDesktopSplit);
|
||||
if (!nextDesktopSplit) {
|
||||
cancelDrag();
|
||||
return;
|
||||
}
|
||||
const nextBounds = getSessionPanelBounds(container.clientWidth);
|
||||
boundsRef.current = nextBounds;
|
||||
setBounds(nextBounds);
|
||||
applyWidth(widthRef.current, true);
|
||||
};
|
||||
syncLayout();
|
||||
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(syncLayout);
|
||||
observer?.observe(container);
|
||||
const media = window.matchMedia?.("(min-width: 1024px)");
|
||||
media?.addEventListener?.("change", syncLayout);
|
||||
window.addEventListener("resize", syncLayout);
|
||||
return () => {
|
||||
cancelDrag();
|
||||
observer?.disconnect();
|
||||
media?.removeEventListener?.("change", syncLayout);
|
||||
window.removeEventListener("resize", syncLayout);
|
||||
};
|
||||
}, [applyWidth, containerRef, enabled]);
|
||||
|
||||
const onPointerDown: PointerEventHandler<HTMLDivElement> = (event) => {
|
||||
dragRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startWidth: widthRef.current,
|
||||
target: event.currentTarget,
|
||||
};
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
setResizing(true);
|
||||
};
|
||||
const onPointerMove: PointerEventHandler<HTMLDivElement> = (event) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||
applyWidth(drag.startWidth + event.clientX - drag.startX, false);
|
||||
};
|
||||
const onPointerUp: PointerEventHandler<HTMLDivElement> = (event) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||
applyWidth(drag.startWidth + event.clientX - drag.startX, true);
|
||||
if (event.currentTarget.hasPointerCapture?.(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
||||
}
|
||||
dragRef.current = null;
|
||||
setResizing(false);
|
||||
};
|
||||
const onPointerCancel: PointerEventHandler<HTMLDivElement> = (event) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||
applyWidth(drag.startWidth, false);
|
||||
dragRef.current = null;
|
||||
setResizing(false);
|
||||
};
|
||||
const onKeyDown: KeyboardEventHandler<HTMLDivElement> = (event) => {
|
||||
const next = widthFromKey(widthRef.current, event.key, event.shiftKey, boundsRef.current);
|
||||
if (next === null) return;
|
||||
event.preventDefault();
|
||||
applyWidth(next, true);
|
||||
};
|
||||
|
||||
return {
|
||||
panelWidth,
|
||||
desktopSplit,
|
||||
resizing,
|
||||
separatorProps: {
|
||||
role: "separator" as const,
|
||||
tabIndex: 0,
|
||||
"aria-label": "Resize session summary",
|
||||
"aria-orientation": "vertical" as const,
|
||||
"aria-valuemin": bounds.min,
|
||||
"aria-valuemax": bounds.max,
|
||||
"aria-valuenow": panelWidth,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
onPointerCancel,
|
||||
onKeyDown,
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user