Skip to content
Open
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
143 changes: 138 additions & 5 deletions src/bundled/bestie/BestieTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ import "@testing-library/jest-dom/vitest";
import { act, cleanup, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import type { Agent } from "../../features/agents/service";
import type { Agent, Agents } from "../../features/agents/service";
import type { RelayData } from "../../features/relay/service";
import { writeView } from "../../shared/view-state";
import { bestieTab } from "./BestieTab";
import { memory, withFeature } from "./features";

afterEach(() => {
cleanup();
Expand All @@ -28,6 +29,16 @@ const member = (name: string, digit: string): Agent =>
const ada = member("Ada", "a");
const bea = member("Bea", "b");

/** Agents that save as a synced editor would: the agent's next config. */
function host(types = ["test/echo"]) {
const save = vi.fn<Agents["save"]>(async () => {});
const agents = {
save,
types: () => types.map((key) => ({ key, adopt: () => ({}) })),
} as unknown as Pick<Agents, "save" | "types">;
return { agents, save };
}

function community(saved: string | undefined, agents = [ada, bea]) {
let record = saved;
const choice = (agent: Agent) => ({
Expand Down Expand Up @@ -85,7 +96,7 @@ function community(saved: string | undefined, agents = [ada, bea]) {
it("shows on the saved Bestie only, and moves when Change picks another", async () => {
const user = userEvent.setup();
const { relay, mainAgent } = community(ada.pubkey);
const tab = bestieTab(relay);
const tab = bestieTab(relay, host([]).agents);
const changed = vi.fn();
const stop = tab.subscribe?.(changed);
// Nothing shows before the account's choice is read.
Expand All @@ -98,8 +109,8 @@ it("shows on the saved Bestie only, and moves when Change picks another", async
const Tab = tab.component;
render(<Tab agent={ada} />);
expect(screen.getByText("This is your Bestie")).toBeVisible();
// A type without instructions has nothing to switch.
expect(screen.getByText("Remembers you")).toBeVisible();
// No switches yet.
expect(screen.queryByRole("switch")).toBeNull();
changed.mockClear();
await user.click(screen.getByRole("button", { name: "Change" }));
Expand All @@ -122,7 +133,7 @@ it("shows on the saved Bestie only, and moves when Change picks another", async

it("keeps Change unavailable while there is no other agent to choose", async () => {
const { relay } = community(ada.pubkey, [ada]);
const Tab = bestieTab(relay).component;
const Tab = bestieTab(relay, host().agents).component;
await act(async () => {
render(<Tab agent={ada} />);
});
Expand All @@ -131,7 +142,7 @@ it("keeps Change unavailable while there is no other agent to choose", async ()

it("tells the host only when the Bestie or its community changes", async () => {
const { relay, reconnect } = community(ada.pubkey);
const tab = bestieTab(relay);
const tab = bestieTab(relay, host([]).agents);
const changed = vi.fn();
const stop = tab.subscribe?.(changed);
await vi.waitFor(() => expect(tab.when?.(ada)).toBe(true));
Expand All @@ -145,3 +156,125 @@ it("tells the host only when the Bestie or its community changes", async () => {
expect(tab.when?.(ada)).toBe(false);
stop?.();
});

it("turns memory on and off as a marked section of the instructions", async () => {
const user = userEvent.setup();
const { relay } = community(ada.pubkey);
const { agents, save } = host();
const Tab = bestieTab(relay, agents).component;
const base = { instructions: "head-1" };
const agent = {
...ada,
config: { model: "m", instructions: "Be brief." },
configBase: base,
} as Agent;
const { rerender } = render(<Tab agent={agent} />);
const remembers = screen.getByRole("switch", { name: "Remembers you" });
expect(remembers).not.toBeChecked();
expect(
screen.getByText(/Nothing Bestie remembered is deleted/),
).toBeVisible();

await user.click(remembers);
const on = withFeature("Be brief.", memory, true);
// Revises the head the tab showed, keeping the rest of the config.
expect(save).toHaveBeenCalledWith(ada.key, {
config: { model: "m", instructions: on },
base,
});
expect(on).toMatch(
/^Be brief\.\n\n<!-- buzz:bestie\/memory v1 -->\n# Memory\n/,
);

// The saved instructions are what the switch shows.
rerender(
<Tab agent={{ ...agent, config: { model: "m", instructions: on } }} />,
);
await user.click(screen.getByRole("switch", { name: "Remembers you" }));
expect(screen.getByRole("switch", { name: "Remembers you" })).toBeChecked();
expect(save).toHaveBeenLastCalledWith(ada.key, {
config: { model: "m", instructions: "Be brief." },
base,
});
});

it("keeps memory on through the owner's edits, and offers Restore for edited markers", async () => {
const user = userEvent.setup();
const { relay } = community(ada.pubkey);
const { agents, save } = host();
const Tab = bestieTab(relay, agents).component;
const base = { instructions: "head-2" };
const on = withFeature("Be brief.", memory, true);
const close = "<!-- /buzz:bestie/memory -->";
const shown = (instructions: string) =>
({ ...ada, config: { instructions }, configBase: base }) as Agent;
// Typed below the section, which lands on its closing marker's line.
const typed = `${on} Also French.`;
const { rerender } = render(<Tab agent={shown(typed)} />);
const remembers = screen.getByRole("switch", { name: "Remembers you" });
expect(remembers).toBeChecked();
await user.click(remembers);
expect(save).toHaveBeenLastCalledWith(ada.key, {
config: { instructions: "Be brief.\n\nAlso French." },
base,
});

// A closing marker edited by hand: no switch to add a second section.
save.mockClear();
const edited = on.replace(close, "<!-- /buzz:bestie/memory--> Also French.");
rerender(<Tab agent={shown(edited)} />);
expect(screen.queryByRole("switch", { name: "Remembers you" })).toBeNull();
expect(screen.getByText(/edited where it starts or ends/)).toBeVisible();
await user.click(screen.getByRole("button", { name: "Restore" }));
expect(save).toHaveBeenCalledWith(ada.key, {
config: { instructions: `${on} Also French.` },
base,
});

// With a marker gone, where the section ended is the owner's to say.
rerender(<Tab agent={shown(on.replace(close, ""))} />);
expect(screen.queryByRole("button", { name: "Restore" })).toBeNull();
expect(screen.getByText(/Fix its start and end lines there/)).toBeVisible();
});

it("says why a save failed and lets the switch be tried again", async () => {
const user = userEvent.setup();
const { relay } = community(ada.pubkey);
const { agents, save } = host();
save.mockRejectedValueOnce(new Error("Connect to the community to save"));
const Tab = bestieTab(relay, agents).component;
render(<Tab agent={{ ...ada, config: {} } as Agent} />);
const remembers = screen.getByRole("switch", { name: "Remembers you" });
await user.click(remembers);
expect(await screen.findByRole("alert")).toHaveTextContent(
"Connect to the community to save",
);
expect(remembers).not.toBeChecked();
expect(remembers).toBeEnabled();
// A config with no instructions yet gets the section alone.
expect(save).toHaveBeenCalledWith(ada.key, {
config: { instructions: withFeature("", memory, true) },
});
});

it("offers no switch while the agent's instructions can't be read here", () => {
const { relay } = community(ada.pubkey);
const { agents, save } = host();
const Tab = bestieTab(relay, agents).component;
render(
<Tab
agent={
{
...ada,
config: {},
configProblem: "This agent's instructions could not be read",
} as Agent
}
/>,
);
expect(screen.queryByRole("switch")).toBeNull();
expect(
screen.getByText("This agent's instructions could not be read"),
).toBeVisible();
expect(save).not.toHaveBeenCalled();
});
146 changes: 141 additions & 5 deletions src/bundled/bestie/BestieTab.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import type { Agent, AgentAddedTab } from "../../features/agents/service";
import { useState } from "react";
import type {
Agent,
AgentAddedTab,
Agents,
} from "../../features/agents/service";
import { relayOrigin } from "../../features/communities/destination";
import { relayPartition } from "../../features/relay/partition";
import { useRelayConnection } from "../../features/relay/react";
Expand All @@ -17,10 +22,21 @@ import {
} from "../../shared/design-system/ui/Menu";
import { PreferenceRow } from "../../shared/design-system/ui/PreferenceRow";
import { SettingsGroup } from "../../shared/design-system/ui/SettingsGroup";
import { SwitchPreferenceRow } from "../../shared/design-system/ui/SwitchPreferenceRow";
import { bestieName, useBestieChoice } from "../agents/BestieChoice";
import {
FEATURES,
type Feature,
featureState,
hasFeature,
restoredFeature,
withFeature,
} from "./features";

type TabAgents = Pick<Agents, "save" | "types">;

/** The Bestie tab, on the agent saved as Bestie in its community. */
export function bestieTab(relay: RelayData): AgentAddedTab {
export function bestieTab(relay: RelayData, agents: TabAgents): AgentAddedTab {
const bestie = savedBestie(relay);
return {
id: "bestie",
Expand All @@ -29,7 +45,9 @@ export function bestieTab(relay: RelayData): AgentAddedTab {
agent.pubkey === bestie.snapshot() &&
inCommunity(agent, relay.snapshot()),
subscribe: bestie.subscribe,
component: ({ agent }) => <BestieTab relay={relay} agent={agent} />,
component: ({ agent }) => (
<BestieTab relay={relay} agents={agents} agent={agent} />
),
};
}

Expand Down Expand Up @@ -91,7 +109,15 @@ function inCommunity(agent: Agent, { scope }: RelaySnapshot) {
}
}

function BestieTab({ relay, agent }: { relay: RelayData; agent: Agent }) {
function BestieTab({
relay,
agents,
agent,
}: {
relay: RelayData;
agents: TabAgents;
agent: Agent;
}) {
const { session, scope } = useRelayConnection(relay);
const bestie = useBestieChoice(session, scope ?? "");
const others = bestie.choices.selectable.filter(
Expand Down Expand Up @@ -131,7 +157,14 @@ function BestieTab({ relay, agent }: { relay: RelayData; agent: Agent }) {
</SettingsGroup>
<InlineHeader title="Settings" />
<SettingsGroup>
<PreferenceRow title="Remembers you" subtitle="Not set up yet." />
{FEATURES.map((feature) => (
<FeatureSwitch
key={feature.id}
agents={agents}
agent={agent}
feature={feature}
/>
))}
<PreferenceRow
title="Check-ins"
subtitle="Every hour. Not set up yet."
Expand All @@ -145,3 +178,106 @@ function BestieTab({ relay, agent }: { relay: RelayData; agent: Agent }) {
</div>
);
}

/** A feature's switch: on while the agent's instructions hold its section.
* Saving revises the synced instructions the tab shows, as their editor does.
* A section edited by hand where it starts or ends shows that instead, with
* Restore when what is left of its markers shows where it was. */
function FeatureSwitch({
agents,
agent,
feature,
}: {
agents: TabAgents;
agent: Agent;
feature: Feature;
}) {
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string>();
// A type without `adopt` has no instructions, as `updateKind` says.
if (!agents.types().find(({ key }) => key === agent.type)?.adopt)
return (
<PreferenceRow
title={feature.title}
subtitle="This agent's type has no instructions to add this to."
/>
);
// Without a confirmed copy its instructions aren't shown, so a save here
// would replace them.
if (agent.configProblem)
return (
<PreferenceRow title={feature.title} subtitle={agent.configProblem} />
);
const config = (agent.config ?? {}) as { instructions?: unknown };
const instructions =
typeof config.instructions === "string" ? config.instructions : "";
const run = async (task: () => Promise<void>) => {
setSaving(true);
setError(undefined);
try {
await task();
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
} finally {
setSaving(false);
}
};
const saveInstructions = (next: string) =>
agents.save(agent.key, {
config: { ...config, instructions: next },
...(agent.configBase && { base: agent.configBase }),
});
const save = (on: boolean) =>
run(async () => {
// On never adds a second section beside one edited by hand.
if (on && featureState(instructions, feature) === "edited")
throw new Error(
`${feature.title} was edited in Instructions; restore it first.`,
);
await saveInstructions(withFeature(instructions, feature, on));
});
const alert = error && (
<p role="alert" className="buzz-field-error">
{error}
</p>
);
if (featureState(instructions, feature) === "edited") {
const restored = restoredFeature(instructions, feature);
return (
<>
<PreferenceRow
title={feature.title}
subtitle={
restored !== undefined
? "Its section in Instructions was edited where it starts or ends. Restore puts back the original section."
: "Its section in Instructions was edited where it starts or ends. Fix its start and end lines there."
}
trailing={
restored !== undefined ? (
<Button
variant="outline"
disabled={saving}
onClick={() => void run(() => saveInstructions(restored))}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [P2] Preserve keyboard focus when Restore replaces its control

When an owner repairs an edited memory section with the keyboard, the confirmed instructions change this branch from "edited" to "on". That unmounts Restore and mounts the switch at lines 273–279, but neither this component nor its agent-tab hosts transfer focus to that replacement. The existing ProfilePanel focus effect runs only on mount/tab-host changes, and AgentDetails' setup focus fallback runs only on mount, so neither handles this save. The user loses the focused action at the end of recovery. This conflicts with the focus-preservation rules in src/shared/design-system/DESIGN.md (States, feedback, and motion / Loading without flicker).

Keep focus through the pending operation and hand it to the resulting switch once the confirmed config is rendered, only if the user has not moved elsewhere. The shared Button's loading state can preserve pending focus/busy semantics. Extend the Restore test through the confirmed-config rerender and check success and failure/retry focus. This is source inference; the exact browser focus destination and assistive-technology announcement were not exercised.

>
Restore
</Button>
) : undefined
}
/>
{alert}
</>
);
}
return (
<>
<SwitchPreferenceRow
label={feature.title}
description={feature.description}
checked={hasFeature(instructions, feature)}
disabled={saving}
onCheckedChange={(on) => void save(on)}
/>
{alert}
</>
);
}
Loading
Loading