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