Skip to content

Commit 7dfd36d

Browse files
authored
Merge pull request #78 from httpsphl/perf/app-calm
perf(app): keep still what nobody watches
2 parents 7ab5fba + 546ae4f commit 7dfd36d

15 files changed

Lines changed: 231 additions & 17 deletions

File tree

‎app/src/features/bots/BotAvatar.tsx‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,14 @@ export function BotAvatar({
7777
size = 28,
7878
framed = false,
7979
mood,
80+
still = false,
8081
}: {
8182
color: string;
8283
size?: number;
8384
framed?: boolean;
8485
mood?: Mood | undefined;
86+
/** Shows the mood without moving. */
87+
still?: boolean;
8588
}) {
8689
// Every avatar on the page needs its own clip, blur and gradients.
8790
const unique = useId().replace(/[^\w-]/g, "");
@@ -106,6 +109,7 @@ export function BotAvatar({
106109
viewBox={box.join(" ")}
107110
className={`shrink-0 overflow-visible ${framed ? "rounded-lg bg-[#0b0b0b]" : ""}`}
108111
data-mood={mood}
112+
data-still={still || undefined}
109113
style={style}
110114
>
111115
<defs>
@@ -203,3 +207,22 @@ function Eye({ eye, side, fill }: { eye: Ellipse; side: "l" | "r"; fill: string
203207
</g>
204208
);
205209
}
210+
211+
/**
212+
* A bot's mascot in a list (the sidebar, a crew's cards): it moves only
213+
* while the bot does something. One idle mascot per bot, all moving, cost
214+
* a repaint every frame even with nothing going on.
215+
*/
216+
export function ListAvatar({
217+
bot,
218+
crewPaused,
219+
size,
220+
}: {
221+
bot: Pick<Bot, "color" | "state" | "paused">;
222+
crewPaused: boolean;
223+
size: number;
224+
}) {
225+
const mood = moodOf(bot, crewPaused);
226+
const still = mood === "idle" || mood === "sleeping";
227+
return <BotAvatar color={bot.color} size={size} mood={mood} still={still} />;
228+
}

‎app/src/features/bots/BotView.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) {
6161
[side, leaving],
6262
);
6363
const [pane, setPane] = useState<Pane>("chat");
64-
const files = useBotFiles(bot);
64+
const files = useBotFiles(bot, side === "files");
6565
// What the owner has seen: files newer than this are new to them.
6666
const [seenAt, setSeenAt] = useState(() => Date.now());
6767
const [since, setSince] = useState(seenAt);

‎app/src/features/bots/avatar.test.tsx‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { cleanup, render } from "@testing-library/react";
22
import { afterEach, describe, expect, test } from "vitest";
33
import flameFrames from "../../mascot-flame.css?raw";
4-
import { BOTLOFT_COLOR, BotAvatar } from "./BotAvatar";
4+
import { BOTLOFT_COLOR, BotAvatar, ListAvatar } from "./BotAvatar";
55
import { BotStateBadge } from "./BotStateBadge";
66
import { BODY, DRAWN_IN, OUTLINE, SHADES } from "./mascotArt";
77
import { retint, toHex, toHsl } from "./retint";
@@ -116,3 +116,28 @@ describe("state badge", () => {
116116
expect(container.querySelector(".state-trace")?.getAttribute("pathLength")).toBe("100");
117117
});
118118
});
119+
120+
describe("ListAvatar", () => {
121+
const bot = (state: "idle" | "busy" | "offline") => ({
122+
color: BOTLOFT_COLOR,
123+
state,
124+
paused: false,
125+
});
126+
127+
test("a bot at rest keeps its look but does not move; a working one moves", () => {
128+
const { container, rerender } = render(
129+
<ListAvatar bot={bot("idle")} crewPaused={false} size={32} />,
130+
);
131+
const svg = () => container.querySelector("svg") as SVGElement;
132+
expect(svg().dataset.mood).toBe("idle");
133+
expect(svg().hasAttribute("data-still")).toBe(true);
134+
135+
rerender(<ListAvatar bot={bot("offline")} crewPaused={false} size={32} />);
136+
expect(svg().dataset.mood).toBe("sleeping");
137+
expect(svg().hasAttribute("data-still")).toBe(true);
138+
139+
rerender(<ListAvatar bot={bot("busy")} crewPaused={false} size={32} />);
140+
expect(svg().dataset.mood).toBe("working");
141+
expect(svg().hasAttribute("data-still")).toBe(false);
142+
});
143+
});

