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:
@@ -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.
|
||||
Reference in New Issue
Block a user