fix(frontend): harden activity split responsiveness
This commit is contained in:
@@ -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");
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user