merge: resizable activity timeline and CTE density
This commit is contained in:
@@ -202,6 +202,63 @@ ThothII gira in Docker sul server co-locato, **embedded nel portale omics_portal
|
||||
unrelated Pi runtime existed to disturb. The count-only frontend sensitive/error pattern scan
|
||||
was **0**. No live model smoke was run, and settings and sessions were intentionally untouched.
|
||||
|
||||
### Resizable activity split + compact CTE rows — LIVE 2026-07-15
|
||||
|
||||
- **Resolved UI contract.** `activityLog` remains the complete in-memory chronological fold. The
|
||||
left Model activity panel default-denies every kind except prompt, thinking, and assistant,
|
||||
labels those entries Question, Reasoning, and Response in source order, and hides status, tool,
|
||||
gate, lifecycle, and unknown kinds. The desktop panel is pointer/keyboard resizable from 288–576
|
||||
px while preserving 512 px centrally, persists its global width in localStorage, and becomes an
|
||||
overlay drawer below `lg` or whenever the measured app shell is narrower than 800 px. F6 CTE
|
||||
cards retain their semantic structure and responsive grids;
|
||||
lateral padding is 12/16 px, header/content edge padding is 8 px, internal section gaps are 12
|
||||
px, heading/divider spacing is 4 px, and table/filter rows use 4 px vertical padding with compact
|
||||
line heights.
|
||||
- **Source verification (`2026-07-15T15:22:17+02:00`, source HEAD
|
||||
`f1af1f909b387ae12a10f1b534bf6a529ea42505`).** Frontend tests passed **295/295** across
|
||||
**44/44** files. `npx tsc -b`, `npm run build`, and `git diff --check` exited 0; the Impeccable
|
||||
layout detector returned `[]`. The local source build emitted Vite entry
|
||||
`assets/index-PlvQqhNG.js`.
|
||||
- **Frontend-only deployment.** The pre-deploy frontend was image
|
||||
`sha256:39dc47d81abcb13466218500476fbb78d1be66a424f4293470437700c848c26d`, started
|
||||
`2026-07-15T10:30:30.869532521Z`, serving `index-CihtpQJV.js`. Compose built and force-recreated
|
||||
only `frontend` with `--no-deps`; the final running frontend is image
|
||||
`sha256:0947211373784a860c7507d03c0fcf1f901ac3d1547cd0aa62b914d158f15bd5`, started
|
||||
`2026-07-15T13:21:08.29613952Z`, serving `index-Dn7T524a.js`. Because the entry changed, exactly
|
||||
`omics_portal-web-1` was restarted: it retained image
|
||||
`sha256:4580cf2bc85f3656ee515996c24ed02d9096d24ed0d8afe384a2f8bd8cf4bac9` and moved from
|
||||
start `2026-07-15T10:30:46.369205604Z` to `2026-07-15T13:21:21.968808414Z`.
|
||||
- **Isolation and final state.** Core remained `running`/`healthy` with exactly its original image
|
||||
`sha256:edd8f19ef269ee6f45ecaf464ba4053460d94378f9cdc967d2dbcb386f599147` and start
|
||||
`2026-07-15T00:18:37.040145636Z`; frontend and portal were `running` with no container
|
||||
healthcheck. Pre/post `docker top` showed only core's supervisor and backend server, so no
|
||||
unrelated Pi process existed and no Pi process was stopped or steered. The count-only frontend
|
||||
sensitive/error pattern scan was **0**. No live model smoke was run; settings and sessions were
|
||||
intentionally untouched.
|
||||
|
||||
### Final activity-split fix — LIVE 2026-07-15
|
||||
|
||||
- **Source and verification (`2026-07-15T15:56:57+02:00`).** Deployed source commit
|
||||
`1f540fcb78ac9e552e56a21e47edf66e9872b323` (`1f540fc`). Frontend Vitest passed **298/298**
|
||||
tests across **44/44** files; `npx tsc -b` and `npm run build` exited 0. The Impeccable detector
|
||||
scoped to AppShell, ModelActivityPanel, index.css, and CtePlanViewer returned `[]`; `git diff
|
||||
--check` exited 0.
|
||||
- **Frontend-only deployment.** Compose built and force-recreated only `frontend` with `--no-deps`.
|
||||
The frontend image changed from
|
||||
`sha256:0947211373784a860c7507d03c0fcf1f901ac3d1547cd0aa62b914d158f15bd5` to
|
||||
`sha256:6e14f55092b7e3aca9a396220394ae484147674d81b051771e394e59b73b1c88`; its active Vite entry
|
||||
changed from `index-Dn7T524a.js` to `index-BIznZeLH.js`. Therefore exactly
|
||||
`omics_portal-web-1` was restarted to refresh its manifest cache; it retained image
|
||||
`sha256:4580cf2bc85f3656ee515996c24ed02d9096d24ed0d8afe384a2f8bd8cf4bac9` and started at
|
||||
`2026-07-15T13:56:32.786693915Z`.
|
||||
- **Isolation and final state.** Core retained image
|
||||
`sha256:edd8f19ef269ee6f45ecaf464ba4053460d94378f9cdc967d2dbcb386f599147` and exact original
|
||||
start `2026-07-15T00:18:37.040145636Z`, remaining `running`/`healthy`. Final frontend and portal
|
||||
states are `running` (no healthcheck). Pre/post core process tables contained only the supervisor
|
||||
and backend server, so Pi was preserved and no Pi process was stopped or steered. The count-only
|
||||
frontend sensitive/error-pattern scan was **0**. No model smoke was run; settings and sessions
|
||||
were intentionally untouched.
|
||||
|
||||
## What ThothII is
|
||||
|
||||
A **human-in-the-loop datamart builder**: it turns a natural-language question into
|
||||
|
||||
@@ -23,16 +23,14 @@
|
||||
closes the old EventSource and opens the same session URL again. Failed Resume must not reconnect.
|
||||
- SSE endpoints are intentionally keep-alive. Browser cleanup and one-off probes must explicitly
|
||||
close the EventSource or cancel/abort the response reader after their terminal event.
|
||||
- `activityLog` remains the complete in-memory chronological fold of prompt, thinking, assistant,
|
||||
sanitized tool lifecycle, reviewer gates, status, and turn lifecycle. The left Model activity
|
||||
panel is a strict projection of only thinking and status; prompt, gate, assistant, tool,
|
||||
lifecycle, and unknown future kinds are hidden. While the model is working, the central body
|
||||
projects every non-blank assistant transcript line into one bounded, accessible scrolling log;
|
||||
user-entry echoes, timer/spinner labels, and step messages are not rendered there. Reviewer
|
||||
widgets, artifacts, store folds, and workflow state continue to consume their existing events.
|
||||
- F6 CTE cards keep their existing semantic structure and responsive grids while using 8 px
|
||||
vertical padding, 12 px lateral padding below `sm`, and 16 px lateral padding from `sm` upward
|
||||
for headers, content, table rows, and filter rows. Divider top padding is 8 px.
|
||||
- `activityLog` remains the complete in-memory chronological fold. The left Model activity panel
|
||||
default-denies every kind except prompt, thinking, and assistant, presenting them as Question,
|
||||
Reasoning, and Response in source order; status, tool, gate, lifecycle, and unknown kinds stay
|
||||
hidden. Its desktop width is pointer/keyboard resizable from 288–576 px while preserving 512 px
|
||||
centrally, persists globally in localStorage, and becomes an overlay drawer below `lg`.
|
||||
- F6 CTE cards keep their semantic structure, responsive grids, 12/16 px lateral padding, and 8 px
|
||||
header/content edge padding. Internal section gaps are 12 px, headings/dividers use 4 px spacing,
|
||||
and table/filter rows use 4 px vertical padding with compact line heights.
|
||||
- Pi tool events may cross the backend/client boundary only as call id, tool name, and
|
||||
`running`/`completed`/`failed` status. Tool updates, arguments, partial/final results, commands,
|
||||
raw output, and raw errors remain server-side.
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { act, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
@@ -6,6 +6,7 @@ import { server } from "../test/msw";
|
||||
import { FakeEventSource } from "../test/fakeEventSource";
|
||||
import { AppShell } from "./AppShell";
|
||||
import { useSessionStore } from "../store/sessionStore";
|
||||
import { ACTIVITY_PANEL_STORAGE_KEY } from "./useActivityPanelResize";
|
||||
|
||||
function wrap() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
@@ -20,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; });
|
||||
@@ -27,9 +45,13 @@ 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)),
|
||||
@@ -711,11 +733,12 @@ test("closing and reopening Model activity preserves the complete activity log",
|
||||
await userEvent.click(screen.getByRole("button", { name: /model activity/i }));
|
||||
const panel = (await screen.findByRole("heading", { name: /model activity/i })).closest("aside");
|
||||
expect(panel).not.toBeNull();
|
||||
expect(panel).toHaveTextContent("Inspect patient cohort");
|
||||
expect(panel).toHaveTextContent("Inspecting cohort");
|
||||
expect(within(panel!).getByText("cohort").tagName).toBe("STRONG");
|
||||
expect(within(panel!).getByText("Cohort status")).toBeInTheDocument();
|
||||
expect(within(panel!).queryByText("Inspect patient cohort")).not.toBeInTheDocument();
|
||||
expect(within(panel!).getByText("Cohort ready")).toBeInTheDocument();
|
||||
expect(within(panel!).queryByText("Cohort status")).not.toBeInTheDocument();
|
||||
expect(within(panel!).queryByText("bash")).not.toBeInTheDocument();
|
||||
expect(within(panel!).queryByText("Cohort ready")).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /close model activity/i }));
|
||||
expect(screen.queryByRole("heading", { name: /model activity/i })).not.toBeInTheDocument();
|
||||
@@ -724,11 +747,12 @@ test("closing and reopening Model activity preserves the complete activity log",
|
||||
await userEvent.click(screen.getByRole("button", { name: /show model activity/i }));
|
||||
const reopened = (await screen.findByRole("heading", { name: /model activity/i })).closest("aside");
|
||||
expect(reopened).not.toBeNull();
|
||||
expect(reopened).toHaveTextContent("Inspect patient cohort");
|
||||
expect(reopened).toHaveTextContent("Inspecting cohort");
|
||||
expect(within(reopened!).getByText("cohort").tagName).toBe("STRONG");
|
||||
expect(within(reopened!).getByText("Cohort status")).toBeInTheDocument();
|
||||
expect(within(reopened!).queryByText("Inspect patient cohort")).not.toBeInTheDocument();
|
||||
expect(within(reopened!).getByText("Cohort ready")).toBeInTheDocument();
|
||||
expect(within(reopened!).queryByText("Cohort status")).not.toBeInTheDocument();
|
||||
expect(within(reopened!).queryByText("bash")).not.toBeInTheDocument();
|
||||
expect(within(reopened!).queryByText("Cohort ready")).not.toBeInTheDocument();
|
||||
expect(useSessionStore.getState().activityLog).toEqual(beforeClose);
|
||||
});
|
||||
|
||||
@@ -777,21 +801,103 @@ test("renaming a group reassigns its members via setSessionGroup", async () => {
|
||||
});
|
||||
|
||||
|
||||
test("opening Model activity replaces the session rail with a 40/60 activity and chat layout", async () => {
|
||||
test("opens an accessible resizable activity split and persists pointer width", async () => {
|
||||
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 }));
|
||||
|
||||
expect(screen.getByText("Sessions")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: /show model activity/i }));
|
||||
|
||||
const shell = screen.getByTestId("app-shell");
|
||||
expect(shell).toHaveAttribute("data-activity-layout", "open");
|
||||
expect(screen.getByRole("heading", { name: "Model activity" }).closest("aside")).toHaveClass("w-2/5");
|
||||
expect(screen.queryByText("Sessions")).not.toBeInTheDocument();
|
||||
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");
|
||||
expect(separator).toHaveAttribute("aria-valuemin", "288");
|
||||
expect(separator).toHaveAttribute("aria-valuenow", "384");
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /hide model activity/i }));
|
||||
expect(shell).toHaveAttribute("data-activity-layout", "closed");
|
||||
expect(screen.getByText("Sessions")).toBeInTheDocument();
|
||||
Object.assign(separator, {
|
||||
setPointerCapture: vi.fn(),
|
||||
releasePointerCapture: vi.fn(),
|
||||
hasPointerCapture: vi.fn(() => true),
|
||||
});
|
||||
fireEvent.pointerDown(separator, { pointerId: 1, clientX: 384 });
|
||||
fireEvent.pointerMove(separator, { pointerId: 1, clientX: 484 });
|
||||
fireEvent.pointerUp(separator, { pointerId: 1, clientX: 484 });
|
||||
|
||||
expect(shell.style.getPropertyValue("--activity-panel-width")).toBe("484px");
|
||||
expect(localStorage.getItem(ACTIVITY_PANEL_STORAGE_KEY)).toBe("484");
|
||||
expect(screen.getByTestId("conversation-column")).toHaveClass("flex-1");
|
||||
});
|
||||
|
||||
test("resizes the activity split with keyboard and exposes responsive drawer classes", async () => {
|
||||
localStorage.setItem(ACTIVITY_PANEL_STORAGE_KEY, "448");
|
||||
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(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" });
|
||||
expect(shell.style.getPropertyValue("--activity-panel-width")).toBe("432px");
|
||||
fireEvent.keyDown(separator, { key: "ArrowRight", shiftKey: true });
|
||||
expect(shell.style.getPropertyValue("--activity-panel-width")).toBe("480px");
|
||||
fireEvent.keyDown(separator, { key: "Home" });
|
||||
expect(shell.style.getPropertyValue("--activity-panel-width")).toBe("288px");
|
||||
|
||||
const panel = screen.getByRole("heading", { name: "Model activity" }).closest("aside");
|
||||
expect(panel).toHaveClass(
|
||||
"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");
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useSessionStore } from "../store/sessionStore";
|
||||
import { WidgetHost } from "./WidgetHost";
|
||||
import { CentralStatus } from "./CentralStatus";
|
||||
import { ModelActivityPanel } from "./ModelActivityPanel";
|
||||
import { useActivityPanelResize } from "./useActivityPanelResize";
|
||||
import { NavSessions } from "./NavSessions";
|
||||
import { SessionMenu } from "./SessionMenu";
|
||||
import { SessionDocumentsPanel } from "./SessionDocumentsPanel";
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
import type { SessionSummary } from "../api/types";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
/**
|
||||
* The page is designed to live INSIDE the Omics Portal chrome (its left sidebar
|
||||
@@ -31,6 +33,10 @@ import { useEffect, useMemo, useRef, useState } 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 activityWidthStyle = {
|
||||
"--activity-panel-width": `${panelWidth}px`,
|
||||
} as CSSProperties;
|
||||
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
||||
const activeSessionIdRef = useRef<string | null>(null);
|
||||
const resumeInvocationRef = useRef(0);
|
||||
@@ -364,9 +370,15 @@ export function AppShell() {
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={activityWidthStyle}
|
||||
data-testid="app-shell"
|
||||
data-activity-layout={showActivity ? "open" : "closed"}
|
||||
className="flex h-screen bg-background text-foreground"
|
||||
data-activity-resizing={resizing}
|
||||
className={[
|
||||
"flex h-screen bg-background text-foreground",
|
||||
resizing ? "select-none cursor-col-resize" : "",
|
||||
].join(" ")}
|
||||
>
|
||||
{panelSession && (
|
||||
<SessionDocumentsPanel
|
||||
@@ -375,10 +387,22 @@ export function AppShell() {
|
||||
onResume={doResume}
|
||||
/>
|
||||
)}
|
||||
{showActivity && <ModelActivityPanel onClose={() => setShowActivity(false)} />}
|
||||
{showActivity && <ModelActivityPanel desktopSplit={desktopSplit} onClose={() => setShowActivity(false)} />}
|
||||
{showActivity && desktopSplit && (
|
||||
<div className="relative w-0 shrink-0">
|
||||
<div
|
||||
{...separatorProps}
|
||||
data-testid="activity-resize-handle"
|
||||
data-dragging={resizing}
|
||||
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" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Conversation column */}
|
||||
<div className={["flex min-w-0 flex-col", showActivity ? "w-3/5 shrink-0" : "flex-1"].join(" ")}>
|
||||
<div data-testid="conversation-column" className="flex min-w-0 flex-1 flex-col">
|
||||
{activeSessionId && (
|
||||
<div className="relative shrink-0 border-b border-border/70 bg-background/80 px-6 py-2.5 backdrop-blur">
|
||||
{/* Arrow toggle for the left Model-activity panel: → opens it, ← closes it. */}
|
||||
|
||||
@@ -9,18 +9,17 @@ import { ModelActivityPanel } from "./ModelActivityPanel";
|
||||
|
||||
beforeEach(() => useSessionStore.getState().resetSession());
|
||||
|
||||
test("renders only thinking and status from a mixed F1 sequence", () => {
|
||||
test("renders question, reasoning, and response in chronological order", () => {
|
||||
const store = useSessionStore.getState();
|
||||
store.setPhase("F1");
|
||||
store.setLastUserEntry({ kind: "input", text: "How many patients?" });
|
||||
store.applyEvent({ type: "system_event", event: "agent_start" });
|
||||
store.setLastUserEntry({ kind: "input", text: "How many **patients**?" });
|
||||
store.applyEvent({ type: "info", level: "warning", text: "Retrying schema lookup" });
|
||||
store.applyEvent({ type: "activity_delta", text: "Inspecting **schema**." });
|
||||
store.applyEvent({ type: "text_delta", text: "I found *42* patients." });
|
||||
store.applyEvent({
|
||||
type: "activity_event",
|
||||
activity: { kind: "tool", toolCallId: "tool-1", toolName: "bash", status: "running" },
|
||||
activity: { kind: "tool", toolCallId: "tool-1", toolName: "bash", status: "completed" },
|
||||
});
|
||||
store.applyEvent({ type: "text_delta", text: "Let me run another bash search." });
|
||||
store.applyEvent({ type: "activity_delta", text: "Inspecting **schema**." });
|
||||
store.applyEvent({ type: "info", level: "warning", text: "Retrying schema lookup" });
|
||||
store.applyEvent({
|
||||
type: "ui_request",
|
||||
ui_request: { id: "gate-1", widget: "select", phase: "F1_review", title: "Confirm cohort" },
|
||||
@@ -28,16 +27,36 @@ test("renders only thinking and status from a mixed F1 sequence", () => {
|
||||
|
||||
render(<ModelActivityPanel onClose={vi.fn()} />);
|
||||
|
||||
expect(screen.getAllByRole("article")).toHaveLength(2);
|
||||
expect(screen.getByText("schema").tagName).toBe("STRONG");
|
||||
expect(screen.getByText("Retrying schema lookup")).toBeInTheDocument();
|
||||
expect(screen.queryByText("How many patients?")).not.toBeInTheDocument();
|
||||
const rows = screen.getAllByRole("article");
|
||||
expect(rows).toHaveLength(3);
|
||||
expect(within(rows[0]).getByText("Question")).toBeInTheDocument();
|
||||
expect(within(rows[0]).getByText("patients").tagName).toBe("STRONG");
|
||||
expect(within(rows[1]).getByText("Reasoning")).toBeInTheDocument();
|
||||
expect(within(rows[1]).getByText("schema").tagName).toBe("STRONG");
|
||||
expect(within(rows[2]).getByText("Response")).toBeInTheDocument();
|
||||
expect(within(rows[2]).getByText("42").tagName).toBe("EM");
|
||||
expect(screen.queryByText("Retrying schema lookup")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Confirm cohort")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("bash")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Let me run another bash search.")).not.toBeInTheDocument();
|
||||
expect(useSessionStore.getState().transcript).toEqual([
|
||||
{ role: "assistant", text: "Let me run another bash search." },
|
||||
]);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
@@ -47,10 +66,10 @@ test("uses an explicit default-deny activity-kind allowlist", () => {
|
||||
expect(predicate).toBeTypeOf("function");
|
||||
if (!predicate) return;
|
||||
|
||||
for (const kind of ["thinking", "status"] satisfies ActivityKind[]) {
|
||||
for (const kind of ["prompt", "thinking", "assistant"] satisfies ActivityKind[]) {
|
||||
expect(predicate({ kind, phase: "F1", text: kind })).toBe(true);
|
||||
}
|
||||
for (const kind of ["prompt", "gate", "assistant", "tool", "lifecycle"] satisfies ActivityKind[]) {
|
||||
for (const kind of ["status", "gate", "tool", "lifecycle"] satisfies ActivityKind[]) {
|
||||
expect(predicate({ kind, phase: "F1", text: kind })).toBe(false);
|
||||
}
|
||||
expect(predicate({ kind: "future-kind" as ActivityKind, phase: "F1", text: "future" })).toBe(false);
|
||||
@@ -59,9 +78,8 @@ test("uses an explicit default-deny activity-kind allowlist", () => {
|
||||
test("shows the empty state when the raw log contains only hidden entries", () => {
|
||||
useSessionStore.setState({
|
||||
activityLog: [
|
||||
{ kind: "prompt", phase: "F1", text: "How many patients?" },
|
||||
{ kind: "status", phase: "F1", text: "Status" },
|
||||
{ kind: "gate", phase: "F1", text: "Confirm cohort" },
|
||||
{ kind: "assistant", phase: "F1", text: "Let me try another command." },
|
||||
{ kind: "tool", phase: "F1", text: "bash", toolCallId: "tool-1", status: "completed" },
|
||||
{ kind: "lifecycle", phase: "F1", text: "Turn end" },
|
||||
],
|
||||
@@ -75,7 +93,7 @@ test("shows the empty state when the raw log contains only hidden entries", () =
|
||||
|
||||
test("keeps all timeline entries visible without an expansion control", () => {
|
||||
const activityLog: ActivityEntry[] = Array.from({ length: 8 }, (_, index) => ({
|
||||
kind: "status",
|
||||
kind: "thinking",
|
||||
phase: `F${index + 1}`,
|
||||
text: `timeline-${index + 1}`,
|
||||
}));
|
||||
@@ -87,39 +105,10 @@ test("keeps all timeline entries visible without an expansion control", () => {
|
||||
expect(screen.queryByRole("button", { name: /show (more|less)/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("labels a warning in text instead of relying on its semantic color", () => {
|
||||
useSessionStore.setState({
|
||||
activityLog: [
|
||||
{ kind: "status", phase: "F3", text: "Retrying retrieval", level: "warning" },
|
||||
],
|
||||
});
|
||||
|
||||
render(<ModelActivityPanel onClose={vi.fn()} />);
|
||||
|
||||
const row = screen.getByText("Retrying retrieval").closest("article");
|
||||
expect(row).not.toBeNull();
|
||||
expect(within(row!).getByText("warning")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders thinking markdown without assistant narration", () => {
|
||||
useSessionStore.setState({
|
||||
activityLog: [
|
||||
{ kind: "thinking", phase: "F2", text: "Considering **history**." },
|
||||
{ kind: "assistant", phase: "F2", text: "Use *current* records." },
|
||||
],
|
||||
});
|
||||
|
||||
render(<ModelActivityPanel onClose={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText("history").tagName).toBe("STRONG");
|
||||
expect(screen.queryByText("current")).not.toBeInTheDocument();
|
||||
expect(screen.getAllByRole("article")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("close calls onClose without mutating the activity log", async () => {
|
||||
const onClose = vi.fn();
|
||||
const activityLog: ActivityEntry[] = [
|
||||
{ kind: "status", phase: "F1", text: "Keep this row" },
|
||||
{ kind: "thinking", phase: "F1", text: "Keep this row" },
|
||||
];
|
||||
useSessionStore.setState({ activityLog });
|
||||
render(<ModelActivityPanel onClose={onClose} />);
|
||||
@@ -148,7 +137,7 @@ function setScrollGeometry(
|
||||
});
|
||||
}
|
||||
|
||||
test("follows appended visible activity while near the bottom", () => {
|
||||
test("follows appended visible response while near the bottom", () => {
|
||||
useSessionStore.setState({
|
||||
activityLog: [{ kind: "thinking", phase: "F1", text: "First" }],
|
||||
});
|
||||
@@ -156,10 +145,8 @@ test("follows appended visible activity while near the bottom", () => {
|
||||
const viewport = screen.getByTestId("activity-scroll");
|
||||
setScrollGeometry(viewport, { scrollHeight: 400, clientHeight: 100, scrollTop: 300 });
|
||||
|
||||
act(() => useSessionStore.getState().applyEvent({ type: "info", text: "Second" }));
|
||||
|
||||
act(() => useSessionStore.getState().applyEvent({ type: "text_delta", text: "Second" }));
|
||||
expect(viewport.scrollTop).toBe(400);
|
||||
expect(viewport).toHaveAttribute("tabindex", "0");
|
||||
});
|
||||
|
||||
test("preserves manual scroll position when visible activity arrives", () => {
|
||||
@@ -171,20 +158,19 @@ test("preserves manual scroll position when visible activity arrives", () => {
|
||||
setScrollGeometry(viewport, { scrollHeight: 400, clientHeight: 100, scrollTop: 100 });
|
||||
fireEvent.scroll(viewport);
|
||||
|
||||
act(() => useSessionStore.getState().applyEvent({ type: "info", text: "Second" }));
|
||||
act(() => useSessionStore.getState().applyEvent({ type: "text_delta", text: "Second" }));
|
||||
|
||||
expect(viewport.scrollTop).toBe(100);
|
||||
});
|
||||
|
||||
test("does not bottom-follow when only a hidden prompt arrives", () => {
|
||||
test("does not bottom-follow when only a hidden status arrives", () => {
|
||||
useSessionStore.setState({
|
||||
activityLog: [{ kind: "status", phase: "F1", text: "Visible status" }],
|
||||
activityLog: [{ kind: "thinking", phase: "F1", text: "Visible reasoning" }],
|
||||
});
|
||||
render(<ModelActivityPanel onClose={vi.fn()} />);
|
||||
const viewport = screen.getByTestId("activity-scroll");
|
||||
setScrollGeometry(viewport, { scrollHeight: 400, clientHeight: 100, scrollTop: 300 });
|
||||
|
||||
act(() => useSessionStore.getState().setLastUserEntry({ kind: "input", text: "Hidden prompt" }));
|
||||
|
||||
act(() => useSessionStore.getState().applyEvent({ type: "info", text: "Hidden status" }));
|
||||
expect(viewport.scrollTop).toBe(300);
|
||||
});
|
||||
|
||||
@@ -8,10 +8,17 @@ import { Button } from "../components/ui/button";
|
||||
import { isNearBottom } from "./activityScroll";
|
||||
|
||||
const VISIBLE_MODEL_ACTIVITY_KINDS: ReadonlySet<ActivityKind> = new Set([
|
||||
"prompt",
|
||||
"thinking",
|
||||
"status",
|
||||
"assistant",
|
||||
]);
|
||||
|
||||
const ACTIVITY_LABELS: Readonly<Partial<Record<ActivityKind, string>>> = {
|
||||
prompt: "Question",
|
||||
thinking: "Reasoning",
|
||||
assistant: "Response",
|
||||
};
|
||||
|
||||
export function isVisibleModelActivity(entry: ActivityEntry): boolean {
|
||||
return VISIBLE_MODEL_ACTIVITY_KINDS.has(entry.kind);
|
||||
}
|
||||
@@ -30,66 +37,32 @@ 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>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityBody({ entry }: { entry: ActivityEntry }) {
|
||||
if (entry.kind === "assistant" || entry.kind === "thinking") {
|
||||
return <MarkdownBody entry={entry} />;
|
||||
}
|
||||
|
||||
if (entry.kind === "status") {
|
||||
const tone =
|
||||
entry.level === "error"
|
||||
? "text-destructive"
|
||||
: entry.level === "warning"
|
||||
? "text-amber-600"
|
||||
: "text-foreground";
|
||||
return <p className={`whitespace-pre-wrap break-words text-sm leading-relaxed ${tone}`}>{entry.text}</p>;
|
||||
}
|
||||
|
||||
if (entry.kind === "lifecycle") {
|
||||
return <p className="whitespace-pre-wrap break-words text-sm leading-relaxed text-muted-foreground">{entry.text}</p>;
|
||||
}
|
||||
|
||||
return <p className="whitespace-pre-wrap break-words text-sm leading-relaxed text-foreground">{entry.text}</p>;
|
||||
}
|
||||
|
||||
function ActivityRow({ entry }: { entry: ActivityEntry }) {
|
||||
return (
|
||||
<article className="border-b border-border/50 py-3 last:border-b-0">
|
||||
<div className="mb-1.5 flex flex-wrap items-center gap-1.5 text-[0.65rem] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
|
||||
{entry.phase && <span>{entry.phase}</span>}
|
||||
<span>{entry.kind}</span>
|
||||
{entry.status && (
|
||||
<span className={entry.status === "failed" ? "text-destructive" : undefined}>
|
||||
{entry.status}
|
||||
</span>
|
||||
)}
|
||||
{entry.level && (
|
||||
<span
|
||||
className={
|
||||
entry.level === "error"
|
||||
? "text-destructive"
|
||||
: entry.level === "warning"
|
||||
? "text-amber-600"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{entry.level}
|
||||
</span>
|
||||
)}
|
||||
<article className="border-b border-border/50 py-2 last:border-b-0">
|
||||
<div className="mb-1 flex flex-wrap items-center gap-1 text-[0.65rem] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
|
||||
<span className="text-foreground/80">{ACTIVITY_LABELS[entry.kind]}</span>
|
||||
{entry.phase && <span aria-label={`Phase ${entry.phase}`}>{entry.phase}</span>}
|
||||
</div>
|
||||
<ActivityBody entry={entry} />
|
||||
<MarkdownBody entry={entry} />
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
@@ -103,8 +76,11 @@ export function ModelActivityPanel({ onClose }: { onClose: () => void }) {
|
||||
}, [visibleCount, visibleTail]);
|
||||
|
||||
return (
|
||||
<aside className="flex w-2/5 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-3">
|
||||
<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}>
|
||||
<X className="size-4" />
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
ACTIVITY_PANEL_DEFAULT_WIDTH,
|
||||
ACTIVITY_PANEL_STORAGE_KEY,
|
||||
clampActivityPanelWidth,
|
||||
getActivityPanelBounds,
|
||||
loadActivityPanelWidth,
|
||||
resizeWidthFromKey,
|
||||
saveActivityPanelWidth,
|
||||
} from "./useActivityPanelResize";
|
||||
|
||||
describe("activity panel resize contract", () => {
|
||||
test("computes desktop bounds while preserving 512px centrally", () => {
|
||||
expect(getActivityPanelBounds(1200)).toEqual({ min: 288, max: 576 });
|
||||
expect(getActivityPanelBounds(1024)).toEqual({ min: 288, max: 512 });
|
||||
expect(getActivityPanelBounds(800)).toEqual({ min: 288, max: 288 });
|
||||
});
|
||||
|
||||
test("clamps finite values and falls back for invalid values", () => {
|
||||
const bounds = { min: 288, max: 576 };
|
||||
expect(clampActivityPanelWidth(200, bounds)).toBe(288);
|
||||
expect(clampActivityPanelWidth(420, bounds)).toBe(420);
|
||||
expect(clampActivityPanelWidth(900, bounds)).toBe(576);
|
||||
expect(clampActivityPanelWidth(Number.NaN, bounds)).toBe(ACTIVITY_PANEL_DEFAULT_WIDTH);
|
||||
});
|
||||
|
||||
test("maps keyboard controls to exact clamped increments", () => {
|
||||
const bounds = { min: 288, max: 576 };
|
||||
expect(resizeWidthFromKey(384, "ArrowLeft", false, bounds)).toBe(368);
|
||||
expect(resizeWidthFromKey(384, "ArrowRight", true, bounds)).toBe(432);
|
||||
expect(resizeWidthFromKey(384, "Home", false, bounds)).toBe(288);
|
||||
expect(resizeWidthFromKey(384, "End", false, bounds)).toBe(576);
|
||||
expect(resizeWidthFromKey(384, "Enter", false, bounds)).toBeNull();
|
||||
});
|
||||
|
||||
test("loads, persists, and rejects corrupt browser values", () => {
|
||||
localStorage.clear();
|
||||
expect(loadActivityPanelWidth(localStorage)).toBe(ACTIVITY_PANEL_DEFAULT_WIDTH);
|
||||
localStorage.setItem(ACTIVITY_PANEL_STORAGE_KEY, "corrupt");
|
||||
expect(loadActivityPanelWidth(localStorage)).toBe(ACTIVITY_PANEL_DEFAULT_WIDTH);
|
||||
saveActivityPanelWidth(448, localStorage);
|
||||
expect(loadActivityPanelWidth(localStorage)).toBe(448);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
import { useCallback, useLayoutEffect, useRef, useState } from "react";
|
||||
import type { KeyboardEventHandler, PointerEventHandler, RefObject } from "react";
|
||||
|
||||
export const ACTIVITY_PANEL_DEFAULT_WIDTH = 384;
|
||||
export const ACTIVITY_PANEL_MIN_WIDTH = 288;
|
||||
export const ACTIVITY_PANEL_MAX_WIDTH = 576;
|
||||
export const ACTIVITY_CENTRAL_MIN_WIDTH = 512;
|
||||
export const ACTIVITY_PANEL_STORAGE_KEY = "thothii:model-activity-panel-width";
|
||||
|
||||
export type ActivityPanelBounds = { min: number; max: number };
|
||||
|
||||
export function getActivityPanelBounds(containerWidth: number): ActivityPanelBounds {
|
||||
return {
|
||||
min: ACTIVITY_PANEL_MIN_WIDTH,
|
||||
max: Math.max(
|
||||
ACTIVITY_PANEL_MIN_WIDTH,
|
||||
Math.min(ACTIVITY_PANEL_MAX_WIDTH, containerWidth - ACTIVITY_CENTRAL_MIN_WIDTH),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function clampActivityPanelWidth(width: number, bounds: ActivityPanelBounds): number {
|
||||
const candidate = Number.isFinite(width) ? width : ACTIVITY_PANEL_DEFAULT_WIDTH;
|
||||
return Math.min(bounds.max, Math.max(bounds.min, candidate));
|
||||
}
|
||||
|
||||
export function loadActivityPanelWidth(storage: Pick<Storage, "getItem"> | null): number {
|
||||
if (!storage) return ACTIVITY_PANEL_DEFAULT_WIDTH;
|
||||
try {
|
||||
const raw = storage.getItem(ACTIVITY_PANEL_STORAGE_KEY);
|
||||
if (raw === null || raw.trim() === "") return ACTIVITY_PANEL_DEFAULT_WIDTH;
|
||||
const parsed = Number(raw);
|
||||
return Number.isFinite(parsed) ? parsed : ACTIVITY_PANEL_DEFAULT_WIDTH;
|
||||
} catch {
|
||||
return ACTIVITY_PANEL_DEFAULT_WIDTH;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveActivityPanelWidth(
|
||||
width: number,
|
||||
storage: Pick<Storage, "setItem"> | null,
|
||||
): void {
|
||||
if (!storage) return;
|
||||
try {
|
||||
storage.setItem(ACTIVITY_PANEL_STORAGE_KEY, String(Math.round(width)));
|
||||
} catch {
|
||||
// Storage can be unavailable in privacy-restricted browser contexts.
|
||||
}
|
||||
}
|
||||
|
||||
function getBrowserStorage(): Storage | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function resizeWidthFromKey(
|
||||
width: number,
|
||||
key: string,
|
||||
shiftKey: boolean,
|
||||
bounds: ActivityPanelBounds,
|
||||
): number | null {
|
||||
const step = shiftKey ? 48 : 16;
|
||||
if (key === "ArrowLeft") return clampActivityPanelWidth(width - step, bounds);
|
||||
if (key === "ArrowRight") return clampActivityPanelWidth(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 useActivityPanelResize(): {
|
||||
containerRef: RefObject<HTMLDivElement>;
|
||||
panelWidth: number;
|
||||
desktopSplit: boolean;
|
||||
resizing: boolean;
|
||||
separatorProps: {
|
||||
role: "separator";
|
||||
tabIndex: 0;
|
||||
"aria-label": string;
|
||||
"aria-orientation": "vertical";
|
||||
"aria-valuemin": number;
|
||||
"aria-valuemax": number;
|
||||
"aria-valuenow": number;
|
||||
onPointerDown: PointerEventHandler<HTMLDivElement>;
|
||||
onPointerMove: PointerEventHandler<HTMLDivElement>;
|
||||
onPointerUp: PointerEventHandler<HTMLDivElement>;
|
||||
onPointerCancel: PointerEventHandler<HTMLDivElement>;
|
||||
onKeyDown: KeyboardEventHandler<HTMLDivElement>;
|
||||
};
|
||||
} {
|
||||
const storageRef = useRef<Storage | null>(getBrowserStorage());
|
||||
const initialWidthRef = useRef(loadActivityPanelWidth(storageRef.current));
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const widthRef = useRef(initialWidthRef.current);
|
||||
const boundsRef = useRef<ActivityPanelBounds>({
|
||||
min: ACTIVITY_PANEL_MIN_WIDTH,
|
||||
max: ACTIVITY_PANEL_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 = clampActivityPanelWidth(rawWidth, boundsRef.current);
|
||||
widthRef.current = next;
|
||||
setPanelWidth(next);
|
||||
if (persist) saveActivityPanelWidth(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 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);
|
||||
};
|
||||
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 () => {
|
||||
observer?.disconnect();
|
||||
media?.removeEventListener?.("change", syncLayout);
|
||||
window.removeEventListener("resize", syncLayout);
|
||||
};
|
||||
}, [applyWidth]);
|
||||
|
||||
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 = resizeWidthFromKey(widthRef.current, event.key, event.shiftKey, boundsRef.current);
|
||||
if (next === null) return;
|
||||
event.preventDefault();
|
||||
applyWidth(next, true);
|
||||
};
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
panelWidth,
|
||||
desktopSplit,
|
||||
resizing,
|
||||
separatorProps: {
|
||||
role: "separator",
|
||||
tabIndex: 0,
|
||||
"aria-label": "Resize model activity",
|
||||
"aria-orientation": "vertical",
|
||||
"aria-valuemin": bounds.min,
|
||||
"aria-valuemax": bounds.max,
|
||||
"aria-valuenow": panelWidth,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
onPointerCancel,
|
||||
onKeyDown,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -68,19 +68,15 @@ test("renders strategy, question, and a chain strip with CTE names in order", ()
|
||||
expect(screen.getAllByText("impianti_recenti").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("uses compact vertical and responsive lateral CTE card padding", () => {
|
||||
test("uses compact CTE section rhythm with moderate lateral padding", () => {
|
||||
const { container } = render(<CtePlanViewer plan={plan} />);
|
||||
|
||||
for (const header of container.querySelectorAll('[data-slot="card-header"]')) {
|
||||
expect(header).toHaveClass("px-3", "py-2", "sm:px-4", "[&.border-b]:pb-2");
|
||||
expect(header).not.toHaveClass("p-4", "sm:p-5", "sm:[&.border-b]:pb-5");
|
||||
expect(header).toHaveClass("gap-2", "px-3", "py-2", "sm:px-4", "[&.border-b]:pb-2");
|
||||
expect(header).not.toHaveClass("gap-3", "p-4", "sm:p-5");
|
||||
}
|
||||
for (const content of container.querySelectorAll('[data-slot="card-content"]')) {
|
||||
expect(content).toHaveClass("px-3", "py-2", "sm:px-4");
|
||||
expect(content).not.toHaveClass("p-4", "sm:p-5");
|
||||
}
|
||||
for (const card of container.querySelectorAll('[data-slot="card"]')) {
|
||||
expect(card).toHaveClass("shadow-none");
|
||||
expect(content).toHaveClass("gap-3", "px-3", "py-2", "sm:px-4");
|
||||
expect(content).not.toHaveClass("gap-5", "p-4", "sm:p-5");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -142,7 +138,7 @@ test("groups filters in one bordered divided list with accessible padded rows",
|
||||
expect(list).toHaveClass("border", "divide-y");
|
||||
for (const filter of filters) {
|
||||
expect(filter.parentElement).toBe(list);
|
||||
expect(filter).toHaveClass("px-3", "py-2", "sm:px-4");
|
||||
expect(filter).toHaveClass("px-3", "py-1", "sm:px-4");
|
||||
expect(filter).not.toHaveClass("p-3", "sm:p-4");
|
||||
}
|
||||
lists.push(list!);
|
||||
@@ -161,22 +157,23 @@ test("renders rationale as a plain top-divided section", () => {
|
||||
expect(rationale).not.toHaveClass("border-l-2", "bg-primary/5", "rounded-lg");
|
||||
});
|
||||
|
||||
test("uses the same compact padding for table rows and rationale dividers", () => {
|
||||
test("compacts table, filter, detail, and rationale rows", () => {
|
||||
render(<CtePlanViewer plan={plan} />);
|
||||
const card = getCteCard("pazienti_idonei");
|
||||
const tableName = within(card).getByText("pazienti");
|
||||
expect(tableName.parentElement).toHaveClass("px-3", "py-2", "sm:px-4");
|
||||
expect(tableName.parentElement).not.toHaveClass("p-3", "sm:p-4");
|
||||
const tableRow = within(card).getByText("pazienti").parentElement;
|
||||
expect(tableRow).toHaveClass("px-3", "py-1", "sm:px-4");
|
||||
|
||||
const filterRationale = within(card).getByText("Solo pazienti in follow-up").parentElement;
|
||||
expect(filterRationale).toHaveClass("pt-2");
|
||||
expect(filterRationale).not.toHaveClass("pt-3");
|
||||
const filter = within(card).getByRole("group", { name: "Filter 1" });
|
||||
expect(filter).toHaveClass("space-y-2", "px-3", "py-1", "sm:px-4");
|
||||
expect(filter.firstElementChild).toHaveClass("gap-2");
|
||||
const filterDetails = within(card).getByText("Solo pazienti in follow-up").parentElement;
|
||||
expect(filterDetails).toHaveClass("gap-y-1", "pt-1");
|
||||
|
||||
const cteRationale = within(card)
|
||||
const rationale = within(card)
|
||||
.getByText("Base della catena: riduce il volume prima dei join")
|
||||
.closest("section");
|
||||
expect(cteRationale).toHaveClass("border-t", "pt-2");
|
||||
expect(cteRationale).not.toHaveClass("pt-4");
|
||||
expect(rationale).toHaveClass("pt-1");
|
||||
expect(within(rationale!).getByText("Rationale")).toHaveClass("mb-1");
|
||||
});
|
||||
|
||||
test("shows 'no dependencies' for a CTE with an empty depends_on", () => {
|
||||
|
||||
@@ -16,34 +16,34 @@ function FilterRow({ filter, index }: { filter: CtePlanFilter; index: number })
|
||||
<div
|
||||
role="group"
|
||||
aria-label={`Filter ${index}`}
|
||||
className="min-w-0 space-y-3 px-3 py-2 sm:px-4"
|
||||
className="min-w-0 space-y-2 px-3 py-1 sm:px-4"
|
||||
>
|
||||
<div className="grid min-w-0 grid-cols-1 gap-3 md:grid-cols-[minmax(0,1fr)_minmax(5rem,auto)_minmax(0,1fr)]">
|
||||
<div className="grid min-w-0 grid-cols-1 gap-2 md:grid-cols-[minmax(0,1fr)_minmax(5rem,auto)_minmax(0,1fr)]">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p className={FIELD_LABEL}>Column</p>
|
||||
<code className="block min-w-0 whitespace-pre-wrap [overflow-wrap:anywhere] font-mono text-xs leading-relaxed text-foreground">
|
||||
<code className="block min-w-0 whitespace-pre-wrap [overflow-wrap:anywhere] font-mono text-xs leading-4 text-foreground">
|
||||
{filter.column}
|
||||
</code>
|
||||
</div>
|
||||
<div className="min-w-0 space-y-1 md:text-center">
|
||||
<p className={FIELD_LABEL}>Operator</p>
|
||||
<code className="block min-w-0 whitespace-pre-wrap [overflow-wrap:anywhere] font-mono text-xs font-semibold leading-relaxed text-primary">
|
||||
<code className="block min-w-0 whitespace-pre-wrap [overflow-wrap:anywhere] font-mono text-xs font-semibold leading-4 text-primary">
|
||||
{filter.op}
|
||||
</code>
|
||||
</div>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p className={FIELD_LABEL}>Value</p>
|
||||
<code className="block min-w-0 whitespace-pre-wrap [overflow-wrap:anywhere] font-mono text-xs leading-relaxed text-foreground">
|
||||
<code className="block min-w-0 whitespace-pre-wrap [overflow-wrap:anywhere] font-mono text-xs leading-4 text-foreground">
|
||||
{filter.value}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
{(filter.description || filter.rationale) && (
|
||||
<div className="grid min-w-0 gap-x-4 gap-y-2 border-t border-border/50 pt-2 md:grid-cols-[5.5rem_minmax(0,1fr)]">
|
||||
<div className="grid min-w-0 gap-x-4 gap-y-1 border-t border-border/50 pt-1 md:grid-cols-[5.5rem_minmax(0,1fr)]">
|
||||
{filter.description && (
|
||||
<>
|
||||
<span className={FIELD_LABEL}>Description</span>
|
||||
<p className="min-w-0 whitespace-pre-wrap [overflow-wrap:anywhere] text-xs leading-relaxed text-muted-foreground">
|
||||
<p className="min-w-0 whitespace-pre-wrap [overflow-wrap:anywhere] text-xs leading-4 text-muted-foreground">
|
||||
{filter.description}
|
||||
</p>
|
||||
</>
|
||||
@@ -51,7 +51,7 @@ function FilterRow({ filter, index }: { filter: CtePlanFilter; index: number })
|
||||
{filter.rationale && (
|
||||
<>
|
||||
<span className={FIELD_LABEL}>Rationale</span>
|
||||
<p className="min-w-0 whitespace-pre-wrap [overflow-wrap:anywhere] text-xs leading-relaxed text-muted-foreground">
|
||||
<p className="min-w-0 whitespace-pre-wrap [overflow-wrap:anywhere] text-xs leading-4 text-muted-foreground">
|
||||
{filter.rationale}
|
||||
</p>
|
||||
</>
|
||||
@@ -67,25 +67,25 @@ function CteCard({ cte, total }: { cte: CtePlanCte; total: number }) {
|
||||
|
||||
return (
|
||||
<Card className="gap-0 rounded-lg border py-0 shadow-none">
|
||||
<CardHeader className="gap-3 rounded-t-lg border-b border-border/60 bg-muted/15 px-3 py-2 sm:px-4 [&.border-b]:pb-2">
|
||||
<CardHeader className="gap-2 rounded-t-lg border-b border-border/60 bg-muted/15 px-3 py-2 sm:px-4 [&.border-b]:pb-2">
|
||||
<div className="grid min-w-0 grid-cols-[auto_minmax(0,1fr)] items-center gap-2.5">
|
||||
<Badge variant="outline" className="font-mono">
|
||||
CTE {cte.index}/{total}
|
||||
</Badge>
|
||||
<h3 className="min-w-0 whitespace-pre-wrap [overflow-wrap:anywhere] font-mono text-sm font-medium leading-relaxed">
|
||||
<h3 className="min-w-0 whitespace-pre-wrap [overflow-wrap:anywhere] font-mono text-sm font-medium leading-5">
|
||||
{cte.name}
|
||||
</h3>
|
||||
</div>
|
||||
{cte.purpose && (
|
||||
<p className="min-w-0 whitespace-pre-wrap [overflow-wrap:anywhere] text-sm leading-relaxed text-muted-foreground">
|
||||
<p className="min-w-0 whitespace-pre-wrap [overflow-wrap:anywhere] text-sm leading-5 text-muted-foreground">
|
||||
{cte.purpose}
|
||||
</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex min-w-0 flex-col gap-5 px-3 py-2 sm:px-4">
|
||||
<div className="grid min-w-0 gap-4 md:grid-cols-2">
|
||||
<CardContent className="flex min-w-0 flex-col gap-3 px-3 py-2 sm:px-4">
|
||||
<div className="grid min-w-0 gap-3 md:grid-cols-2">
|
||||
<section className="min-w-0">
|
||||
<h4 className="thot-label mb-2">Depends on</h4>
|
||||
<h4 className="thot-label mb-1">Depends on</h4>
|
||||
{dependsOn.length ? (
|
||||
<div className="flex min-w-0 flex-wrap gap-1.5">
|
||||
{dependsOn.map((dependency) => (
|
||||
@@ -99,7 +99,7 @@ function CteCard({ cte, total }: { cte: CtePlanCte; total: number }) {
|
||||
|
||||
{cte.keys && cte.keys.length > 0 && (
|
||||
<section className="min-w-0">
|
||||
<h4 className="thot-label mb-2">Keys</h4>
|
||||
<h4 className="thot-label mb-1">Keys</h4>
|
||||
<div className="flex min-w-0 flex-wrap gap-1.5">
|
||||
{cte.keys.map((key) => (
|
||||
<Chip key={key}>{key}</Chip>
|
||||
@@ -111,18 +111,18 @@ function CteCard({ cte, total }: { cte: CtePlanCte; total: number }) {
|
||||
|
||||
{cte.tables && cte.tables.length > 0 && (
|
||||
<section className="min-w-0">
|
||||
<h4 className="thot-label mb-2">Tables</h4>
|
||||
<h4 className="thot-label mb-1">Tables</h4>
|
||||
<div className="min-w-0 divide-y divide-border/50 rounded-lg border border-border/60">
|
||||
{cte.tables.map((table) => (
|
||||
<div
|
||||
key={table.name}
|
||||
className="grid min-w-0 gap-1 px-3 py-2 sm:px-4 md:grid-cols-[minmax(10rem,0.8fr)_minmax(0,1.2fr)] md:gap-4"
|
||||
className="grid min-w-0 gap-1 px-3 py-1 sm:px-4 md:grid-cols-[minmax(10rem,0.8fr)_minmax(0,1.2fr)] md:gap-4"
|
||||
>
|
||||
<code className="min-w-0 whitespace-pre-wrap [overflow-wrap:anywhere] font-mono text-xs leading-relaxed text-foreground">
|
||||
<code className="min-w-0 whitespace-pre-wrap [overflow-wrap:anywhere] font-mono text-xs leading-4 text-foreground">
|
||||
{table.name}
|
||||
</code>
|
||||
{table.description && (
|
||||
<p className="min-w-0 whitespace-pre-wrap [overflow-wrap:anywhere] text-xs leading-relaxed text-muted-foreground">
|
||||
<p className="min-w-0 whitespace-pre-wrap [overflow-wrap:anywhere] text-xs leading-4 text-muted-foreground">
|
||||
{table.description}
|
||||
</p>
|
||||
)}
|
||||
@@ -134,7 +134,7 @@ function CteCard({ cte, total }: { cte: CtePlanCte; total: number }) {
|
||||
|
||||
{cte.filters && cte.filters.length > 0 && (
|
||||
<section className="min-w-0">
|
||||
<h4 className="thot-label mb-2">Filters</h4>
|
||||
<h4 className="thot-label mb-1">Filters</h4>
|
||||
<div className="min-w-0 divide-y divide-border/50 rounded-lg border border-border/60">
|
||||
{cte.filters.map((f, i) => (
|
||||
<FilterRow key={`${f.column}-${i}`} filter={f} index={i + 1} />
|
||||
@@ -145,7 +145,7 @@ function CteCard({ cte, total }: { cte: CtePlanCte; total: number }) {
|
||||
|
||||
{cte.output_columns && cte.output_columns.length > 0 && (
|
||||
<section className="min-w-0">
|
||||
<h4 className="thot-label mb-2">Output columns</h4>
|
||||
<h4 className="thot-label mb-1">Output columns</h4>
|
||||
<div className="flex min-w-0 flex-wrap gap-1.5">
|
||||
{cte.output_columns.map((column) => (
|
||||
<Chip key={column}>{column}</Chip>
|
||||
@@ -155,9 +155,9 @@ function CteCard({ cte, total }: { cte: CtePlanCte; total: number }) {
|
||||
)}
|
||||
|
||||
{cte.rationale && (
|
||||
<section className="min-w-0 border-t border-border/60 pt-2">
|
||||
<h4 className="thot-label mb-1.5">Rationale</h4>
|
||||
<p className="whitespace-pre-wrap [overflow-wrap:anywhere] text-sm leading-relaxed text-foreground/90">
|
||||
<section className="min-w-0 border-t border-border/60 pt-1">
|
||||
<h4 className="thot-label mb-1">Rationale</h4>
|
||||
<p className="whitespace-pre-wrap [overflow-wrap:anywhere] text-sm leading-5 text-foreground/90">
|
||||
{cte.rationale}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user