36 KiB
Resizable Model Activity Timeline and Dense CTE Plan Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Restore the left panel as a readable question/reasoning/response timeline, add an accessible persisted drag separator, compact F6 CTE cards, and deploy only the affected frontend container.
Architecture: Keep the complete Zustand activity fold and all backend/SSE contracts unchanged. Change only the panel projection, isolate resize calculations/storage/pointer behavior in a dedicated React hook, let AppShell own the responsive split geometry, and keep CTE density changes local to CtePlanViewer.
Tech Stack: React 18, TypeScript 5.6, Zustand, Tailwind CSS 3.4, React Testing Library, Vitest, Docker Compose.
Global Constraints
- The panel allowlist is exactly
prompt,thinking, andassistant, in originalactivityLogorder. - Human-facing row labels are exactly
Question,Reasoning, andResponse; raw status/kind/level metadata is not rendered. status,tool,gate,lifecycle, and unknown future kinds remain hidden, including warning/error status rows.- All three visible bodies support Markdown and safe long-value wrapping; reasoning alone uses the quieter tone.
activityLog,transcript, store folds, backend, SSE/replay, workflow, persistence, and provider contracts remain unchanged.- The compact central assistant log remains unchanged.
- Desktop width defaults to 384 px, clamps to 288–576 px, and preserves at least 512 px centrally.
- The separator supports pointer capture, Left/Right by 16 px, Shift+Left/Right by 48 px, Home/End, and complete separator ARIA values.
- Persist committed width globally in browser
localStorage; corrupt values fall back and valid values clamp to current geometry. - Below
lg, use a drawer up tomin(90vw, 384px)and do not compress the central column. - CTE horizontal padding remains 12 px below
smand 16 px fromsm; only the approved vertical rhythm changes. - Do not add a frontend dependency or change the shared
Cardprimitive. - UI strings remain English; do not add settings, preferences, text heuristics, or unrelated refactors.
- Preserve the user-owned untracked
.vite/tree: do not add, delete, clean, or commit it. - Deployment may rebuild/recreate only
frontend; restart onlyomics_portal-web-1if the Vite entry changes. Never restartcore, mutate settings/sessions, terminate an unrelated Pi process, or run a model smoke.
Task 1: Restore the readable model activity timeline
Files:
- Modify:
frontend/src/shell/ModelActivityPanel.tsx - Test:
frontend/src/shell/ModelActivityPanel.test.tsx - Test:
frontend/src/shell/AppShell.session-mgmt.test.tsx
Interfaces:
-
Consumes: unchanged
ActivityEntry,ActivityKind, anduseSessionStore().activityLog. -
Produces:
isVisibleModelActivity(entry)with an exact prompt/thinking/assistant allowlist and a compact labeled Markdown timeline. -
Step 1: Replace the obsolete projection tests with failing contract tests
Replace the mixed-sequence, allowlist, hidden-only, status-warning, Markdown, and scroll-update cases in ModelActivityPanel.test.tsx with these exact expectations:
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: "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: "completed" },
});
store.applyEvent({
type: "ui_request",
ui_request: { id: "gate-1", widget: "select", phase: "F1_review", title: "Confirm cohort" },
});
render(<ModelActivityPanel onClose={vi.fn()} />);
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();
});
test("uses an explicit default-deny activity-kind allowlist", () => {
const predicate = (activityPanelModule as unknown as {
isVisibleModelActivity?: (entry: ActivityEntry) => boolean;
}).isVisibleModelActivity;
expect(predicate).toBeTypeOf("function");
if (!predicate) return;
for (const kind of ["prompt", "thinking", "assistant"] satisfies ActivityKind[]) {
expect(predicate({ kind, phase: "F1", text: kind })).toBe(true);
}
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);
});
test("shows the empty state when the raw log contains only hidden entries", () => {
useSessionStore.setState({
activityLog: [
{ kind: "status", phase: "F1", text: "Status" },
{ kind: "gate", phase: "F1", text: "Confirm cohort" },
{ kind: "tool", phase: "F1", text: "bash", toolCallId: "tool-1", status: "completed" },
{ kind: "lifecycle", phase: "F1", text: "Turn end" },
],
});
render(<ModelActivityPanel onClose={vi.fn()} />);
expect(screen.getByText("No activity yet.")).toBeInTheDocument();
expect(screen.queryAllByRole("article")).toHaveLength(0);
});
test("follows appended visible response while near the bottom", () => {
useSessionStore.setState({
activityLog: [{ kind: "thinking", phase: "F1", text: "First" }],
});
render(<ModelActivityPanel onClose={vi.fn()} />);
const viewport = screen.getByTestId("activity-scroll");
setScrollGeometry(viewport, { scrollHeight: 400, clientHeight: 100, scrollTop: 300 });
act(() => useSessionStore.getState().applyEvent({ type: "text_delta", text: "Second" }));
expect(viewport.scrollTop).toBe(400);
});
test("does not bottom-follow when only a hidden status arrives", () => {
useSessionStore.setState({
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().applyEvent({ type: "info", text: "Hidden status" }));
expect(viewport.scrollTop).toBe(300);
});
Also change the close-preservation fixture and the all-visible fixture to visible kinds, and import within from Testing Library. In the AppShell close/reopen test, assert that Inspect patient cohort, Inspecting cohort, and Cohort ready appear, while Cohort status and bash do not.
- Step 2: Run the focused tests and verify RED
Run:
cd frontend
npx vitest run src/shell/ModelActivityPanel.test.tsx src/shell/AppShell.session-mgmt.test.tsx
Expected: failures show the current thinking/status allowlist, missing Question/Response rows, and obsolete status visibility. Existing unrelated session-management tests remain green.
- Step 3: Implement the exact timeline projection and compact row hierarchy
In ModelActivityPanel.tsx, replace the visibility/row rendering contract with:
const VISIBLE_MODEL_ACTIVITY_KINDS: ReadonlySet<ActivityKind> = new Set([
"prompt",
"thinking",
"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);
}
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}`}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{formatModelActivity(entry.text)}</ReactMarkdown>
</div>
);
}
function ActivityRow({ entry }: { entry: ActivityEntry }) {
return (
<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>
<MarkdownBody entry={entry} />
</article>
);
}
Delete the now-unreachable status/lifecycle body branches. Change the panel header from py-3 to py-2; leave the scroll viewport, empty state, close behavior, Markdown formatter, and 48 px follow threshold unchanged.
- Step 4: Run the focused tests and verify GREEN
Run:
npx vitest run src/shell/ModelActivityPanel.test.tsx src/shell/AppShell.session-mgmt.test.tsx
Expected: both files pass; rows appear in prompt/thinking/assistant order and no F1 STATUS INFO content is rendered.
- Step 5: Review and commit Task 1
Run:
cd ..
git diff --check
git diff -- frontend/src/shell/ModelActivityPanel.tsx frontend/src/shell/ModelActivityPanel.test.tsx frontend/src/shell/AppShell.session-mgmt.test.tsx
git add frontend/src/shell/ModelActivityPanel.tsx frontend/src/shell/ModelActivityPanel.test.tsx frontend/src/shell/AppShell.session-mgmt.test.tsx
git commit -m "fix(frontend): restore readable model activity timeline"
Expected: only the panel projection and its tests are committed; the store and stream are untouched.
Task 2: Add the accessible persisted resize contract
Files:
- Create:
frontend/src/shell/useActivityPanelResize.ts - Test:
frontend/src/shell/useActivityPanelResize.test.ts - Modify:
frontend/src/shell/ModelActivityPanel.tsx - Modify:
frontend/src/shell/AppShell.tsx - Test:
frontend/src/shell/AppShell.session-mgmt.test.tsx
Interfaces:
-
Produces:
getActivityPanelBounds(containerWidth),clampActivityPanelWidth(width, bounds),resizeWidthFromKey(width, key, shiftKey, bounds), anduseActivityPanelResize(). -
useActivityPanelResize()returnscontainerRef,panelWidth,resizing, andseparatorProps;AppShellapplies--activity-panel-widthand renders the separator. -
Step 1: Write failing pure resize and storage tests
Create useActivityPanelResize.test.ts:
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);
});
});
- Step 2: Replace the fixed 40/60 AppShell test with failing split interaction tests
Clear localStorage in AppShell.session-mgmt.test.tsx's beforeEach. Replace the 40/60 test with:
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 }));
await userEvent.click(screen.getByRole("button", { name: /show model activity/i }));
const shell = screen.getByTestId("app-shell");
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");
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");
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(
"fixed",
"w-[min(90vw,24rem)]",
"lg:static",
"lg:w-[var(--activity-panel-width)]",
);
expect(separator).toHaveClass("touch-none", "cursor-col-resize");
});
Add fireEvent and ACTIVITY_PANEL_STORAGE_KEY imports.
- Step 3: Run the focused tests and verify RED
Run:
cd frontend
npx vitest run src/shell/useActivityPanelResize.test.ts src/shell/AppShell.session-mgmt.test.tsx
Expected: the new module is missing and the current shell still exposes fixed w-2/5/w-3/5 classes with no separator.
- Step 4: Implement resize calculations, persistence, observation, pointer capture, and keyboard control
Create useActivityPanelResize.ts with these public constants/functions and a hook using the same helpers:
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 };
export function useActivityPanelResize(): {
containerRef: RefObject<HTMLDivElement>;
panelWidth: number;
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 [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 syncBounds = () => {
if (container.clientWidth < ACTIVITY_PANEL_MIN_WIDTH + ACTIVITY_CENTRAL_MIN_WIDTH) return;
const nextBounds = getActivityPanelBounds(container.clientWidth);
boundsRef.current = nextBounds;
setBounds(nextBounds);
applyWidth(widthRef.current, true);
};
syncBounds();
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(syncBounds);
observer?.observe(container);
window.addEventListener("resize", syncBounds);
return () => {
observer?.disconnect();
window.removeEventListener("resize", syncBounds);
};
}, [applyWidth]);
const onPointerDown: PointerEventHandler<HTMLDivElement> = (event) => {
dragRef.current = { pointerId: event.pointerId, startX: event.clientX, startWidth: widthRef.current };
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,
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,
},
};
}
- Step 5: Integrate the hook, responsive drawer, and zero-width hit-area wrapper
In AppShell.tsx, import CSSProperties and useActivityPanelResize, initialize:
const { containerRef, panelWidth, resizing, separatorProps } = useActivityPanelResize();
const activityWidthStyle = {
"--activity-panel-width": `${panelWidth}px`,
} as CSSProperties;
Attach ref={containerRef}, style={activityWidthStyle}, and data-activity-resizing={resizing} to app-shell. Add select-none cursor-col-resize only while resizing. Between ModelActivityPanel and the conversation column, render:
{showActivity && (
<div className="relative hidden w-0 shrink-0 lg:block">
<div
{...separatorProps}
data-testid="activity-resize-handle"
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>
)}
Set data-dragging={resizing} on the separator div. Change the conversation wrapper to:
<div data-testid="conversation-column" className="flex min-w-0 flex-1 flex-col">
In ModelActivityPanel.tsx, replace w-2/5 with:
className="fixed inset-y-0 left-0 z-30 flex w-[min(90vw,24rem)] shrink-0 flex-col border-r border-border bg-sidebar lg:static lg:z-auto lg:w-[var(--activity-panel-width)] lg:border-r-0"
- Step 6: Run focused resize, panel, and shell tests
Run:
npx vitest run src/shell/useActivityPanelResize.test.ts src/shell/ModelActivityPanel.test.tsx src/shell/AppShell.session-mgmt.test.tsx
npx tsc -b
Expected: all focused tests and TypeScript pass; the panel restores saved widths, pointer and keyboard changes clamp/persist, and no fixed 40/60 class remains.
- Step 7: Review and commit Task 2
Run:
cd ..
git diff --check
git diff -- frontend/src/shell/useActivityPanelResize.ts frontend/src/shell/useActivityPanelResize.test.ts frontend/src/shell/ModelActivityPanel.tsx frontend/src/shell/AppShell.tsx frontend/src/shell/AppShell.session-mgmt.test.tsx
git add frontend/src/shell/useActivityPanelResize.ts frontend/src/shell/useActivityPanelResize.test.ts frontend/src/shell/ModelActivityPanel.tsx frontend/src/shell/AppShell.tsx frontend/src/shell/AppShell.session-mgmt.test.tsx
git commit -m "feat(frontend): add resizable activity split"
Expected: the commit contains only the split helper, responsive shell integration, panel layout class, and tests.
Task 3: Reduce the true vertical height sources in F6 CTE cards
Files:
- Modify:
frontend/src/viewers/CtePlanViewer.tsx - Test:
frontend/src/viewers/CtePlanViewer.test.tsx
Interfaces:
-
Consumes: unchanged
CtePlanV2,CtePlanCte,CtePlanFilter,Card,CardHeader, andCardContent. -
Produces: the same semantic CTE plan with compact internal rhythm and unchanged horizontal padding/grid topology.
-
Step 1: Replace padding-only assertions with failing complete density assertions
Update the compact-spacing tests in CtePlanViewer.test.tsx to assert:
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("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("gap-3", "px-3", "py-2", "sm:px-4");
expect(content).not.toHaveClass("gap-5", "p-4", "sm:p-5");
}
});
test("compacts table, filter, detail, and rationale rows", () => {
render(<CtePlanViewer plan={plan} />);
const card = getCteCard("pazienti_idonei");
const tableRow = within(card).getByText("pazienti").parentElement;
expect(tableRow).toHaveClass("px-3", "py-1", "sm:px-4");
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 rationale = within(card)
.getByText("Base della catena: riduce il volume prima dei join")
.closest("section");
expect(rationale).toHaveClass("pt-1");
expect(within(rationale!).getByText("Rationale")).toHaveClass("mb-1");
});
Retain the existing semantic, optional-section, ordered-list, filter alignment, and long-wrap tests. Change existing py-2/pt-2 assertions for table/filter/detail/rationale rows to py-1/pt-1.
- Step 2: Run the viewer tests and verify RED
Run:
cd frontend
npx vitest run src/viewers/CtePlanViewer.test.tsx
Expected: exact class failures identify gap-5, gap-3, py-2, space-y-3, and pt-2 as the remaining excessive height sources.
- Step 3: Apply the approved compact class map
In CtePlanViewer.tsx, make these exact replacements:
FilterRow root: space-y-3 px-3 py-2 -> space-y-2 px-3 py-1
Filter field grid: gap-3 -> gap-2
Filter code/text: leading-relaxed -> leading-4
Filter details: gap-y-2 pt-2 -> gap-y-1 pt-1
CardHeader: gap-3 -> gap-2
Purpose/name lines: leading-relaxed -> leading-5
CardContent: gap-5 -> gap-3
Depends/keys grid: gap-4 -> gap-3
Section headings: mb-2 -> mb-1
Table rows: py-2 -> py-1
Table code/text: leading-relaxed -> leading-4
CTE rationale: pt-2 -> pt-1
Rationale heading: mb-1.5 -> mb-1
Rationale text: leading-relaxed -> leading-5
Keep CardHeader/CardContent outer py-2, all px-3 sm:px-4, chips, badges, borders, gap-3 between distinct CTE cards, grids, section conditions, and wrapping classes unchanged.
- Step 4: Run focused tests, TypeScript, and the layout detector
Run:
npx vitest run src/viewers/CtePlanViewer.test.tsx
npx tsc -b
node /home/admlocforn1/.codex/skills/impeccable/scripts/detect.mjs --json --scope layout \
src/viewers/CtePlanViewer.tsx
Expected: viewer tests and TypeScript pass; detector output is exactly [].
- Step 5: Review and commit Task 3
Run:
cd ..
git diff --check
git diff -- frontend/src/viewers/CtePlanViewer.tsx frontend/src/viewers/CtePlanViewer.test.tsx
git add frontend/src/viewers/CtePlanViewer.tsx frontend/src/viewers/CtePlanViewer.test.tsx
git commit -m "style(frontend): tighten CTE plan vertical rhythm"
Expected: only the CTE viewer and test are committed; shared card.tsx remains untouched.
Task 4: Full verification, frontend-only Docker deployment, and durable state
Files:
- Modify:
brain/codebase/workflow-ui-contracts.md - Modify:
PROJECT_STATE.md
Interfaces:
-
Consumes: Tasks 1–3, the existing root Compose
frontendservice, and the portal Vite manifest cache. -
Produces: verified source, a running updated frontend image, unchanged core runtime, and durable UI/deployment evidence.
-
Step 1: Run the complete source verification gate
Run:
cd frontend
npx vitest run
npx tsc -b
npm run build
node /home/admlocforn1/.codex/skills/impeccable/scripts/detect.mjs --json --scope layout \
src/shell/AppShell.tsx src/shell/ModelActivityPanel.tsx \
src/shell/CentralStatus.tsx src/viewers/CtePlanViewer.tsx
cd ..
git diff --check
git status --short --untracked-files=all
Expected: every frontend test passes, TypeScript/build exit 0, detector output is [], and .vite/ remains untracked. Record exact test/file counts and the emitted Vite entry for PROJECT_STATE.md.
- Step 2: Record live runtime state without mutating sessions or configuration
Run:
docker inspect -f '{{.Name}}|{{.State.Status}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}no-healthcheck{{end}}|{{.Image}}|{{.State.StartedAt}}' \
thothii-core-1 thothii-frontend-1 omics_portal-web-1
docker exec thothii-frontend-1 sh -lc \
"rg -n -B 3 '\"isEntry\": true' /usr/share/nginx/html/.vite/manifest.json 2>/dev/null || grep -n -B 3 '\"isEntry\": true' /usr/share/nginx/html/.vite/manifest.json"
docker top thothii-core-1 -eo pid,ppid,etime,args
Expected: capture core/frontend/portal image and start timestamps, current Vite entry, and whether an unrelated Pi process exists. Do not stop it, close a session, read settings, print session questions, or run a model smoke.
- Step 3: Build and recreate only the frontend service
If execution occurs in an isolated worktree, create only the ignored deployment-environment symlink when absent, without reading or copying the target. Then run from the repository root:
if [ ! -e deploy/thothii.env ]; then
ln -s /home/chirone/ThothII/deploy/thothii.env deploy/thothii.env
fi
test -e deploy/thothii.env
docker compose build frontend
docker compose up -d --no-deps --force-recreate --wait --wait-timeout 60 frontend
docker inspect -f '{{.Name}}|{{.State.Status}}|{{.Image}}|{{.State.StartedAt}}' thothii-frontend-1
docker exec thothii-frontend-1 sh -lc \
"rg -n -B 3 '\"isEntry\": true' /usr/share/nginx/html/.vite/manifest.json 2>/dev/null || grep -n -B 3 '\"isEntry\": true' /usr/share/nginx/html/.vite/manifest.json"
Expected: build and frontend-only recreation succeed; record the new image and entry. If and only if the Vite entry differs from Step 2, run exactly:
docker restart omics_portal-web-1
docker inspect -f '{{.Name}}|{{.State.Status}}|{{.State.StartedAt}}' omics_portal-web-1
Do not recreate/restart core or any other portal service.
- Step 4: Verify deployment isolation and count-only logs
Run:
docker inspect -f '{{.Name}}|{{.State.Status}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}no-healthcheck{{end}}|{{.Image}}|{{.State.StartedAt}}' \
thothii-core-1 thothii-frontend-1 omics_portal-web-1
docker top thothii-core-1 -eo pid,ppid,etime,args
frontend_log_patterns=$(docker logs --since 10m --tail 200 thothii-frontend-1 2>&1 | \
rg -i -c 'fatal|emerg|panic|authorization:|api[_-]?key|password|secret' || true)
printf 'frontend_log_patterns=%s\n' "${frontend_log_patterns:-0}"
Expected: core remains healthy with exactly its Step 2 image/start timestamp, frontend and portal run normally, an unrelated Pi process remains untouched, and the count-only scan reports 0. Never print raw application logs or secret values.
- Step 5: Update durable UI contracts and project state
Replace the two obsolete activity/CTE bullets in brain/codebase/workflow-ui-contracts.md with:
- `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.
Add a dated resolved-state section to PROJECT_STATE.md containing source HEAD, exact frontend test count, TypeScript/build/detector results, new frontend image ID, old/new Vite entries, portal restart decision, unchanged core image/start timestamp, final container state, and preservation of any unrelated Pi process. Explicitly state that no live model smoke was run.
- Step 6: Review documentation scope and commit evidence
Run:
git diff --check
git status --short --untracked-files=all
git diff -- brain/codebase/workflow-ui-contracts.md PROJECT_STATE.md
git add brain/codebase/workflow-ui-contracts.md PROJECT_STATE.md
git diff --cached --check
git commit -m "docs: record activity split deployment"
Expected: only the two documentation files enter the commit; .vite/, runtime configuration, settings, and session data remain unstaged.
Final review gate
Before claiming completion, use requesting-code-review on the complete implementation range, resolve every Critical/Important finding, then use verification-before-completion to rerun the relevant focused tests, the complete frontend suite, TypeScript, build, detector, git diff --check, and runtime/container evidence. The final handoff must report the exact frontend image, Vite entry, test count, portal restart decision, and unchanged core start timestamp.