feat: redesign persisted session summaries

This commit is contained in:
2026-07-23 15:35:31 +02:00
parent 12c7a976d7
commit 0db85e4e4e
12 changed files with 1090 additions and 71 deletions
+1 -1
View File
@@ -133,7 +133,7 @@ export interface SessionDocument {
phase: string;
key: string;
title: string;
format: "markdown" | "sql" | "schema-linking" | "decisions" | "text";
format: "markdown" | "sql" | "schema-linking" | "decisions" | "memories" | "text";
content: string;
}
@@ -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(
+36 -8
View File
@@ -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();
}
});
+91 -6
View File
@@ -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">
+196
View File
@@ -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,
},
};
}
+36 -1
View File
@@ -22,6 +22,16 @@ const blocks = [
},
];
let writeText: ReturnType<typeof vi.fn>;
beforeEach(() => {
writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});
});
test("(a) renders headers with name, field count, and test-status badge", async () => {
render(<SqlViewer blocks={blocks} />);
// Drain the pending highlightSql microtasks inside act() before asserting,
@@ -51,7 +61,11 @@ test("(b) bodies start expanded and clicking header collapses then re-expands",
});
// Collapse the first block by clicking its header
const firstHeader = screen.getByRole("button", { name: /base_orders/i });
const firstHeader = screen
.getAllByRole("button", { name: /base_orders/i })
.find((button) => button.hasAttribute("aria-expanded"));
expect(firstHeader).toBeDefined();
if (!firstHeader) throw new Error("Missing SQL collapse control");
await user.click(firstHeader);
// Now only one highlighted block visible
@@ -92,3 +106,24 @@ test("a single SQL block hides the multi-block layout toggle", async () => {
expect(screen.queryByRole("button", { name: /horizontal/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /vertical/i })).not.toBeInTheDocument();
});
test("copies the exact SQL from an always-available accessible control", async () => {
render(<SqlViewer blocks={[blocks[0]]} />);
await waitFor(() => expect(screen.getByTestId("hl")).toBeInTheDocument());
await userEvent.click(screen.getByRole("button", { name: /copy sql for base_orders/i }));
expect(writeText).toHaveBeenCalledWith("SELECT id, amount FROM orders");
expect(await screen.findByText("Copied")).toBeInTheDocument();
});
test("reports clipboard rejection and keeps the SQL visible", async () => {
writeText.mockRejectedValueOnce(new Error("clipboard unavailable"));
render(<SqlViewer blocks={[blocks[0]]} />);
await waitFor(() => expect(screen.getByTestId("hl")).toBeInTheDocument());
await userEvent.click(screen.getByRole("button", { name: /copy sql for base_orders/i }));
expect(await screen.findByText("Copy failed")).toBeInTheDocument();
expect(screen.getByTestId("hl")).toBeInTheDocument();
});
+51 -19
View File
@@ -1,4 +1,5 @@
import React, { useEffect, useState } from "react";
import { Check, Copy, TriangleAlert } from "lucide-react";
import { highlightSql } from "./highlight";
import { statusBadgeClass } from "./statusBadge";
@@ -21,6 +22,7 @@ const STATUS_LABELS: Record<string, string> = {
function Block({ block }: { block: SqlBlock }) {
const [open, setOpen] = useState(true);
const [html, setHtml] = useState<string>("");
const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">("idle");
useEffect(() => {
let cancelled = false;
@@ -37,28 +39,58 @@ function Block({ block }: { block: SqlBlock }) {
? statusBadgeClass(block.testStatus)
: "";
async function copySql() {
try {
if (!navigator.clipboard?.writeText) throw new Error("Clipboard unavailable");
await navigator.clipboard.writeText(block.sql);
setCopyState("copied");
} catch {
setCopyState("failed");
}
}
return (
<div className="sql-block rounded-lg border border-border/70 mb-2">
<button
className="w-full flex items-center gap-2 px-3 py-2 text-left font-mono text-sm hover:bg-muted/30"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
>
<span className="select-none">{open ? "▾" : "▸"}</span>
<span className="font-semibold">{block.name}</span>
{block.fields !== undefined && (
<span className="text-muted-foreground text-xs">
{block.fields} campi
<div className="flex items-stretch">
<button
className="flex min-w-0 flex-1 items-center gap-2 px-3 py-2 text-left font-mono text-sm hover:bg-muted/30"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
>
<span className="select-none">{open ? "▾" : "▸"}</span>
<span className="truncate font-semibold">{block.name}</span>
{block.fields !== undefined && (
<span className="shrink-0 text-xs text-muted-foreground">
{block.fields} campi
</span>
)}
{badge && (
<span
className={`ml-auto shrink-0 rounded-md px-1.5 py-0.5 text-xs ${badgeClass}`}
>
{badge}
</span>
)}
</button>
<button
type="button"
aria-label={`Copy SQL for ${block.name}`}
title="Copy SQL"
onClick={copySql}
className="flex shrink-0 items-center gap-1.5 border-l border-border/70 px-2.5 text-xs text-muted-foreground transition-colors hover:bg-muted/40 hover:text-foreground"
>
{copyState === "copied" ? (
<Check className="size-3.5 text-[oklch(0.47_0.12_165)]" aria-hidden="true" />
) : copyState === "failed" ? (
<TriangleAlert className="size-3.5 text-destructive" aria-hidden="true" />
) : (
<Copy className="size-3.5" aria-hidden="true" />
)}
<span role="status" aria-live="polite">
{copyState === "copied" ? "Copied" : copyState === "failed" ? "Copy failed" : ""}
</span>
)}
{badge && (
<span
className={`ml-auto text-xs px-1.5 py-0.5 rounded-md ${badgeClass}`}
>
{badge}
</span>
)}
</button>
</button>
</div>
{open && (
<div className="sql-block-body px-3 pb-3">