From ccfa3a18bf27755c6f86e8dcd74541153d6bb185 Mon Sep 17 00:00:00 2001 From: mptyl Date: Sat, 18 Jul 2026 12:33:25 +0200 Subject: [PATCH] feat(frontend): pulse the stop dot while the harness is working MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Give live feedback that something is happening the moment a session starts processing: the red stop dot in the composer beats with a soft heartbeat + halo while the harness is actively working (running — not waiting at a gate or finalized), and settles the instant the model hands control back to the reviewer or the session ends. Ring/scale only (no layout properties animated); honors prefers-reduced-motion. Co-Authored-By: Claude Opus 4.8 --- frontend/src/index.css | 14 ++++++++++++++ frontend/src/shell/AppShell.tsx | 1 + frontend/src/shell/SteerInput.test.tsx | 9 +++++++++ frontend/src/shell/SteerInput.tsx | 8 +++++++- 4 files changed, 31 insertions(+), 1 deletion(-) diff --git a/frontend/src/index.css b/frontend/src/index.css index f4c6b0e0..5800585b 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -249,9 +249,23 @@ .thot-awaiting-input:focus { box-shadow: 0 0 0 3px oklch(var(--success) / 0.18); } + /* Live-activity pulse: a soft ring emanates from the red stop dot while the harness + is actively working, signalling "something is happening". Ring only (no layout + properties animated); calm 1.6s ease-out cadence, silenced for reduced motion. */ + .thot-pulse { + animation: thot-pulse-ring 1.5s ease-in-out infinite; + } + @media (prefers-reduced-motion: reduce) { + .thot-pulse { animation: none; } + } .thot-prose p + p { margin-top: 1em; } .thot-prose ul { list-style: disc; } .thot-prose ol { list-style: decimal; } .thot-prose ul ul { list-style: circle; } .thot-prose ol ol { list-style: lower-alpha; } } + +@keyframes thot-pulse-ring { + 0%, 100% { transform: scale(1); box-shadow: 0 0 0 0 oklch(var(--primary) / 0.4); } + 50% { transform: scale(1.15); box-shadow: 0 0 0 5px oklch(var(--primary) / 0); } +} diff --git a/frontend/src/shell/AppShell.tsx b/frontend/src/shell/AppShell.tsx index 9f590bb7..4921ba57 100644 --- a/frontend/src/shell/AppShell.tsx +++ b/frontend/src/shell/AppShell.tsx @@ -508,6 +508,7 @@ export function AppShell() { onStop={() => setStopConfirm(true)} inputRef={composerRef} awaitingInput={awaitingQuestion || pendingWidget?.widget === "freetext"} + working={running} /> diff --git a/frontend/src/shell/SteerInput.test.tsx b/frontend/src/shell/SteerInput.test.tsx index 4164cfd7..4f8033be 100644 --- a/frontend/src/shell/SteerInput.test.tsx +++ b/frontend/src/shell/SteerInput.test.tsx @@ -82,3 +82,12 @@ test("marks the composer as awaiting input only when requested", () => { expect(input).toHaveAttribute("data-awaiting-input", "true"); expect(input).toHaveClass("thot-awaiting-input"); }); + +test("pulses the stop dot only while the harness is working", () => { + const { rerender } = render(); + const dot = () => + screen.getByRole("button", { name: /stop and save session/i }).querySelector("span"); + expect(dot()).toHaveClass("thot-pulse"); + rerender(); + expect(dot()).not.toHaveClass("thot-pulse"); +}); diff --git a/frontend/src/shell/SteerInput.tsx b/frontend/src/shell/SteerInput.tsx index 110060a5..7909c971 100644 --- a/frontend/src/shell/SteerInput.tsx +++ b/frontend/src/shell/SteerInput.tsx @@ -26,6 +26,8 @@ interface Props { inputRef?: React.Ref; /** The workflow is explicitly waiting for text in this composer. */ awaitingInput?: boolean; + /** The harness is actively working: pulse the stop dot to signal live activity. */ + working?: boolean; } export function SteerInput({ @@ -36,6 +38,7 @@ export function SteerInput({ onStop, inputRef, awaitingInput = false, + working = false, }: Props) { const [text, setText] = useState(""); const [busy, setBusy] = useState(false); @@ -115,7 +118,10 @@ export function SteerInput({ aria-label="Stop and save session" className="grid size-7 shrink-0 place-items-center rounded-md border border-border text-primary transition-colors hover:bg-muted" > - + )}