fix(frontend): harden activity split responsiveness

This commit is contained in:
User
2026-07-15 15:52:22 +02:00
parent f0ef2654df
commit 1f540fcb78
6 changed files with 160 additions and 18 deletions
+18
View File
@@ -220,6 +220,24 @@
text-transform: uppercase; text-transform: uppercase;
color: oklch(var(--muted-foreground)); color: oklch(var(--muted-foreground));
} }
.thot-activity-prose,
.thot-activity-prose p,
.thot-activity-prose li,
.thot-activity-prose a,
.thot-activity-prose code,
.thot-activity-prose th,
.thot-activity-prose td {
overflow-wrap: anywhere;
word-break: break-word;
}
.thot-activity-prose table { table-layout: fixed; }
.thot-activity-prose pre {
overflow-x: hidden;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
}
.thot-activity-prose pre code { white-space: inherit; }
} }
@@ -21,6 +21,23 @@ const LIST = [
const resumeResult = (id: string, alreadyActive = false) => const resumeResult = (id: string, alreadyActive = false) =>
HttpResponse.json({ id, alreadyActive }); HttpResponse.json({ id, alreadyActive });
class ControlledResizeObserver {
static instances: ControlledResizeObserver[] = [];
constructor(private readonly callback: ResizeObserverCallback) {
ControlledResizeObserver.instances.push(this);
}
observe = vi.fn();
disconnect = vi.fn();
trigger() {
this.callback([], this as unknown as ResizeObserver);
}
}
function setContainerWidth(element: HTMLElement, width: number) {
Object.defineProperty(element, "clientWidth", { configurable: true, value: width });
act(() => ControlledResizeObserver.instances.at(-1)?.trigger());
}
function deferred() { function deferred() {
let resolve!: () => void; let resolve!: () => void;
const promise = new Promise<void>((onResolve) => { resolve = onResolve; }); const promise = new Promise<void>((onResolve) => { resolve = onResolve; });
@@ -30,8 +47,11 @@ function deferred() {
beforeEach(() => { beforeEach(() => {
localStorage.clear(); localStorage.clear();
FakeEventSource.instances = []; FakeEventSource.instances = [];
ControlledResizeObserver.instances = [];
(globalThis as any).EventSource = FakeEventSource; (globalThis as any).EventSource = FakeEventSource;
(globalThis as any).PointerEvent = MouseEvent; (globalThis as any).PointerEvent = MouseEvent;
(globalThis as any).ResizeObserver = ControlledResizeObserver;
window.matchMedia = vi.fn().mockReturnValue({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() });
useSessionStore.getState().resetSession(); useSessionStore.getState().resetSession();
server.use( server.use(
http.get("http://localhost:8787/sessions", () => HttpResponse.json(LIST)), http.get("http://localhost:8787/sessions", () => HttpResponse.json(LIST)),
@@ -789,6 +809,7 @@ test("opens an accessible resizable activity split and persists pointer width",
await userEvent.click(screen.getByRole("button", { name: /show model activity/i })); await userEvent.click(screen.getByRole("button", { name: /show model activity/i }));
const shell = screen.getByTestId("app-shell"); const shell = screen.getByTestId("app-shell");
setContainerWidth(shell, 1200);
const separator = screen.getByRole("separator", { name: /resize model activity/i }); const separator = screen.getByRole("separator", { name: /resize model activity/i });
expect(shell.style.getPropertyValue("--activity-panel-width")).toBe("384px"); expect(shell.style.getPropertyValue("--activity-panel-width")).toBe("384px");
expect(separator).toHaveAttribute("aria-orientation", "vertical"); expect(separator).toHaveAttribute("aria-orientation", "vertical");
@@ -818,6 +839,7 @@ test("resizes the activity split with keyboard and exposes responsive drawer cla
await userEvent.click(screen.getByRole("button", { name: /show model activity/i })); await userEvent.click(screen.getByRole("button", { name: /show model activity/i }));
const shell = screen.getByTestId("app-shell"); const shell = screen.getByTestId("app-shell");
setContainerWidth(shell, 1200);
const separator = screen.getByRole("separator", { name: /resize model activity/i }); const separator = screen.getByRole("separator", { name: /resize model activity/i });
expect(shell.style.getPropertyValue("--activity-panel-width")).toBe("448px"); expect(shell.style.getPropertyValue("--activity-panel-width")).toBe("448px");
fireEvent.keyDown(separator, { key: "ArrowLeft" }); fireEvent.keyDown(separator, { key: "ArrowLeft" });
@@ -829,10 +851,53 @@ test("resizes the activity split with keyboard and exposes responsive drawer cla
const panel = screen.getByRole("heading", { name: "Model activity" }).closest("aside"); const panel = screen.getByRole("heading", { name: "Model activity" }).closest("aside");
expect(panel).toHaveClass( expect(panel).toHaveClass(
"fixed", "static",
"w-[min(90vw,24rem)]", "w-[var(--activity-panel-width)]",
"lg:static",
"lg:w-[var(--activity-panel-width)]",
); );
expect(separator).toHaveClass("touch-none", "cursor-col-resize"); expect(separator).toHaveClass("touch-none", "cursor-col-resize");
}); });
test("uses the measured app shell for the desktop activity split", async () => {
localStorage.setItem(ACTIVITY_PANEL_STORAGE_KEY, "576");
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(await screen.findByRole("button", { name: /show model activity/i }));
const shell = screen.getByTestId("app-shell");
setContainerWidth(shell, 1200);
expect(shell.style.getPropertyValue("--activity-panel-width")).toBe("576px");
expect(screen.getByRole("separator", { name: /resize model activity/i })).toBeInTheDocument();
setContainerWidth(shell, 1024);
expect(shell.style.getPropertyValue("--activity-panel-width")).toBe("512px");
setContainerWidth(shell, 799);
expect(screen.queryByRole("separator", { name: /resize model activity/i })).not.toBeInTheDocument();
expect(screen.getByTestId("conversation-column")).toHaveClass("flex-1");
expect(screen.getByRole("heading", { name: "Model activity" }).closest("aside")).toHaveClass("fixed");
});
test("cancels an active resize when the measured shell becomes too narrow", async () => {
localStorage.setItem(ACTIVITY_PANEL_STORAGE_KEY, "576");
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(await screen.findByRole("button", { name: /show model activity/i }));
const shell = screen.getByTestId("app-shell");
setContainerWidth(shell, 1200);
const separator = screen.getByRole("separator", { name: /resize model activity/i });
Object.assign(separator, {
setPointerCapture: vi.fn(), releasePointerCapture: vi.fn(), hasPointerCapture: vi.fn(() => true),
});
fireEvent.pointerDown(separator, { pointerId: 1, clientX: 576 });
fireEvent.pointerMove(separator, { pointerId: 1, clientX: 400 });
setContainerWidth(shell, 799);
fireEvent.pointerUp(separator, { pointerId: 1, clientX: 300 });
expect(localStorage.getItem(ACTIVITY_PANEL_STORAGE_KEY)).toBe("576");
expect(shell).toHaveAttribute("data-activity-resizing", "false");
});
+4 -4
View File
@@ -33,7 +33,7 @@ import type { CSSProperties } from "react";
* portal's left sidebar, and the workflow phases ride a slim strip above the chat. * portal's left sidebar, and the workflow phases ride a slim strip above the chat.
*/ */
export function AppShell() { export function AppShell() {
const { containerRef, panelWidth, resizing, separatorProps } = useActivityPanelResize(); const { containerRef, panelWidth, desktopSplit, resizing, separatorProps } = useActivityPanelResize();
const activityWidthStyle = { const activityWidthStyle = {
"--activity-panel-width": `${panelWidth}px`, "--activity-panel-width": `${panelWidth}px`,
} as CSSProperties; } as CSSProperties;
@@ -387,9 +387,9 @@ export function AppShell() {
onResume={doResume} onResume={doResume}
/> />
)} )}
{showActivity && <ModelActivityPanel onClose={() => setShowActivity(false)} />} {showActivity && <ModelActivityPanel desktopSplit={desktopSplit} onClose={() => setShowActivity(false)} />}
{showActivity && ( {showActivity && desktopSplit && (
<div className="relative hidden w-0 shrink-0 lg:block"> <div className="relative w-0 shrink-0">
<div <div
{...separatorProps} {...separatorProps}
data-testid="activity-resize-handle" data-testid="activity-resize-handle"
@@ -40,6 +40,25 @@ test("renders question, reasoning, and response in chronological order", () => {
expect(screen.queryByText("bash")).not.toBeInTheDocument(); expect(screen.queryByText("bash")).not.toBeInTheDocument();
}); });
test("uses the activity-only Markdown wrapping renderer for long content", () => {
useSessionStore.setState({
activityLog: [
{ kind: "prompt", phase: "F1", text: "https://example.test/" + "x".repeat(120) },
{ kind: "thinking", phase: "F1", text: "Inline `" + "code".repeat(40) + "`" },
{ kind: "assistant", phase: "F1", text: "| Cell |\n| --- |\n| " + "table".repeat(40) + " |\n\n```sql\n" + "select_".repeat(40) + "\n```" },
],
});
render(<ModelActivityPanel onClose={vi.fn()} />);
const renderers = document.querySelectorAll(".thot-activity-prose");
expect(renderers).toHaveLength(3);
expect(renderers[0]).toHaveClass("thot-prose");
expect(renderers[1].querySelector("code")).not.toBeNull();
expect(renderers[2].querySelector("td")).not.toBeNull();
expect(renderers[2].querySelector("pre")).not.toBeNull();
});
test("uses an explicit default-deny activity-kind allowlist", () => { test("uses an explicit default-deny activity-kind allowlist", () => {
const predicate = (activityPanelModule as unknown as { const predicate = (activityPanelModule as unknown as {
isVisibleModelActivity?: (entry: ActivityEntry) => boolean; isVisibleModelActivity?: (entry: ActivityEntry) => boolean;
+12 -3
View File
@@ -37,7 +37,7 @@ export function formatModelActivity(text: string): string {
function MarkdownBody({ entry }: { entry: ActivityEntry }) { function MarkdownBody({ entry }: { entry: ActivityEntry }) {
const tone = entry.kind === "thinking" ? "text-muted-foreground" : "text-foreground"; const tone = entry.kind === "thinking" ? "text-muted-foreground" : "text-foreground";
return ( return (
<div className={`thot-prose max-w-[74ch] text-sm ${tone}`}> <div className={`thot-prose thot-activity-prose max-w-[74ch] text-sm ${tone}`}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{formatModelActivity(entry.text)}</ReactMarkdown> <ReactMarkdown remarkPlugins={[remarkGfm]}>{formatModelActivity(entry.text)}</ReactMarkdown>
</div> </div>
); );
@@ -56,7 +56,13 @@ function ActivityRow({ entry }: { entry: ActivityEntry }) {
} }
/** Left drawer showing the user-facing projection of the in-memory model activity timeline. */ /** Left drawer showing the user-facing projection of the in-memory model activity timeline. */
export function ModelActivityPanel({ onClose }: { onClose: () => void }) { export function ModelActivityPanel({
desktopSplit = false,
onClose,
}: {
desktopSplit?: boolean;
onClose: () => void;
}) {
const activityLog = useSessionStore((s) => s.activityLog); const activityLog = useSessionStore((s) => s.activityLog);
const visibleActivity = activityLog.filter(isVisibleModelActivity); const visibleActivity = activityLog.filter(isVisibleModelActivity);
const visibleCount = visibleActivity.length; const visibleCount = visibleActivity.length;
@@ -70,7 +76,10 @@ export function ModelActivityPanel({ onClose }: { onClose: () => void }) {
}, [visibleCount, visibleTail]); }, [visibleCount, visibleTail]);
return ( return (
<aside className="fixed inset-y-0 left-0 z-30 flex w-[min(90vw,24rem)] shrink-0 flex-col border-r border-border bg-sidebar lg:static lg:z-auto lg:w-[var(--activity-panel-width)] lg:border-r-0"> <aside className={desktopSplit
? "static z-auto flex w-[var(--activity-panel-width)] shrink-0 flex-col border-r-0 bg-sidebar"
: "fixed inset-y-0 left-0 z-30 flex w-[min(90vw,24rem)] shrink-0 flex-col border-r border-border bg-sidebar"
}>
<div className="flex items-center justify-between border-b border-border/60 px-3 py-2"> <div className="flex items-center justify-between border-b border-border/60 px-3 py-2">
<h2 className="font-heading text-sm font-semibold text-foreground">Model activity</h2> <h2 className="font-heading text-sm font-semibold text-foreground">Model activity</h2>
<Button size="icon-sm" variant="ghost" aria-label="Close model activity" onClick={onClose}> <Button size="icon-sm" variant="ghost" aria-label="Close model activity" onClick={onClose}>
+38 -7
View File
@@ -71,11 +71,17 @@ export function resizeWidthFromKey(
return null; return null;
} }
type DragState = { pointerId: number; startX: number; startWidth: number }; type DragState = {
pointerId: number;
startX: number;
startWidth: number;
target: HTMLDivElement;
};
export function useActivityPanelResize(): { export function useActivityPanelResize(): {
containerRef: RefObject<HTMLDivElement>; containerRef: RefObject<HTMLDivElement>;
panelWidth: number; panelWidth: number;
desktopSplit: boolean;
resizing: boolean; resizing: boolean;
separatorProps: { separatorProps: {
role: "separator"; role: "separator";
@@ -103,6 +109,7 @@ export function useActivityPanelResize(): {
const dragRef = useRef<DragState | null>(null); const dragRef = useRef<DragState | null>(null);
const [panelWidth, setPanelWidth] = useState(initialWidthRef.current); const [panelWidth, setPanelWidth] = useState(initialWidthRef.current);
const [bounds, setBounds] = useState(boundsRef.current); const [bounds, setBounds] = useState(boundsRef.current);
const [desktopSplit, setDesktopSplit] = useState(false);
const [resizing, setResizing] = useState(false); const [resizing, setResizing] = useState(false);
const applyWidth = useCallback((rawWidth: number, persist: boolean) => { const applyWidth = useCallback((rawWidth: number, persist: boolean) => {
@@ -116,20 +123,42 @@ export function useActivityPanelResize(): {
useLayoutEffect(() => { useLayoutEffect(() => {
const container = containerRef.current; const container = containerRef.current;
if (!container) return; if (!container) return;
const syncBounds = () => { const cancelDrag = () => {
if (container.clientWidth < ACTIVITY_PANEL_MIN_WIDTH + ACTIVITY_CENTRAL_MIN_WIDTH) return; 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 hasUsableContainer = container.clientWidth >= ACTIVITY_PANEL_MIN_WIDTH + ACTIVITY_CENTRAL_MIN_WIDTH;
const nextDesktopSplit = viewportIsDesktop && hasUsableContainer;
setDesktopSplit(nextDesktopSplit);
if (!nextDesktopSplit) {
cancelDrag();
const savedWidth = loadActivityPanelWidth(storageRef.current);
widthRef.current = savedWidth;
setPanelWidth(savedWidth);
return;
}
const nextBounds = getActivityPanelBounds(container.clientWidth); const nextBounds = getActivityPanelBounds(container.clientWidth);
boundsRef.current = nextBounds; boundsRef.current = nextBounds;
setBounds(nextBounds); setBounds(nextBounds);
applyWidth(widthRef.current, true); applyWidth(widthRef.current, true);
}; };
syncBounds(); syncLayout();
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(syncBounds); const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(syncLayout);
observer?.observe(container); observer?.observe(container);
window.addEventListener("resize", syncBounds); const media = window.matchMedia?.("(min-width: 1024px)");
media?.addEventListener?.("change", syncLayout);
window.addEventListener("resize", syncLayout);
return () => { return () => {
observer?.disconnect(); observer?.disconnect();
window.removeEventListener("resize", syncBounds); media?.removeEventListener?.("change", syncLayout);
window.removeEventListener("resize", syncLayout);
}; };
}, [applyWidth]); }, [applyWidth]);
@@ -138,6 +167,7 @@ export function useActivityPanelResize(): {
pointerId: event.pointerId, pointerId: event.pointerId,
startX: event.clientX, startX: event.clientX,
startWidth: widthRef.current, startWidth: widthRef.current,
target: event.currentTarget,
}; };
event.currentTarget.setPointerCapture?.(event.pointerId); event.currentTarget.setPointerCapture?.(event.pointerId);
setResizing(true); setResizing(true);
@@ -174,6 +204,7 @@ export function useActivityPanelResize(): {
return { return {
containerRef, containerRef,
panelWidth, panelWidth,
desktopSplit,
resizing, resizing,
separatorProps: { separatorProps: {
role: "separator", role: "separator",