From a868fc06735d885833d7561e82590dffda26be36 Mon Sep 17 00:00:00 2001 From: mptyl Date: Mon, 29 Jun 2026 21:23:49 +0200 Subject: [PATCH] docs(plan): session UI refinements implementation plan (5 tasks, frontend, TDD) Co-Authored-By: Claude Opus 4.8 --- .../2026-06-29-session-ui-refinements.md | 767 ++++++++++++++++++ 1 file changed, 767 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-29-session-ui-refinements.md diff --git a/docs/superpowers/plans/2026-06-29-session-ui-refinements.md b/docs/superpowers/plans/2026-06-29-session-ui-refinements.md new file mode 100644 index 00000000..e6480699 --- /dev/null +++ b/docs/superpowers/plans/2026-06-29-session-ui-refinements.md @@ -0,0 +1,767 @@ +# Session UI Refinements 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:** Refine the session UI — Active/Archive accordions, group rename, and a minimal central area with the verbose model stream moved to an on-demand left panel. + +**Architecture:** Frontend-only. The session rail's Active/Archive becomes two accordions; group rename reassigns members via the existing `setSessionGroup`. The central area drops the full `` and shows only the last user entry + the gate's `notify`/`info` messages + the active widget; the streamed model text moves to a left `ModelActivityPanel` toggled by the work-in-progress icon (relocated above the composer). + +**Tech Stack:** React 18 + Zustand + TanStack Query + Tailwind + vitest/RTL/MSW. + +## Global Constraints + +- **Frontend-only.** No backend/harness changes. No new API endpoints. +- **Central area = essentials only:** last user input/choice + gate `notify`/`info` of the + current step + active widget (`WidgetHost`). The full `text_delta` stream is NOT in the centre. +- **Verbose model stream → left panel, on demand.** The whole `transcript` (streamed model + text) renders in a left `ModelActivityPanel`, opened by clicking the WIP icon. +- **WIP icon moves** from the right-rail header to **above the composer**; it is a ` + {activeOpen && ( +
+ {groups.map((g) => ( +
+ + {!collapsedGroups[g] && ( + s.group === g)} + activeSessionId={activeSessionId} + onOpenPanel={openPanel} + menuFor={menuFor} + /> + )} +
+ ))} +
+ {groups.length > 0 && ( +

No group

+ )} + !s.group)} + activeSessionId={activeSessionId} + onOpenPanel={openPanel} + menuFor={menuFor} + /> +
+
+ )} + + + {archiveOpen && ( + + )} + +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd frontend && npx vitest run src/shell/AppShell.session-mgmt.test.tsx` then `npx tsc -b` +Expected: PASS (incl. "active list shows group header and hides archived sessions" and the new Archive accordion test); typecheck clean. + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/shell/AppShell.tsx frontend/src/shell/AppShell.session-mgmt.test.tsx +git commit -m "feat(frontend): Active/Archive as independent accordions (replaces toggle)" +``` + +--- + +### Task 2: Rename group + +**Files:** +- Modify: `frontend/src/shell/AppShell.tsx` +- Test: `frontend/src/shell/AppShell.session-mgmt.test.tsx` (append) + +**Interfaces:** +- Consumes: `setSessionGroup` (existing API), `RenameDialog`, `groups`, `activeList`, `refresh`. +- Produces: a per-group-header "Rename group" action that reassigns members. + +- [ ] **Step 1: Write the failing test** + +Append to `frontend/src/shell/AppShell.session-mgmt.test.tsx` (the `LIST` fixture already has +`s1` in group "Aritmologia"): + +```tsx +test("renaming a group reassigns its members via setSessionGroup", async () => { + const groupSets: Array<{ id: string; group: string }> = []; + server.use( + http.post("http://localhost:8787/sessions/:id/group", async ({ params, request }) => { + const body = (await request.json()) as { group: string }; + groupSets.push({ id: params.id as string, group: body.group }); + return new HttpResponse(null, { status: 204 }); + }), + ); + wrap(); + await screen.findByText("Aritmologia"); + await userEvent.click(screen.getByRole("button", { name: /rename group aritmologia/i })); + const input = await screen.findByLabelText(/name/i); + await userEvent.clear(input); + await userEvent.type(input, "Cardiologia"); + await userEvent.click(screen.getByRole("button", { name: /save/i })); + await waitFor(() => expect(groupSets).toEqual([{ id: "s1", group: "Cardiologia" }])); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd frontend && npx vitest run src/shell/AppShell.session-mgmt.test.tsx -t "renaming a group"` +Expected: FAIL — no "rename group" button exists. + +- [ ] **Step 3: Add the rename-group state, handler, and header button** + +In `frontend/src/shell/AppShell.tsx`, add state near the other dialog targets: + +```tsx + const [renameGroupTarget, setRenameGroupTarget] = useState(null); +``` + +Add the handler near `move`/`newGroup`: + +```tsx + async function renameGroup(oldName: string, newName: string) { + const trimmed = newName.trim(); + if (!trimmed || trimmed === oldName) return; + try { + for (const s of sessions.filter((x) => x.group === oldName)) { + await setSessionGroup(s.id, trimmed); + } + refresh(); + } catch { + toast.error("Failed to rename group."); + } + } +``` + +In the group header (from Task 1), wrap the chevron button + a rename button in a flex row so +the header carries an edit affordance. Replace the group header `` with: + +```tsx +
+ + +
+``` + +Add `import { Pencil } from "lucide-react";` at the top of AppShell.tsx. + +Add the rename dialog near the other dialogs (before `` with the Toaster): + +```tsx + {renameGroupTarget && ( + { if (!o) setRenameGroupTarget(null); }} + onSubmit={async (name) => { await renameGroup(renameGroupTarget, name); setRenameGroupTarget(null); }} + /> + )} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd frontend && npx vitest run src/shell/AppShell.session-mgmt.test.tsx` then `npx tsc -b` +Expected: PASS; typecheck clean. + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/shell/AppShell.tsx frontend/src/shell/AppShell.session-mgmt.test.tsx +git commit -m "feat(frontend): rename group (reassign members via setSessionGroup)" +``` + +--- + +### Task 3: Store — last user entry + step messages + +**Files:** +- Modify: `frontend/src/store/sessionStore.ts` +- Modify: `frontend/src/shell/SteerInput.tsx` +- Modify: `frontend/src/shell/WidgetHost.tsx` +- Test: `frontend/src/store/sessionStore.test.ts` (create) + +**Interfaces:** +- Produces on the store: + - `lastUserEntry: { kind: "input" | "choice"; text: string } | null` + - `stepMessages: { level: string; text: string }[]` + - `setLastUserEntry: (e: { kind: "input" | "choice"; text: string }) => void` — sets the entry + AND clears `stepMessages`. + - `applyEvent` for an `info` event now appends to `stepMessages` (instead of `toasts`). + +- [ ] **Step 1: Write the failing test** + +Create `frontend/src/store/sessionStore.test.ts`: + +```ts +import { test, expect, beforeEach } from "vitest"; +import { useSessionStore } from "./sessionStore"; + +beforeEach(() => useSessionStore.getState().resetSession()); + +test("info events accumulate in stepMessages", () => { + const { applyEvent } = useSessionStore.getState(); + applyEvent({ type: "info", level: "info", text: "found 3 tables" }); + applyEvent({ type: "info", level: "warning", text: "ambiguous term" }); + expect(useSessionStore.getState().stepMessages).toEqual([ + { level: "info", text: "found 3 tables" }, + { level: "warning", text: "ambiguous term" }, + ]); +}); + +test("setLastUserEntry records the entry and clears stepMessages", () => { + const st = useSessionStore.getState(); + st.applyEvent({ type: "info", level: "info", text: "x" }); + st.setLastUserEntry({ kind: "input", text: "my question" }); + expect(useSessionStore.getState().lastUserEntry).toEqual({ kind: "input", text: "my question" }); + expect(useSessionStore.getState().stepMessages).toEqual([]); +}); + +test("resetSession clears lastUserEntry and stepMessages", () => { + const st = useSessionStore.getState(); + st.setLastUserEntry({ kind: "choice", text: "promote" }); + st.applyEvent({ type: "info", level: "info", text: "y" }); + st.resetSession(); + expect(useSessionStore.getState().lastUserEntry).toBeNull(); + expect(useSessionStore.getState().stepMessages).toEqual([]); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd frontend && npx vitest run src/store/sessionStore.test.ts` +Expected: FAIL — `stepMessages`/`setLastUserEntry` undefined. + +- [ ] **Step 3: Extend the store** + +In `frontend/src/store/sessionStore.ts`: + +Extend the interface: + +```ts +interface SessionState { + pendingWidget: WidgetDescriptor | null; + transcript: Entry[]; + toasts: { level: string; text: string }[]; + stepMessages: { level: string; text: string }[]; + lastUserEntry: { kind: "input" | "choice"; text: string } | null; + lastSystemEvent: StreamEvent | null; + currentPhase: string | null; + applyEvent: (e: StreamEvent) => void; + clearPending: () => void; + resetSession: () => void; + setPhase: (phase: string | null) => void; + pushToast: (toast: { level: string; text: string }) => void; + setLastUserEntry: (e: { kind: "input" | "choice"; text: string }) => void; +} +``` + +Extend `empty`: + +```ts +const empty = { + pendingWidget: null, + transcript: [] as Entry[], + toasts: [] as { level: string; text: string }[], + stepMessages: [] as { level: string; text: string }[], + lastUserEntry: null as { kind: "input" | "choice"; text: string } | null, + lastSystemEvent: null, + currentPhase: null as string | null, +}; +``` + +Change the `info` branch in `applyEvent` (route to `stepMessages`, not `toasts`): + +```ts + if (e.type === "info") return { stepMessages: [...st.stepMessages, { level: e.level ?? "info", text: e.text }] }; +``` + +Add the setter (next to `pushToast`): + +```ts + setLastUserEntry: (e) => set({ lastUserEntry: e, stepMessages: [] }), +``` + +- [ ] **Step 4: Record the last user entry from the composer and the widget** + +In `frontend/src/shell/SteerInput.tsx`: import the store and the setter, and record on submit. +Add at the top: `import { useSessionStore } from "../store/sessionStore";`. Inside `SteerInput`, +add `const setLastUserEntry = useSessionStore((s) => s.setLastUserEntry);`. In `submit`, after the +`await postSteer(...)` / `await createSession(...)` succeeds and before `setText("")`, add: + +```tsx + setLastUserEntry({ kind: "input", text: trimmed }); +``` + +In `frontend/src/shell/WidgetHost.tsx`: add `const setLastUserEntry = useSessionStore((s) => s.setLastUserEntry);` +and, in `onRespond` after a successful `postResponse` (before `clearPending()`), record the choice: + +```tsx + setLastUserEntry({ + kind: "choice", + text: r.text ?? r.choices?.join(", ") ?? r.decision?.type ?? r.control ?? "(choice)", + }); +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cd frontend && npx vitest run src/store/sessionStore.test.ts` then `npx tsc -b` and `npx vitest run` +Expected: PASS; typecheck clean; full suite green (the old behavior where `info`→toasts is gone — check no test asserts `toasts` for info; if one does, update it to `stepMessages`). + +- [ ] **Step 6: Commit** + +```bash +git add frontend/src/store/sessionStore.ts frontend/src/shell/SteerInput.tsx frontend/src/shell/WidgetHost.tsx frontend/src/store/sessionStore.test.ts +git commit -m "feat(frontend): store lastUserEntry + stepMessages; record user input/choice" +``` + +--- + +### Task 4: CentralStatus + ModelActivityPanel components + +**Files:** +- Create: `frontend/src/shell/CentralStatus.tsx` +- Create: `frontend/src/shell/ModelActivityPanel.tsx` +- Test: `frontend/src/shell/CentralStatus.test.tsx`, `frontend/src/shell/ModelActivityPanel.test.tsx` + +**Interfaces:** +- Consumes: `useSessionStore` (`lastUserEntry`, `stepMessages`, `transcript`), `Transcript`. +- Produces: + - `CentralStatus()` — renders the last user entry + step messages (null when both empty). + - `ModelActivityPanel({ onClose }: { onClose: () => void })` — left drawer rendering ``. + +- [ ] **Step 1: Write the failing tests** + +Create `frontend/src/shell/CentralStatus.test.tsx`: + +```tsx +import { render, screen } from "@testing-library/react"; +import { beforeEach } from "vitest"; +import { useSessionStore } from "../store/sessionStore"; +import { CentralStatus } from "./CentralStatus"; + +beforeEach(() => useSessionStore.getState().resetSession()); + +test("renders nothing when there is no user entry and no step messages", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); +}); + +test("echoes the last user entry and the step messages", () => { + const st = useSessionStore.getState(); + st.setLastUserEntry({ kind: "input", text: "how many patients?" }); + st.applyEvent({ type: "info", level: "info", text: "Searching the schema…" }); + render(); + expect(screen.getByText("how many patients?")).toBeInTheDocument(); + expect(screen.getByText("Searching the schema…")).toBeInTheDocument(); +}); +``` + +Create `frontend/src/shell/ModelActivityPanel.test.tsx`: + +```tsx +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, vi } from "vitest"; +import { useSessionStore } from "../store/sessionStore"; +import { ModelActivityPanel } from "./ModelActivityPanel"; + +beforeEach(() => useSessionStore.getState().resetSession()); + +test("renders the streamed model transcript", () => { + useSessionStore.getState().applyEvent({ type: "text_delta", text: "Promoting table dim_patient." }); + render(); + expect(screen.getByText(/Promoting table dim_patient/)).toBeInTheDocument(); +}); + +test("close button calls onClose", async () => { + const onClose = vi.fn(); + render(); + await userEvent.click(screen.getByRole("button", { name: /close/i })); + expect(onClose).toHaveBeenCalled(); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd frontend && npx vitest run src/shell/CentralStatus.test.tsx src/shell/ModelActivityPanel.test.tsx` +Expected: FAIL — modules not found. + +- [ ] **Step 3: Create CentralStatus** + +Create `frontend/src/shell/CentralStatus.tsx`: + +```tsx +import { useSessionStore } from "../store/sessionStore"; + +/** The minimal central view: the user's last input/choice + the gate's curated + * messages for the current step. The verbose model stream lives in the left panel. */ +export function CentralStatus() { + const lastUserEntry = useSessionStore((s) => s.lastUserEntry); + const stepMessages = useSessionStore((s) => s.stepMessages); + if (!lastUserEntry && stepMessages.length === 0) return null; + + return ( +
+ {lastUserEntry && ( +
+ + {lastUserEntry.kind === "input" ? "You asked" : "You chose"} + +

{lastUserEntry.text}

+
+ )} + {stepMessages.map((m, i) => ( +

+ {m.text} +

+ ))} +
+ ); +} +``` + +- [ ] **Step 4: Create ModelActivityPanel** + +Create `frontend/src/shell/ModelActivityPanel.tsx`: + +```tsx +import { X } from "lucide-react"; +import { Transcript } from "./Transcript"; +import { Button } from "../components/ui/button"; + +/** Left drawer showing the active session's streamed model text ("model activity"), + * opened on demand from the work-in-progress icon. */ +export function ModelActivityPanel({ onClose }: { onClose: () => void }) { + return ( + + ); +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cd frontend && npx vitest run src/shell/CentralStatus.test.tsx src/shell/ModelActivityPanel.test.tsx` then `npx tsc -b` +Expected: PASS; typecheck clean. (`` returns null when the transcript is empty, so the +ModelActivityPanel "close" test still renders the header + button.) + +- [ ] **Step 6: Commit** + +```bash +git add frontend/src/shell/CentralStatus.tsx frontend/src/shell/ModelActivityPanel.tsx frontend/src/shell/CentralStatus.test.tsx frontend/src/shell/ModelActivityPanel.test.tsx +git commit -m "feat(frontend): CentralStatus + ModelActivityPanel components" +``` + +--- + +### Task 5: AppShell integration — central area, WIP icon, left panel + +**Files:** +- Modify: `frontend/src/shell/AppShell.tsx` +- Test: `frontend/src/shell/AppShell.session-mgmt.test.tsx` (append) + +**Interfaces:** +- Consumes: `CentralStatus`, `ModelActivityPanel` (Task 4), `WorkingSpinner` (existing in AppShell), + store `transcript`/`lastUserEntry` (Task 3). +- Produces: central area without ``; the WIP icon above the composer toggling the + left `ModelActivityPanel`; left-region exclusivity with `SessionDocumentsPanel`. + +- [ ] **Step 1: Write the failing test** + +Append to `frontend/src/shell/AppShell.session-mgmt.test.tsx`: + +```tsx +import { act } from "@testing-library/react"; +import { useSessionStore } from "../store/sessionStore"; + +test("WIP icon toggles the Model activity panel and shows the streamed text", async () => { + wrap(); + // Activate a session by opening + resuming is heavy; instead drive the store directly. + // The WIP icon only renders with an active session, so simulate one via Resume. + server.use(http.post("http://localhost:8787/sessions/:id/resume", () => new HttpResponse(null, { status: 204 }))); + await userEvent.click(await screen.findByText("Attiva uno")); // opens docs panel + await userEvent.click(await screen.findByRole("button", { name: /resume/i })); // active session + act(() => { useSessionStore.getState().applyEvent({ type: "text_delta", text: "Looking at dim_patient." }); }); + // Model activity hidden until the WIP icon is clicked + expect(screen.queryByText(/Looking at dim_patient/)).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: /model activity/i })); + expect(await screen.findByText(/Looking at dim_patient/)).toBeInTheDocument(); +}); +``` + +> If driving a real active session in jsdom proves flaky (SSE/EventSource), assert the simpler +> invariant the WIP button controls: render with an active session, click the WIP toggle, and +> assert the `ModelActivityPanel` header ("Model activity") appears/disappears. Note any +> adaptation in the report. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd frontend && npx vitest run src/shell/AppShell.session-mgmt.test.tsx -t "WIP icon"` +Expected: FAIL — no WIP toggle / Model activity panel in AppShell. + +- [ ] **Step 3: Wire the central area + WIP icon + panel** + +In `frontend/src/shell/AppShell.tsx`: + +(a) Add imports: + +```tsx +import { CentralStatus } from "./CentralStatus"; +import { ModelActivityPanel } from "./ModelActivityPanel"; +``` + +(b) Add state near the other panel state: + +```tsx + const [showActivity, setShowActivity] = useState(false); +``` + +(c) Make the two left panels mutually exclusive. In `openPanel`, close activity; add an activity +opener that closes the docs panel. Replace `openPanel` with: + +```tsx + function openPanel(id: string) { + const s = sessions.find((x) => x.id === id); + if (s) { setPanelSession(s); setShowActivity(false); } + } + function toggleActivity() { + setShowActivity((v) => { + const next = !v; + if (next) setPanelSession(null); + return next; + }); + } +``` + +(d) Render the activity panel as a left drawer. Immediately after the existing +`{panelSession && ()}` block, add: + +```tsx + {showActivity && setShowActivity(false)} />} +``` + +(e) Remove `` from the centre and show ``. Replace the +`activeSessionId ? (<>) : ()` block with: + +```tsx + {activeSessionId ? ( + <> + + + + ) : ( + + )} +``` + +Remove the now-unused `import { Transcript } from "./Transcript";` from AppShell (it is used by +`ModelActivityPanel` instead). + +(f) Remove the WIP icon from the rail header — delete the block: + +```tsx + {working && ( + + )} +``` + +(and the now-unneeded `relative` positioning on that header div can stay; it is harmless). + +(g) Add the WIP icon as a toggle just **above** the composer. Inside the sticky composer column, +immediately before the `
` composer box, add (only when a +session is active): + +```tsx + {activeSessionId && ( +
+ +
+ )} +``` + +(h) Update `WorkingSpinner` to accept a `spinning` prop (it currently always spins). Change its +signature and the `animate-spin` class to be conditional: + +```tsx +function WorkingSpinner({ className, spinning = true }: { className?: string; spinning?: boolean }) { + return ( + + + + + ); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd frontend && npx vitest run src/shell/AppShell.session-mgmt.test.tsx` then `npx vitest run` and `npx tsc -b` +Expected: PASS (new WIP test + existing tests, incl. "opening the panel shows the session +documents" — opening docs still works); full suite green; typecheck clean. + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/shell/AppShell.tsx frontend/src/shell/AppShell.session-mgmt.test.tsx +git commit -m "feat(frontend): minimal central area; WIP icon above composer toggles model-activity panel" +``` + +--- + +## Final verification + +- [ ] `cd frontend && npx vitest run` → all pass. +- [ ] `cd frontend && npx tsc -b` → clean. +- [ ] `cd frontend && npm run build` → succeeds. + +## Spec coverage check +- Active/Archive accordions → Task 1. +- Rename group (reassign members) → Task 2. +- Central = last user entry + gate notify/info (cleared per step) + widget → Tasks 3, 4, 5. +- Verbose model stream → left ModelActivityPanel on demand → Tasks 4, 5. +- WIP icon moved above composer, toggles the panel, spins while working → Task 5. +- Left-region exclusivity (docs vs activity) → Task 5. +- Deferred (fine thinking separation, backend/harness changes) → not implemented, by design.