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