Files
ThothII/docs/superpowers/plans/2026-06-29-session-ui-refinements.md
T

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/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:

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 } | 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:

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), 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:

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:

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.