# 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.