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
23 changes: 23 additions & 0 deletions app/src/features/bots/BotAvatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,14 @@ export function BotAvatar({
size = 28,
framed = false,
mood,
still = false,
}: {
color: string;
size?: number;
framed?: boolean;
mood?: Mood | undefined;
/** Shows the mood without moving. */
still?: boolean;
}) {
// Every avatar on the page needs its own clip, blur and gradients.
const unique = useId().replace(/[^\w-]/g, "");
Expand All @@ -106,6 +109,7 @@ export function BotAvatar({
viewBox={box.join(" ")}
className={`shrink-0 overflow-visible ${framed ? "rounded-lg bg-[#0b0b0b]" : ""}`}
data-mood={mood}
data-still={still || undefined}
style={style}
>
<defs>
Expand Down Expand Up @@ -203,3 +207,22 @@ function Eye({ eye, side, fill }: { eye: Ellipse; side: "l" | "r"; fill: string
</g>
);
}

/**
* A bot's mascot in a list (the sidebar, a crew's cards): it moves only
* while the bot does something. One idle mascot per bot, all moving, cost
* a repaint every frame even with nothing going on.
*/
export function ListAvatar({
bot,
crewPaused,
size,
}: {
bot: Pick<Bot, "color" | "state" | "paused">;
crewPaused: boolean;
size: number;
}) {
const mood = moodOf(bot, crewPaused);
const still = mood === "idle" || mood === "sleeping";
return <BotAvatar color={bot.color} size={size} mood={mood} still={still} />;
}
2 changes: 1 addition & 1 deletion app/src/features/bots/BotView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) {
[side, leaving],
);
const [pane, setPane] = useState<Pane>("chat");
const files = useBotFiles(bot);
const files = useBotFiles(bot, side === "files");
// What the owner has seen: files newer than this are new to them.
const [seenAt, setSeenAt] = useState(() => Date.now());
const [since, setSince] = useState(seenAt);
Expand Down
27 changes: 26 additions & 1 deletion app/src/features/bots/avatar.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import flameFrames from "../../mascot-flame.css?raw";
import { BOTLOFT_COLOR, BotAvatar } from "./BotAvatar";
import { BOTLOFT_COLOR, BotAvatar, ListAvatar } from "./BotAvatar";
import { BotStateBadge } from "./BotStateBadge";
import { BODY, DRAWN_IN, OUTLINE, SHADES } from "./mascotArt";
import { retint, toHex, toHsl } from "./retint";
Expand Down Expand Up @@ -116,3 +116,28 @@ describe("state badge", () => {
expect(container.querySelector(".state-trace")?.getAttribute("pathLength")).toBe("100");
});
});

describe("ListAvatar", () => {
const bot = (state: "idle" | "busy" | "offline") => ({
color: BOTLOFT_COLOR,
state,
paused: false,
});

test("a bot at rest keeps its look but does not move; a working one moves", () => {
const { container, rerender } = render(
<ListAvatar bot={bot("idle")} crewPaused={false} size={32} />,
);
const svg = () => container.querySelector("svg") as SVGElement;
expect(svg().dataset.mood).toBe("idle");
expect(svg().hasAttribute("data-still")).toBe(true);

rerender(<ListAvatar bot={bot("offline")} crewPaused={false} size={32} />);
expect(svg().dataset.mood).toBe("sleeping");
expect(svg().hasAttribute("data-still")).toBe(true);

rerender(<ListAvatar bot={bot("busy")} crewPaused={false} size={32} />);
expect(svg().dataset.mood).toBe("working");
expect(svg().hasAttribute("data-still")).toBe(false);
});
});
4 changes: 2 additions & 2 deletions app/src/features/crews/CrewBots.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useT } from "../../i18n";
import type { Bot, Crew } from "../../lib/protocol.gen";
import { useApp } from "../../store/context";
import { Button } from "../../ui/Button";
import { BotAvatar, moodOf } from "../bots/BotAvatar";
import { ListAvatar } from "../bots/BotAvatar";
import { BotStateBadge } from "../bots/BotStateBadge";
import { ChiefBadge, isChief } from "../bots/ChiefBadge";

