docs(plan): session UI refinements implementation plan (5 tasks, frontend, TDD)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-29 21:23:49 +02:00
co-authored by Claude Opus 4.8
parent 3d06505819
commit a868fc0673
@@ -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 `<Transcript>` 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 `<button>`
that toggles the `ModelActivityPanel` and spins while `working`.
- **Step messages reset on each user entry:** the central `notify`/`info` list is cleared when
a new `lastUserEntry` is set.
- **Group rename** reassigns each member via `setSessionGroup(id, newName)` (groups are derived
from the manifest `group` field; non-atomic across members, `toast.error` on failure).
- **Left-region exclusivity:** the read-only `SessionDocumentsPanel` (clicked-session docs) and
the `ModelActivityPanel` (active-session stream) never show together — opening one closes the
other.
- UI strings are English (matches the rest of the app).
---
## File Structure
- Modify `frontend/src/store/sessionStore.ts` — `lastUserEntry`, `stepMessages`, `setLastUserEntry`; `info` → `stepMessages`.
- Modify `frontend/src/shell/SteerInput.tsx` — record `{kind:"input"}` on submit.
- Modify `frontend/src/shell/WidgetHost.tsx` — record `{kind:"choice"}` on respond.
- Create `frontend/src/shell/CentralStatus.tsx` — last user entry + step messages.
- Create `frontend/src/shell/ModelActivityPanel.tsx` — left drawer wrapping `<Transcript>`.
- Modify `frontend/src/shell/AppShell.tsx` — accordions, group rename, central area, WIP icon move, left-panel wiring.
- Tests: `sessionStore.test.ts` (create), `CentralStatus.test.tsx` + `ModelActivityPanel.test.tsx` (create), and additions to `AppShell.session-mgmt.test.tsx`.
---
### Task 1: Active/Archive accordions
**Files:**
- Modify: `frontend/src/shell/AppShell.tsx`
- Test: `frontend/src/shell/AppShell.session-mgmt.test.tsx` (update the archive test)
**Interfaces:**
- Consumes: `activeList`, `archivedList`, `groups`, `collapsedGroups` (existing in AppShell).
- Produces: two collapsible sections (Active/Archive) replacing the `showArchive` toggle.
- [ ] **Step 1: Update the failing test**
In `frontend/src/shell/AppShell.session-mgmt.test.tsx`, replace the test
`"Archive toggle reveals archived sessions"` with an accordion version:
```tsx
test("Archive accordion expands to reveal archived sessions", async () => {
wrap();
// archived hidden until the Archive accordion is expanded
expect(await screen.findByText("Attiva uno")).toBeInTheDocument();
expect(screen.queryByText("Archiviata due")).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: /archive/i }));
expect(await screen.findByText("Archiviata due")).toBeInTheDocument();
// Active sessions remain visible (it is a separate accordion, not a swap)
expect(screen.getByText("Attiva uno")).toBeInTheDocument();
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd frontend && npx vitest run src/shell/AppShell.session-mgmt.test.tsx -t "Archive accordion"`
Expected: FAIL — clicking Archive swaps the view (Attiva uno disappears) under the old toggle.
- [ ] **Step 3: Replace the toggle with two accordions**
In `frontend/src/shell/AppShell.tsx`: remove the `showArchive` state line
(`const [showArchive, setShowArchive] = useState(false);`) and add accordion state next to
`collapsedGroups`:
```tsx
const [activeOpen, setActiveOpen] = useState(true);
const [archiveOpen, setArchiveOpen] = useState(false);
```
Then replace the entire rail scrolling container (the `<div className="flex-1 overflow-y-auto px-2 pb-4">…</div>` block that currently holds the `showArchive ? … : …` ternary and the bottom toggle button) with:
```tsx
<div className="flex-1 overflow-y-auto px-2 pb-4">
<button
type="button"
onClick={() => setActiveOpen((v) => !v)}
aria-expanded={activeOpen}
className="flex w-full items-center gap-1 px-1 pb-1 pt-1 text-left text-[0.7rem] font-bold uppercase tracking-wide text-primary hover:text-primary/80"
>
<span className="select-none">{activeOpen ? "▾" : "▸"}</span>
<span>Active sessions</span>
</button>
{activeOpen && (
<div className="flex flex-col gap-3 pb-2">
{groups.map((g) => (
<div key={g}>
<button
type="button"
onClick={() => setCollapsedGroups((c) => ({ ...c, [g]: !c[g] }))}
aria-expanded={!collapsedGroups[g]}
className="flex w-full items-center gap-1 px-1 pb-1 text-left text-[0.7rem] font-semibold uppercase tracking-wide text-muted-foreground hover:text-foreground"
>
<span className="select-none">{collapsedGroups[g] ? "▸" : "▾"}</span>
<span className="truncate">{g}</span>
</button>
{!collapsedGroups[g] && (
<NavSessions
sessions={activeList.filter((s) => s.group === g)}
activeSessionId={activeSessionId}
onOpenPanel={openPanel}
menuFor={menuFor}
/>
)}
</div>
))}
<div>
{groups.length > 0 && (
<p className="px-1 pb-1 text-[0.7rem] font-semibold uppercase tracking-wide text-muted-foreground">No group</p>
)}
<NavSessions
sessions={activeList.filter((s) => !s.group)}
activeSessionId={activeSessionId}
onOpenPanel={openPanel}
menuFor={menuFor}
/>
</div>
</div>
)}
<button
type="button"
onClick={() => setArchiveOpen((v) => !v)}
aria-expanded={archiveOpen}
className="mt-2 flex w-full items-center gap-1 px-1 pb-1 pt-1 text-left text-[0.7rem] font-bold uppercase tracking-wide text-muted-foreground hover:text-foreground"
>
<span className="select-none">{archiveOpen ? "▾" : "▸"}</span>
<span>Archive ({archivedList.length})</span>
</button>
{archiveOpen && (
<NavSessions sessions={archivedList} activeSessionId={activeSessionId} onOpenPanel={openPanel} menuFor={menuFor} />
)}
</div>
```
- [ ] **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<string | null>(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 `<button>…{g}…</button>` with:
```tsx
<div className="group/gh flex items-center gap-1">
<button
type="button"
onClick={() => setCollapsedGroups((c) => ({ ...c, [g]: !c[g] }))}
aria-expanded={!collapsedGroups[g]}
className="flex min-w-0 flex-1 items-center gap-1 px-1 pb-1 text-left text-[0.7rem] font-semibold uppercase tracking-wide text-muted-foreground hover:text-foreground"
>
<span className="select-none">{collapsedGroups[g] ? "▸" : "▾"}</span>
<span className="truncate">{g}</span>
</button>
<button
type="button"
aria-label={`Rename group ${g}`}
onClick={() => setRenameGroupTarget(g)}
className="rounded p-0.5 text-muted-foreground opacity-0 transition-opacity hover:bg-accent group-hover/gh:opacity-100"
>
<Pencil className="size-3" />
</button>
</div>
```
Add `import { Pencil } from "lucide-react";` at the top of AppShell.tsx.
Add the rename dialog near the other dialogs (before `</div>` with the Toaster):
```tsx
{renameGroupTarget && (
<RenameDialog
open
initial={renameGroupTarget}
onOpenChange={(o) => { 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 `<Transcript>`.
- [ ] **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(<CentralStatus />);
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(<CentralStatus />);
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(<ModelActivityPanel onClose={vi.fn()} />);
expect(screen.getByText(/Promoting table dim_patient/)).toBeInTheDocument();
});
test("close button calls onClose", async () => {
const onClose = vi.fn();
render(<ModelActivityPanel onClose={onClose} />);
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 (
<div className="flex flex-col gap-3">
{lastUserEntry && (
<div className="rounded-lg border border-border/70 bg-card px-3 py-2">
<span className="text-[0.65rem] font-semibold uppercase tracking-wide text-muted-foreground">
{lastUserEntry.kind === "input" ? "You asked" : "You chose"}
</span>
<p className="mt-0.5 text-sm text-foreground">{lastUserEntry.text}</p>
</div>
)}
{stepMessages.map((m, i) => (
<p
key={i}
className={[
"text-sm",
m.level === "error" ? "text-destructive"
: m.level === "warning" ? "text-amber-600"
: "text-foreground/90",
].join(" ")}
>
{m.text}
</p>
))}
</div>
);
}
```
- [ ] **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 (
<aside className="flex w-[380px] shrink-0 flex-col border-r border-border bg-sidebar">
<div className="flex items-center justify-between border-b border-border/70 px-4 py-3">
<h2 className="font-heading text-sm font-semibold text-foreground">Model activity</h2>
<Button size="icon-sm" variant="ghost" aria-label="Close model activity" onClick={onClose}>
<X className="size-4" />
</Button>
</div>
<div className="flex-1 overflow-y-auto px-4 py-4">
<Transcript />
</div>
</aside>
);
}
```
- [ ] **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. (`<Transcript>` 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 `<Transcript>`; 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 && (<SessionDocumentsPanel … />)}` block, add:
```tsx
{showActivity && <ModelActivityPanel onClose={() => setShowActivity(false)} />}
```
(e) Remove `<Transcript />` from the centre and show `<CentralStatus />`. Replace the
`activeSessionId ? (<><Transcript /><WidgetHost … /></>) : (<EmptyState />)` block with:
```tsx
{activeSessionId ? (
<>
<CentralStatus />
<WidgetHost sessionId={activeSessionId} />
</>
) : (
<EmptyState />
)}
```
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 && (
<WorkingSpinner className="absolute left-4 top-[1.35rem]" />
)}
```
(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 `<div className="rounded-2xl border …">` composer box, add (only when a
session is active):
```tsx
{activeSessionId && (
<div className="mb-1.5 flex justify-start">
<button
type="button"
onClick={toggleActivity}
aria-label="Model activity"
aria-pressed={showActivity}
title="Model activity"
className="grid size-7 place-items-center rounded-md border border-border text-primary transition-colors hover:bg-muted"
>
<WorkingSpinner spinning={working} />
</button>
</div>
)}
```
(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 (
<svg
viewBox="0 0 24 24"
fill="none"
role="status"
aria-label="Assistant is working"
className={["size-4 text-primary", spinning ? "animate-spin" : "", className].filter(Boolean).join(" ")}
>
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="3" opacity="0.2" />
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
</svg>
);
}
```
- [ ] **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.