768 lines
30 KiB
Markdown
768 lines
30 KiB
Markdown
# 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.
|