feat(frontend): rotating activity icon + 5-line expandable activity panel
- CentralStatus: replace the red pulsing dot at the start of the "working" line with the rotating WorkingSpinner, now a button that opens the model-activity panel (onOpenActivity). WorkingSpinner is extracted to its own module (was local to AppShell). - ModelActivityPanel: show the last 5 lines of the model-stream tail (refreshing as the stream grows) with an expand toggle to the full stream, replacing the unbounded full transcript. - AppShell: drop the separate spinner button (the inline icon is now the single trigger) and the local WorkingSpinner def. - Remove the now-orphaned Transcript.tsx (its only consumer was the panel). TDD: CentralStatus + ModelActivityPanel tests RED->GREEN; frontend suite 87/87; tsc clean. Live visual verification pending (to do with the B/C frontend pass). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -176,7 +176,7 @@ export function AppShell() {
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-6">
|
||||
{activeSessionId ? (
|
||||
<>
|
||||
<CentralStatus working={working} />
|
||||
<CentralStatus working={working} onOpenActivity={toggleActivity} />
|
||||
<WidgetHost sessionId={activeSessionId} />
|
||||
</>
|
||||
) : (
|
||||
@@ -188,20 +188,6 @@ export function AppShell() {
|
||||
{/* Sticky composer: the box stays pinned while the conversation scrolls above. */}
|
||||
<div className="shrink-0 px-6 pb-6 pt-2">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
{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>
|
||||
)}
|
||||
<div className="rounded-2xl border border-input bg-card shadow-sm transition-colors focus-within:border-primary/40 focus-within:ring-3 focus-within:ring-ring/15">
|
||||
<div className="px-2.5 py-2">
|
||||
<SteerInput
|
||||
@@ -367,22 +353,6 @@ export function AppShell() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Brand-tinted spinner shown while the assistant/harness is working. */
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="flex min-h-[60vh] flex-col items-center justify-center text-center">
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeEach } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, vi } from "vitest";
|
||||
import { useSessionStore } from "../store/sessionStore";
|
||||
import { CentralStatus } from "./CentralStatus";
|
||||
|
||||
beforeEach(() => useSessionStore.getState().resetSession());
|
||||
|
||||
test("the working activity icon opens the model-activity panel", async () => {
|
||||
const onOpenActivity = vi.fn();
|
||||
useSessionStore.getState().applyEvent({ type: "text_delta", text: "reading the schema" });
|
||||
render(<CentralStatus working onOpenActivity={onOpenActivity} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: /model activity/i }));
|
||||
expect(onOpenActivity).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("renders nothing when not working and there is no user entry or step messages", () => {
|
||||
const { container } = render(<CentralStatus working={false} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useReducer, useState } from "react";
|
||||
import { useSessionStore } from "../store/sessionStore";
|
||||
import { WorkingSpinner } from "./WorkingSpinner";
|
||||
|
||||
/** Last few words of a string — a compact "what the model is doing now" tail. */
|
||||
function lastWords(s: string, n = 8): string {
|
||||
@@ -11,7 +12,13 @@ function lastWords(s: string, n = 8): string {
|
||||
* (elapsed time + a short tail of the model stream) while the model is busy, and the
|
||||
* gate's curated messages for the current step. The full verbose stream lives in the
|
||||
* left Model-activity panel. */
|
||||
export function CentralStatus({ working }: { working: boolean }) {
|
||||
export function CentralStatus({
|
||||
working,
|
||||
onOpenActivity,
|
||||
}: {
|
||||
working: boolean;
|
||||
onOpenActivity?: () => void;
|
||||
}) {
|
||||
const lastUserEntry = useSessionStore((s) => s.lastUserEntry);
|
||||
const stepMessages = useSessionStore((s) => s.stepMessages);
|
||||
const transcript = useSessionStore((s) => s.transcript);
|
||||
@@ -49,7 +56,15 @@ export function CentralStatus({ working }: { working: boolean }) {
|
||||
|
||||
{working && (
|
||||
<div className="flex min-w-0 items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="size-1.5 shrink-0 animate-pulse rounded-full bg-primary" aria-hidden />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenActivity}
|
||||
aria-label="Show model activity"
|
||||
title="Model activity"
|
||||
className="grid size-5 shrink-0 place-items-center rounded text-primary transition-colors hover:bg-muted"
|
||||
>
|
||||
<WorkingSpinner spinning className="size-3.5" />
|
||||
</button>
|
||||
<span className="shrink-0 tabular-nums">{elapsed}s</span>
|
||||
{tail && <span className="min-w-0 truncate text-muted-foreground/70">· {tail}</span>}
|
||||
<span className="sr-only">ThothII is working</span>
|
||||
|
||||
@@ -18,3 +18,21 @@ test("close button calls onClose", async () => {
|
||||
await userEvent.click(screen.getByRole("button", { name: /close/i }));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const EIGHT = ["line-01", "line-02", "line-03", "line-04", "line-05", "line-06", "line-07", "line-08"].join("\n");
|
||||
|
||||
test("collapsed shows only the last 5 lines of the model-stream tail", () => {
|
||||
useSessionStore.getState().applyEvent({ type: "text_delta", text: EIGHT });
|
||||
render(<ModelActivityPanel onClose={vi.fn()} />);
|
||||
expect(screen.getByText("line-08")).toBeInTheDocument();
|
||||
expect(screen.getByText("line-04")).toBeInTheDocument();
|
||||
expect(screen.queryByText("line-03")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("line-01")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("expanding reveals the full stream", async () => {
|
||||
useSessionStore.getState().applyEvent({ type: "text_delta", text: EIGHT });
|
||||
render(<ModelActivityPanel onClose={vi.fn()} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: /show more/i }));
|
||||
expect(screen.getByText("line-01")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1,20 +1,57 @@
|
||||
import { X } from "lucide-react";
|
||||
import { Transcript } from "./Transcript";
|
||||
import { useState } from "react";
|
||||
import { ChevronDown, ChevronUp, X } from "lucide-react";
|
||||
import { useSessionStore } from "../store/sessionStore";
|
||||
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. */
|
||||
const TAIL_LINES = 5;
|
||||
|
||||
/** Left drawer showing "what's happening": the tail of the model's streamed output.
|
||||
* Collapsed it shows the last few lines (refreshing as the stream grows); the expand
|
||||
* toggle reveals the full stream. Opened on demand from the rotating activity icon. */
|
||||
export function ModelActivityPanel({ onClose }: { onClose: () => void }) {
|
||||
const transcript = useSessionStore((s) => s.transcript);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const lines = transcript
|
||||
.map((e) => e.text)
|
||||
.join("\n")
|
||||
.split("\n")
|
||||
.map((l) => l.trimEnd())
|
||||
.filter((l) => l.trim() !== "");
|
||||
const shown = expanded ? lines : lines.slice(-TAIL_LINES);
|
||||
const canExpand = lines.length > TAIL_LINES;
|
||||
|
||||
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 className="flex items-center gap-1">
|
||||
{canExpand && (
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label={expanded ? "Show less" : "Show more"}
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
{expanded ? <ChevronUp className="size-4" /> : <ChevronDown className="size-4" />}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="icon-sm" variant="ghost" aria-label="Close model activity" onClick={onClose}>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4">
|
||||
<Transcript />
|
||||
{shown.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No activity yet.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1 font-mono text-xs leading-relaxed text-foreground/90">
|
||||
{shown.map((l, i) => (
|
||||
<p key={i} className="whitespace-pre-wrap break-words">{l}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
// frontend/src/shell/Transcript.tsx
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { useSessionStore } from "../store/sessionStore";
|
||||
|
||||
/**
|
||||
* Renders the streamed assistant conversation. The store accumulates assistant
|
||||
* text deltas in `transcript`; this is the surface that shows them.
|
||||
*/
|
||||
export function Transcript() {
|
||||
const transcript = useSessionStore((s) => s.transcript);
|
||||
if (transcript.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{transcript.map((entry, i) => (
|
||||
<article key={i} className="flex flex-col gap-1.5">
|
||||
<span className="font-heading text-xs font-semibold uppercase tracking-[0.12em] text-primary/80">
|
||||
ThothII
|
||||
</span>
|
||||
<div className="thot-prose">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{entry.text}</ReactMarkdown>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/** The rotating "assistant is working" icon. Spins while `spinning`; used as the
|
||||
* inline activity trigger in CentralStatus. */
|
||||
export 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user