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"
>
-
+
)}