merge: resizable activity timeline and CTE density

This commit is contained in:
User
2026-07-15 16:06:19 +02:00
11 changed files with 612 additions and 182 deletions
+57
View File
@@ -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
+8 -10
View File
@@ -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.
+18
View File
@@ -220,6 +220,24 @@
text-transform: uppercase;
color: oklch(var(--muted-foreground));
}
.thot-activity-prose,
.thot-activity-prose p,
.thot-activity-prose li,
.thot-activity-prose a,
.thot-activity-prose code,
.thot-activity-prose th,
.thot-activity-prose td {
overflow-wrap: anywhere;
word-break: break-word;
}
.thot-activity-prose table { table-layout: fixed; }
.thot-activity-prose pre {
overflow-x: hidden;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
}
.thot-activity-prose pre code { white-space: inherit; }
}
+122 -16
View File
@@ -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");
});
+27 -3
View File
@@ -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. */}
+45 -59
View File
@@ -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);
});
+26 -50
View File
@@ -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,
},
};
}
+17 -20
View File
@@ -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", () => {
+24 -24
View File
@@ -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>