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;
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) =>
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() {
let resolve!: () => void;
const promise = new Promise<void>((onResolve) => { resolve = onResolve; });
@@ -30,8 +47,11 @@ function deferred() {
beforeEach(() => {
localStorage.clear();
FakeEventSource.instances = [];
ControlledResizeObserver.instances = [];
(globalThis as any).EventSource = FakeEventSource;
(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();
server.use(
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 }));
const shell = screen.getByTestId("app-shell");
setContainerWidth(shell, 1200);
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");
@@ -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 }));
const shell = screen.getByTestId("app-shell");
setContainerWidth(shell, 1200);
const separator = screen.getByRole("separator", { name: /resize model activity/i });
expect(shell.style.getPropertyValue("--activity-panel-width")).toBe("448px");
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");
expect(panel).toHaveClass(
"fixed",
"w-[min(90vw,24rem)]",
"lg:static",
"lg:w-[var(--activity-panel-width)]",
"static",
"w-[var(--activity-panel-width)]",
);
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.
*/
export function AppShell() {
const { containerRef, panelWidth, resizing, separatorProps } = useActivityPanelResize();
const { containerRef, panelWidth, desktopSplit, resizing, separatorProps } = useActivityPanelResize();
const activityWidthStyle = {
"--activity-panel-width": `${panelWidth}px`,
} as CSSProperties;
@@ -387,9 +387,9 @@ export function AppShell() {
onResume={doResume}
/>
)}
{showActivity && <ModelActivityPanel onClose={() => setShowActivity(false)} />}
{showActivity && (
<div className="relative hidden w-0 shrink-0 lg:block">
{showActivity && <ModelActivityPanel desktopSplit={desktopSplit} onClose={() => setShowActivity(false)} />}
{showActivity && desktopSplit && (
<div className="relative w-0 shrink-0">
<div
{...separatorProps}
data-testid="activity-resize-handle"
@@ -40,6 +40,25 @@ test("renders question, reasoning, and response in chronological order", () => {
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", () => {
const predicate = (activityPanelModule as unknown as {
isVisibleModelActivity?: (entry: ActivityEntry) => boolean;
+12 -3
View File
@@ -37,7 +37,7 @@ export function formatModelActivity(text: string): string {
function MarkdownBody({ entry }: { entry: ActivityEntry }) {
const tone = entry.kind === "thinking" ? "text-muted-foreground" : "text-foreground";
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>
</div>
);
@@ -56,7 +56,13 @@ function ActivityRow({ entry }: { entry: ActivityEntry }) {
}
/** 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 visibleActivity = activityLog.filter(isVisibleModelActivity);
const visibleCount = visibleActivity.length;
@@ -70,7 +76,10 @@ export function ModelActivityPanel({ onClose }: { onClose: () => void }) {
}, [visibleCount, visibleTail]);
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">
<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}>
+38 -7
View File
@@ -71,11 +71,17 @@ export function resizeWidthFromKey(
return null;
}
type DragState = { pointerId: number; startX: number; startWidth: number };
type DragState = {
pointerId: number;
startX: number;
startWidth: number;
target: HTMLDivElement;
};
export function useActivityPanelResize(): {
containerRef: RefObject<HTMLDivElement>;
panelWidth: number;
desktopSplit: boolean;
resizing: boolean;
separatorProps: {
role: "separator";
@@ -103,6 +109,7 @@ export function useActivityPanelResize(): {
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) => {
@@ -116,20 +123,42 @@ export function useActivityPanelResize(): {
useLayoutEffect(() => {
const container = containerRef.current;
if (!container) return;
const syncBounds = () => {
if (container.clientWidth < ACTIVITY_PANEL_MIN_WIDTH + ACTIVITY_CENTRAL_MIN_WIDTH) 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 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);
boundsRef.current = nextBounds;
setBounds(nextBounds);
applyWidth(widthRef.current, true);
};
syncBounds();
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(syncBounds);
syncLayout();
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(syncLayout);
observer?.observe(container);
window.addEventListener("resize", syncBounds);
const media = window.matchMedia?.("(min-width: 1024px)");
media?.addEventListener?.("change", syncLayout);
window.addEventListener("resize", syncLayout);
return () => {
observer?.disconnect();
window.removeEventListener("resize", syncBounds);
media?.removeEventListener?.("change", syncLayout);
window.removeEventListener("resize", syncLayout);
};
}, [applyWidth]);
@@ -138,6 +167,7 @@ export function useActivityPanelResize(): {
pointerId: event.pointerId,
startX: event.clientX,
startWidth: widthRef.current,
target: event.currentTarget,
};
event.currentTarget.setPointerCapture?.(event.pointerId);
setResizing(true);
@@ -174,6 +204,7 @@ export function useActivityPanelResize(): {
return {
containerRef,
panelWidth,
desktopSplit,
resizing,
separatorProps: {
role: "separator",