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

851 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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(<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:
```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<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:
```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<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:
```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 && (
<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:
```tsx
<div data-testid="conversation-column" className="flex min-w-0 flex-1 flex-col">
```
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(<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:
```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.