diff --git a/docs/superpowers/plans/2026-07-15-resizable-model-activity-cte-density.md b/docs/superpowers/plans/2026-07-15-resizable-model-activity-cte-density.md new file mode 100644 index 00000000..af484b33 --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-resizable-model-activity-cte-density.md @@ -0,0 +1,850 @@ +# 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`, and `assistant`, in original `activityLog` order. +- Human-facing row labels are exactly `Question`, `Reasoning`, and `Response`; 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 to `min(90vw, 384px)` and do not compress the central column. +- CTE horizontal padding remains 12 px below `sm` and 16 px from `sm`; only the approved vertical rhythm changes. +- Do not add a frontend dependency or change the shared `Card` primitive. +- 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 only `omics_portal-web-1` if the Vite entry changes. Never restart `core`, 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`, and `useSessionStore().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: + +```tsx +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(); + + 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(); + 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(); + 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(); + 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: + +```bash +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: + +```tsx +const VISIBLE_MODEL_ACTIVITY_KINDS: ReadonlySet = new Set([ + "prompt", + "thinking", + "assistant", +]); + +const ACTIVITY_LABELS: Readonly>> = { + 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 ( +
+ {formatModelActivity(entry.text)} +
+ ); +} + +function ActivityRow({ entry }: { entry: ActivityEntry }) { + return ( +
+
+ {ACTIVITY_LABELS[entry.kind]} + {entry.phase && {entry.phase}} +
+ +
+ ); +} +``` + +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: + +```bash +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: + +```bash +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)`, and `useActivityPanelResize()`. +- `useActivityPanelResize()` returns `containerRef`, `panelWidth`, `resizing`, and `separatorProps`; `AppShell` applies `--activity-panel-width` and renders the separator. + +- [ ] **Step 1: Write failing pure resize and storage tests** + +Create `useActivityPanelResize.test.ts`: + +```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: + +```tsx +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: + +```bash +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: + +```tsx +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 | 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 | 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; + 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; + onPointerMove: PointerEventHandler; + onPointerUp: PointerEventHandler; + onPointerCancel: PointerEventHandler; + onKeyDown: KeyboardEventHandler; + }; +} { + const storageRef = useRef(getBrowserStorage()); + const initialWidthRef = useRef(loadActivityPanelWidth(storageRef.current)); + const containerRef = useRef(null); + const widthRef = useRef(initialWidthRef.current); + const boundsRef = useRef({ + min: ACTIVITY_PANEL_MIN_WIDTH, + max: ACTIVITY_PANEL_MAX_WIDTH, + }); + const dragRef = useRef(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 = (event) => { + dragRef.current = { pointerId: event.pointerId, startX: event.clientX, startWidth: widthRef.current }; + event.currentTarget.setPointerCapture?.(event.pointerId); + setResizing(true); + }; + const onPointerMove: PointerEventHandler = (event) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + applyWidth(drag.startWidth + event.clientX - drag.startX, false); + }; + const onPointerUp: PointerEventHandler = (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 = (event) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + applyWidth(drag.startWidth, false); + dragRef.current = null; + setResizing(false); + }; + const onKeyDown: KeyboardEventHandler = (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: + +```tsx +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: + +```tsx +{showActivity && ( +
+
+ +
+
+)} +``` + +Set `data-dragging={resizing}` on the separator div. Change the conversation wrapper to: + +```tsx +
+``` + +In `ModelActivityPanel.tsx`, replace `w-2/5` with: + +```tsx +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: + +```bash +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: + +```bash +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`, and `CardContent`. +- 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: + +```tsx +test("uses compact CTE section rhythm with moderate lateral padding", () => { + const { container } = render(); + 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(); + 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: + +```bash +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: + +```text +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: + +```bash +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: + +```bash +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 `frontend` service, 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: + +```bash +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: + +```bash +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: + +```bash +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: + +```bash +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: + +```bash +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: + +```markdown +- `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: + +```bash +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.