feat(frontend): pulse the stop dot while the harness is working
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 <noreply@anthropic.com>
This commit is contained in:
@@ -249,9 +249,23 @@
|
|||||||
.thot-awaiting-input:focus {
|
.thot-awaiting-input:focus {
|
||||||
box-shadow: 0 0 0 3px oklch(var(--success) / 0.18);
|
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 p + p { margin-top: 1em; }
|
||||||
.thot-prose ul { list-style: disc; }
|
.thot-prose ul { list-style: disc; }
|
||||||
.thot-prose ol { list-style: decimal; }
|
.thot-prose ol { list-style: decimal; }
|
||||||
.thot-prose ul ul { list-style: circle; }
|
.thot-prose ul ul { list-style: circle; }
|
||||||
.thot-prose ol ol { list-style: lower-alpha; }
|
.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); }
|
||||||
|
}
|
||||||
|
|||||||
@@ -508,6 +508,7 @@ export function AppShell() {
|
|||||||
onStop={() => setStopConfirm(true)}
|
onStop={() => setStopConfirm(true)}
|
||||||
inputRef={composerRef}
|
inputRef={composerRef}
|
||||||
awaitingInput={awaitingQuestion || pendingWidget?.widget === "freetext"}
|
awaitingInput={awaitingQuestion || pendingWidget?.widget === "freetext"}
|
||||||
|
working={running}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ComposerFooter />
|
<ComposerFooter />
|
||||||
|
|||||||
@@ -82,3 +82,12 @@ test("marks the composer as awaiting input only when requested", () => {
|
|||||||
expect(input).toHaveAttribute("data-awaiting-input", "true");
|
expect(input).toHaveAttribute("data-awaiting-input", "true");
|
||||||
expect(input).toHaveClass("thot-awaiting-input");
|
expect(input).toHaveClass("thot-awaiting-input");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("pulses the stop dot only while the harness is working", () => {
|
||||||
|
const { rerender } = render(<SteerInput sessionId="s1" working />);
|
||||||
|
const dot = () =>
|
||||||
|
screen.getByRole("button", { name: /stop and save session/i }).querySelector("span");
|
||||||
|
expect(dot()).toHaveClass("thot-pulse");
|
||||||
|
rerender(<SteerInput sessionId="s1" working={false} />);
|
||||||
|
expect(dot()).not.toHaveClass("thot-pulse");
|
||||||
|
});
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ interface Props {
|
|||||||
inputRef?: React.Ref<HTMLTextAreaElement>;
|
inputRef?: React.Ref<HTMLTextAreaElement>;
|
||||||
/** The workflow is explicitly waiting for text in this composer. */
|
/** The workflow is explicitly waiting for text in this composer. */
|
||||||
awaitingInput?: boolean;
|
awaitingInput?: boolean;
|
||||||
|
/** The harness is actively working: pulse the stop dot to signal live activity. */
|
||||||
|
working?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SteerInput({
|
export function SteerInput({
|
||||||
@@ -36,6 +38,7 @@ export function SteerInput({
|
|||||||
onStop,
|
onStop,
|
||||||
inputRef,
|
inputRef,
|
||||||
awaitingInput = false,
|
awaitingInput = false,
|
||||||
|
working = false,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [text, setText] = useState("");
|
const [text, setText] = useState("");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -115,7 +118,10 @@ export function SteerInput({
|
|||||||
aria-label="Stop and save session"
|
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"
|
className="grid size-7 shrink-0 place-items-center rounded-md border border-border text-primary transition-colors hover:bg-muted"
|
||||||
>
|
>
|
||||||
<span className="block size-2.5 rounded-full bg-primary" />
|
<span
|
||||||
|
className={["block size-2.5 rounded-full bg-primary", working && "thot-pulse"]
|
||||||
|
.filter(Boolean).join(" ")}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
Reference in New Issue
Block a user