30 KiB
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/infoof the current step + active widget (WidgetHost). The fulltext_deltastream is NOT in the centre. - Verbose model stream → left panel, on demand. The whole
transcript(streamed model text) renders in a leftModelActivityPanel, opened by clicking the WIP icon. - WIP icon moves from the right-rail header to above the composer; it is a
<button>that toggles theModelActivityPaneland spins whileworking. - Step messages reset on each user entry: the central
notify/infolist is cleared when a newlastUserEntryis set. - Group rename reassigns each member via
setSessionGroup(id, newName)(groups are derived from the manifestgroupfield; non-atomic across members,toast.erroron failure). - Left-region exclusivity: the read-only
SessionDocumentsPanel(clicked-session docs) and theModelActivityPanel(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 toAppShell.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
showArchivetoggle. -
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:
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:
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:
<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
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"):
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:
const [renameGroupTarget, setRenameGroupTarget] = useState<string | null>(null);
Add the handler near move/newGroup:
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:
<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):
{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
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 } | nullstepMessages: { level: string; text: string }[]setLastUserEntry: (e: { kind: "input" | "choice"; text: string }) => void— sets the entry AND clearsstepMessages.applyEventfor aninfoevent now appends tostepMessages(instead oftoasts).
-
Step 1: Write the failing test
Create frontend/src/store/sessionStore.test.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:
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:
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):
if (e.type === "info") return { stepMessages: [...st.stepMessages, { level: e.level ?? "info", text: e.text }] };
Add the setter (next to pushToast):
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:
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:
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
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:
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:
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:
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:
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
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), storetranscript/lastUserEntry(Task 3). -
Produces: central area without
<Transcript>; the WIP icon above the composer toggling the leftModelActivityPanel; left-region exclusivity withSessionDocumentsPanel. -
Step 1: Write the failing test
Append to frontend/src/shell/AppShell.session-mgmt.test.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
ModelActivityPanelheader ("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:
import { CentralStatus } from "./CentralStatus";
import { ModelActivityPanel } from "./ModelActivityPanel";
(b) Add state near the other panel state:
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:
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:
{showActivity && <ModelActivityPanel onClose={() => setShowActivity(false)} />}
(e) Remove <Transcript /> from the centre and show <CentralStatus />. Replace the
activeSessionId ? (<><Transcript /><WidgetHost … /></>) : (<EmptyState />) block with:
{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:
{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):
{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:
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
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.