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:
2026-06-30 16:26:43 +02:00
co-authored by Claude Opus 4.8
parent c12bdcd257
commit 0eeb3f7522
7 changed files with 115 additions and 70 deletions
+45 -8
View File
@@ -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>
);