‎app/src/features/crews/CrewBots.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { useT } from "../../i18n";
33
import type { Bot, Crew } from "../../lib/protocol.gen";
44
import { useApp } from "../../store/context";
55
import { Button } from "../../ui/Button";
6-
import { BotAvatar, moodOf } from "../bots/BotAvatar";
6+
import { ListAvatar } from "../bots/BotAvatar";
77
import { BotStateBadge } from "../bots/BotStateBadge";
88
import { ChiefBadge, isChief } from "../bots/ChiefBadge";
99

@@ -32,7 +32,7 @@ export function CrewBots({ crew, bots, onNewBot }: { crew: Crew; bots: Bot[]; on
3232
className="flex h-full w-full flex-col gap-2 rounded-xl border border-line bg-panel p-3.5 text-left transition-[border-color,transform,box-shadow] duration-200 hover:-translate-y-0.5 hover:border-line-strong hover:shadow-sm"
3333
>
3434
<div className="flex w-full items-center gap-2.5">
35-
<BotAvatar color={bot.color} size={32} mood={moodOf(bot, crew.paused)} />
35+
<ListAvatar bot={bot} crewPaused={crew.paused} size={32} />
3636
<div className="min-w-0 flex-1">
3737
<p className="flex items-center gap-1.5">
3838
<span className="truncate font-semibold">{bot.name}</span>

‎app/src/features/crews/Sidebar.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { Button } from "../../ui/Button";
1111
import { ContextMenu, menuPoint, type Point } from "../../ui/ContextMenu";
1212
import { APP_OPENED } from "../../ui/motion";
1313
import { AccountArea } from "../account/AccountArea";
14-
import { BotAvatar, moodOf } from "../bots/BotAvatar";
14+
import { ListAvatar } from "../bots/BotAvatar";
1515
import { BotStateBadge, stateView } from "../bots/BotStateBadge";
1616
import { useBotActions } from "../bots/botActions";
1717
import { ChiefBadge, isChief } from "../bots/ChiefBadge";
@@ -107,7 +107,7 @@ const Conversation = memo(function Conversation({ bot, crew }: { bot: Bot; crew:
107107
}}
108108
className={`${row} gap-2.5 px-2.5 py-2 ${selected || menuAt ? "bg-sunken" : ""}`}
109109
>
110-
<BotAvatar color={bot.color} size={32} mood={moodOf(bot, crew.paused)} />
110+
<ListAvatar bot={bot} crewPaused={crew.paused} size={32} />
111111
<span className="min-w-0 flex-1">
112112
<span className="flex items-baseline gap-2">
113113
<span className="flex min-w-0 flex-1 items-center gap-1">

‎app/src/features/files/useBotFiles.ts‎

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
// The files a bot made, kept current (spec 8.5, 15.1): read when the bot is
2-
// opened, and again while it works and when it stops, since a scan of its
3-
// folders is the only way to see what a script or a command made.
2+
// opened, again while it works with the files panel in sight, and when it
3+
// stops, since a scan of its folders is the only way to see what a script
4+
// or a command made. Each read walks the folders, so nobody pays for it
5+
// while the list is out of sight.
46

57
import { useCallback, useEffect, useRef, useState } from "react";
68
import { errorText } from "../../lib/api";
79
import type { Bot, BotFile } from "../../lib/protocol.gen";
10+
import { useWindowVisible } from "../../shell/visibility";
811
import { useApi, useApp } from "../../store/context";
912

10-
/** How often the list is read while the bot works. */
13+
/** How often the list is read while the bot works and the panel is in sight. */
1114
const WHILE_WORKING_MS = 4000;
1215

1316
export interface BotFiles {
@@ -18,7 +21,8 @@ export interface BotFiles {
1821
refresh(): Promise<void>;
1922
}
2023

21-
export function useBotFiles(bot: Pick<Bot, "id" | "state">): BotFiles {
24+
/** `watching`: the files panel is open. */
25+
export function useBotFiles(bot: Pick<Bot, "id" | "state">, watching: boolean): BotFiles {
2226
const api = useApi();
2327
const open = useApp((state) => state.connection.kind === "open");
2428
const [files, setFiles] = useState<BotFile[]>([]);
@@ -60,6 +64,7 @@ export function useBotFiles(bot: Pick<Bot, "id" | "state">): BotFiles {
6064

6165
const working = bot.state === "busy" || bot.state === "needs_approval";
6266
const wasWorking = useRef(working);
67+
const inSight = useWindowVisible() && watching;
6368
useEffect(() => {
6469
if (!open) {
6570
return;
@@ -69,12 +74,14 @@ export function useBotFiles(bot: Pick<Bot, "id" | "state">): BotFiles {
6974
void refresh();
7075
}
7176
wasWorking.current = working;
72-
if (!working) {
77+
if (!working || !inSight) {
7378
return;
7479
}
80+
// Coming into sight mid-turn shows what was made meanwhile.
81+
void refresh();
7582
const timer = setInterval(() => void refresh(), WHILE_WORKING_MS);
7683
return () => clearInterval(timer);
77-
}, [open, working, refresh]);
84+
}, [open, working, inSight, refresh]);
7885

7986
return { files, loading, error, refresh };
8087
}

‎app/src/index.css‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
@import "./panels.css";
1111
@import "./effort.css";
1212
@import "./motion-less.css";
13+
@import "./paused.css";
1314

1415
/* Colors are tokens so the light and dark themes swap in one place
1516
(spec 15.3). State colors always go with an icon and a label. */

‎app/src/main.tsx‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { connect, rpcUrl } from "./lib/client";
77
import type { Host } from "./lib/host";
88
import { tauriHost } from "./lib/tauriHost";
99
import { prefs } from "./shell/prefs";
10+
import { markHidden, watchVisibility } from "./shell/visibility";
1011
import { currentZoom } from "./shell/zoom";
1112
import "./index.css";
1213

@@ -37,6 +38,12 @@ props().then(({ host, connect: open }) => {
3738
if (prefs.lessMotion.get()) {
3839
document.documentElement.dataset.motion = "less";
3940
}
41+
watchVisibility();
42+
// Opened at sign-in, the window stays hidden until the owner opens it.
43+
host.launchedAtSignIn().then(
44+
(hidden) => hidden && !document.hasFocus() && markHidden(true),
45+
() => {},
46+
);
4047
createRoot(root).render(
4148
<StrictMode>
4249
<App host={host} connect={open} />

‎app/src/mascot.css‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,13 @@
9797
animation: spark 1.5s ease-out infinite;
9898
animation-delay: var(--spark-delay, 0s);
9999
}
100+
101+
/* In a list, a bot at rest keeps its look (shut eyes, low flame) but
102+
does not move: a morphing clip path repaints every frame, one per
103+
mascot on screen. */
104+
[data-mood][data-still] * {
105+
animation: none;
106+
}
100107
}
101108

102109
@keyframes eye-blink {

‎app/src/paused.css‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/* The window is out of sight (shell/visibility.ts): nothing animates for
2+
nobody. Paused, not stopped, so each animation goes on where it was. */
3+
4+
/* biome-ignore-start lint/complexity/noImportantStyles: a hidden window must pause every animation, inline ones included */
5+
:root[data-hidden] *,
6+
:root[data-hidden] *::before,
7+
:root[data-hidden] *::after {
8+
animation-play-state: paused !important;
9+
}
10+
/* biome-ignore-end lint/complexity/noImportantStyles: a hidden window must pause every animation, inline ones included */

0 commit comments

Comments
 (0)