Files
ThothII/docs/superpowers/plans/2026-07-15-resizable-model-activity-cte-density.md
T

36 KiB
Raw Blame History

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:

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), 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:

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, 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:

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 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:

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.