Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions app/src/features/bots/BotStateBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,19 @@ import {
Power,
RotateCw,
} from "lucide-react";
import { useState } from "react";
import { type Messages, t, useT } from "../../i18n";
import type { Bot, BotState } from "../../lib/protocol.gen";
import { TONES, type Tone as ToneName } from "../../ui/tone";
import { ReadyIcon, type StateIcon, WorkingIcon } from "./StateIcons";
import { type StateIcon, WorkingIcon } from "./StateIcons";

type Tone = Exclude<ToneName, "accent">;

export interface StateView {
label: string;
tone: Tone;
icon: StateIcon;
/** None for idle: a bot doing nothing is free, which needs no badge. */
icon: StateIcon | null;
spin?: boolean;
/** One sentence on what the state means for the owner. */
hint: string;
Expand All @@ -30,7 +32,7 @@ type Look = Omit<StateView, "label" | "hint">;
const looks: Record<BotState, Look> = {
offline: { tone: "quiet", icon: Power },
launching: { tone: "work", icon: LoaderCircle, spin: true },
idle: { tone: "ok", icon: ReadyIcon },
idle: { tone: "ok", icon: null },
busy: { tone: "work", icon: WorkingIcon },
needs_approval: { tone: "warn", icon: Hand },
rate_limited: { tone: "warn", icon: Hourglass },
Expand Down Expand Up @@ -70,10 +72,19 @@ export function BotStateBadge({
const messages = useT();
const view = stateView(bot, crewPaused, messages);
const Icon = view.icon;
// The badge fades in when a bot leaves idle in front of the owner; one
// that was already there when the list opened shows still.
const [wasShown, setWasShown] = useState(Icon !== null);
const [entering, setEntering] = useState(false);
if ((Icon !== null) !== wasShown) {
setWasShown(Icon !== null);
setEntering(Icon !== null);
}
if (!Icon) return null;
return (
<span
title={view.hint}
className={`inline-flex shrink-0 items-center gap-1 font-medium transition-colors duration-300 ${compact ? "text-xs" : "text-sm"} ${TONES[view.tone].text}`}
className={`inline-flex shrink-0 items-center gap-1 font-medium transition-colors duration-300 ${compact ? "text-xs" : "text-sm"} ${TONES[view.tone].text} ${entering ? "animate-fade" : ""}`}
>
<Icon
aria-hidden
Expand Down
26 changes: 3 additions & 23 deletions app/src/features/bots/StateIcons.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Two state icons that move (motion.css), where a still lucide icon would
// say less: a ready bot pulses, a working one runs a line. Both keep the
// lucide look (24 grid, 2px stroke) so they sit with the other states.
// A state icon that moves (motion.css), where a still lucide icon would
// say less: a working bot runs a line. It keeps the lucide look (24 grid,
// 2px stroke) so it sits with the other states.

import type { ComponentType } from "react";

Expand All @@ -13,26 +13,6 @@ export interface StateIconProps {
/** A state's icon: one of these, or any lucide icon. */
export type StateIcon = ComponentType<StateIconProps>;

/** "Ready": a dot with two rings spreading from it, one after the other. */
export function ReadyIcon({ size = 14, className = "" }: StateIconProps) {
return (
<svg
aria-hidden
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
className={className}
>
<circle className="state-ring" cx="12" cy="12" r="5" />
<circle className="state-ring state-ring-late" cx="12" cy="12" r="5" />
<circle cx="12" cy="12" r="5" fill="currentColor" stroke="none" />
</svg>
);
}

// The line of lucide's Activity, the icon this state had before it moved.
const PULSE =
"M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2";
Expand Down
15 changes: 13 additions & 2 deletions app/src/features/bots/avatar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,15 +106,26 @@ describe("mascot", () => {
});

describe("state badge", () => {
test("ready pulses and working runs its line", () => {
test("idle shows nothing and working runs its line", () => {
const { container, rerender } = render(
<BotStateBadge bot={{ state: "idle", paused: false }} />,
);
expect(container.querySelectorAll(".state-ring")).toHaveLength(2);
expect(container.textContent).toBe("");

rerender(<BotStateBadge bot={{ state: "busy", paused: false }} />);
expect(container.querySelector(".state-trace")?.getAttribute("pathLength")).toBe("100");
});

test("fades in when the bot starts working, not when it was already working", () => {
const { container, rerender } = render(
<BotStateBadge bot={{ state: "busy", paused: false }} />,
);
expect(container.querySelector(".animate-fade")).toBeNull();

rerender(<BotStateBadge bot={{ state: "idle", paused: false }} />);
rerender(<BotStateBadge bot={{ state: "busy", paused: false }} />);
expect(container.querySelector(".animate-fade")).not.toBeNull();
});
});

describe("ListAvatar", () => {
Expand Down
30 changes: 3 additions & 27 deletions app/src/motion.css
Original file line number Diff line number Diff line change
Expand Up @@ -111,25 +111,12 @@
}

@layer components {
/* The state icons of "Ready" and "Working" (features/bots/StateIcons.tsx):
two rings spread from the dot, one after the other; a bright stretch
runs along the pulse line. The mascot has its own file (mascot.css). */
.state-ring,
/* The state icon of "Working" (features/bots/StateIcons.tsx): a bright
stretch runs along the pulse line. The mascot has its own file
(mascot.css). */
.state-trace {
transform-box: fill-box;
transform-origin: 50%;
}

.state-ring {
opacity: 0;
animation: ring 2.6s var(--ease-out) infinite;
}

.state-ring-late {
animation-delay: 1.3s;
}

.state-trace {
stroke-dasharray: 26 74;
animation: trace 1.5s linear infinite;
}
Expand Down Expand Up @@ -162,17 +149,6 @@
}
}

@keyframes ring {
from {
opacity: 0.6;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(2);
}
}

@keyframes trace {
to {
stroke-dashoffset: -100px;
Expand Down
Loading
Loading