Expand Down Expand Up @@ -32,7 +32,7 @@ export function CrewBots({ crew, bots, onNewBot }: { crew: Crew; bots: Bot[]; on
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"
>
<div className="flex w-full items-center gap-2.5">
<BotAvatar color={bot.color} size={32} mood={moodOf(bot, crew.paused)} />
<ListAvatar bot={bot} crewPaused={crew.paused} size={32} />
<div className="min-w-0 flex-1">
<p className="flex items-center gap-1.5">
<span className="truncate font-semibold">{bot.name}</span>
Expand Down
4 changes: 2 additions & 2 deletions app/src/features/crews/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { Button } from "../../ui/Button";
import { ContextMenu, menuPoint, type Point } from "../../ui/ContextMenu";
import { APP_OPENED } from "../../ui/motion";
import { AccountArea } from "../account/AccountArea";
import { BotAvatar, moodOf } from "../bots/BotAvatar";
import { ListAvatar } from "../bots/BotAvatar";
import { BotStateBadge, stateView } from "../bots/BotStateBadge";
import { useBotActions } from "../bots/botActions";
import { ChiefBadge, isChief } from "../bots/ChiefBadge";
Expand Down Expand Up @@ -106,7 +106,7 @@ function Conversation({ bot, crew }: { bot: Bot; crew: Crew }) {
}}
className={`${row} gap-2.5 px-2.5 py-2 ${selected || menuAt ? "bg-sunken" : ""}`}
>
<BotAvatar color={bot.color} size={32} mood={moodOf(bot, crew.paused)} />
<ListAvatar bot={bot} crewPaused={crew.paused} size={32} />
<span className="min-w-0 flex-1">
<span className="flex items-baseline gap-2">
<span className="flex min-w-0 flex-1 items-center gap-1">
Expand Down
19 changes: 13 additions & 6 deletions app/src/features/files/useBotFiles.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
// The files a bot made, kept current (spec 8.5, 15.1): read when the bot is
// opened, and again while it works and when it stops, since a scan of its
// folders is the only way to see what a script or a command made.
// opened, again while it works with the files panel in sight, and when it
// stops, since a scan of its folders is the only way to see what a script
// or a command made. Each read walks the folders, so nobody pays for it
// while the list is out of sight.

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

/** How often the list is read while the bot works. */
/** How often the list is read while the bot works and the panel is in sight. */
const WHILE_WORKING_MS = 4000;

export interface BotFiles {
Expand All @@ -18,7 +21,8 @@ export interface BotFiles {
refresh(): Promise<void>;
}

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

const working = bot.state === "busy" || bot.state === "needs_approval";
const wasWorking = useRef(working);
const inSight = useWindowVisible() && watching;
useEffect(() => {
if (!open) {
return;
Expand All @@ -69,12 +74,14 @@ export function useBotFiles(bot: Pick<Bot, "id" | "state">): BotFiles {
void refresh();
}
wasWorking.current = working;
if (!working) {
if (!working || !inSight) {
return;
}
// Coming into sight mid-turn shows what was made meanwhile.
void refresh();
const timer = setInterval(() => void refresh(), WHILE_WORKING_MS);
return () => clearInterval(timer);
}, [open, working, refresh]);
}, [open, working, inSight, refresh]);

return { files, loading, error, refresh };
}
1 change: 1 addition & 0 deletions app/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
@import "./panels.css";
@import "./effort.css";
@import "./motion-less.css";
@import "./paused.css";

/* Colors are tokens so the light and dark themes swap in one place
(spec 15.3). State colors always go with an icon and a label. */
Expand Down
7 changes: 7 additions & 0 deletions app/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { connect, rpcUrl } from "./lib/client";
import type { Host } from "./lib/host";
import { tauriHost } from "./lib/tauriHost";
import { prefs } from "./shell/prefs";
import { markHidden, watchVisibility } from "./shell/visibility";
import { currentZoom } from "./shell/zoom";
import "./index.css";

Expand Down Expand Up @@ -37,6 +38,12 @@ props().then(({ host, connect: open }) => {
if (prefs.lessMotion.get()) {
document.documentElement.dataset.motion = "less";
}
watchVisibility();
// Opened at sign-in, the window stays hidden until the owner opens it.
host.launchedAtSignIn().then(
(hidden) => hidden && !document.hasFocus() && markHidden(true),
() => {},
);
createRoot(root).render(
<StrictMode>
<App host={host} connect={open} />
Expand Down
7 changes: 7 additions & 0 deletions app/src/mascot.css
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,13 @@
animation: spark 1.5s ease-out infinite;
animation-delay: var(--spark-delay, 0s);
}

/* In a list, a bot at rest keeps its look (shut eyes, low flame) but
does not move: a morphing clip path repaints every frame, one per
mascot on screen. */
[data-mood][data-still] * {
animation: none;
}
}

@keyframes eye-blink {
Expand Down
10 changes: 10 additions & 0 deletions app/src/paused.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/* The window is out of sight (shell/visibility.ts): nothing animates for
nobody. Paused, not stopped, so each animation goes on where it was. */

/* biome-ignore-start lint/complexity/noImportantStyles: a hidden window must pause every animation, inline ones included */
:root[data-hidden] *,
:root[data-hidden] *::before,
:root[data-hidden] *::after {
animation-play-state: paused !important;
}
/* biome-ignore-end lint/complexity/noImportantStyles: a hidden window must pause every animation, inline ones included */
2 changes: 2 additions & 0 deletions app/src/shell/closing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useEffect } from "react";
import type { Host } from "../lib/host";
import { prefs } from "./prefs";
import { trayShown } from "./tray";
import { markHidden } from "./visibility";

/**
* Stops the bots as the window closes, when the owner chose so. The window
Expand All @@ -26,6 +27,7 @@ export function useCloseBehavior(host: Host): void {
}
if (trayShown()) {
await host.window.hide().catch(() => {});
markHidden(true);
return "stay";
}
return "close";
Expand Down
74 changes: 74 additions & 0 deletions app/src/shell/visibility.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { act, cleanup, renderHook } from "@testing-library/react";
import type { ReactNode } from "react";
import { afterEach, beforeAll, describe, expect, test, vi } from "vitest";
import { useBotFiles } from "../features/files/useBotFiles";
import { FakeBotloft } from "../lib/fake";
import { createAppStore } from "../store/app";
import { DaemonProvider } from "../store/context";
import { syncStore } from "../store/sync";
import { markHidden, watchVisibility, windowHidden } from "./visibility";

beforeAll(watchVisibility);

afterEach(() => {
markHidden(false);
cleanup();
vi.useRealTimers();
});

const polls = (fake: FakeBotloft, method: string) =>
fake.calls.filter((call) => call.method === method).length;

describe("window visibility", () => {
test("hiding the window pauses the animations until it comes back", () => {
markHidden(true);
expect(windowHidden()).toBe(true);
expect(document.documentElement.hasAttribute("data-hidden")).toBe(true);
window.dispatchEvent(new Event("focus"));
expect(windowHidden()).toBe(false);
expect(document.documentElement.hasAttribute("data-hidden")).toBe(false);
});

test("the status is not polled while the window is hidden, and is read when it comes back", async () => {
vi.useFakeTimers();
const fake = new FakeBotloft();
const stop = syncStore(createAppStore(fake), fake);
await vi.advanceTimersByTimeAsync(0);
const before = polls(fake, "system.status");
markHidden(true);
await vi.advanceTimersByTimeAsync(60_000);
expect(polls(fake, "system.status")).toBe(before);
markHidden(false);
await vi.advanceTimersByTimeAsync(0);
expect(polls(fake, "system.status")).toBe(before + 1);
stop();
});

test("a working bot's files are read over and over only while their panel is in sight", async () => {
vi.useFakeTimers();
const fake = new FakeBotloft();
const crew = fake.addCrew("Ops");
const scout = fake.addBot(crew.id, "Scout");
const wrapper = ({ children }: { children: ReactNode }) => (
<DaemonProvider api={fake}>{children}</DaemonProvider>
);
const busy = { id: scout.id, state: "busy" as const };
const { rerender } = renderHook(({ watching }) => useBotFiles(busy, watching), {
wrapper,
initialProps: { watching: false },
});
await act(() => vi.advanceTimersByTimeAsync(0));
const opened = polls(fake, "files.list");
await act(() => vi.advanceTimersByTimeAsync(20_000));
expect(polls(fake, "files.list")).toBe(opened);

rerender({ watching: true });
await act(() => vi.advanceTimersByTimeAsync(8_000));
expect(polls(fake, "files.list")).toBeGreaterThanOrEqual(opened + 3);

act(() => markHidden(true));
const hidden = polls(fake, "files.list");
await act(() => vi.advanceTimersByTimeAsync(20_000));
expect(polls(fake, "files.list")).toBe(hidden);
});
});
48 changes: 48 additions & 0 deletions app/src/shell/visibility.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// Whether the owner can see the window: not minimized, not hidden near the
// clock (spec 15.1), not opened hidden at sign-in. While they cannot, the
// app stops animating (paused.css) and stops asking the daemon for what
// only the screen needs.
//
// Windows may not tell the page when the app hides its own window, so the
// app marks that itself; the window coming back to the front clears it.

import { useSyncExternalStore } from "react";

let hiddenByApp = false;
const listeners = new Set<() => void>();

export function windowHidden(): boolean {
return hiddenByApp || document.visibilityState === "hidden";
}

function changed(): void {
document.documentElement.toggleAttribute("data-hidden", windowHidden());
for (const listener of listeners) {
listener();
}
}

/** The app hid its window, or showed it again. */
export function markHidden(hidden: boolean): void {
if (hiddenByApp !== hidden) {
hiddenByApp = hidden;
changed();
}
}

/** Calls `listener` when the window is hidden or shown; returns the unsubscribe. */
export function onVisibility(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}

/** Starts following the window; once, before the app renders. */
export function watchVisibility(): void {
document.addEventListener("visibilitychange", changed);
window.addEventListener("focus", () => markHidden(false));
changed();
}

export function useWindowVisible(): boolean {
return useSyncExternalStore(onVisibility, () => !windowHidden());
}
Loading
Loading