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: 16 additions & 3 deletions docs/buzz-help.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,22 @@ that agent in Buzz; they do not notify the external app through this connection.

- **Solo** organizes your chats with agents. Choose **New chat**, select or tag
an agent, and send a message. Solo conversations continue at the chat's top
level rather than requiring a new thread for each reply. Solo chats can also
be shared with other people; do not describe every Solo chat as visible only
to its creator.
level rather than requiring a new thread for each reply. Where available, use
**Model and effort** in the composer before sending; Solo keeps those choices
for that chat until you reset them in the same control, even if its available
models change. Wait for saved choices to load before sending; an unreadable
store never silently falls back to defaults. If choices cannot be read, use
**Retry saved choices** before sending. If their agent catalog cannot be loaded, retry
or reset them in the model control. If New chat cannot clear
its choices after sending, reset them before starting another chat. Wait for
pending model changes to save before sending. If saving fails, the current
composer keeps your unsaved selection; other windows may not see it.
If a refused start offers **Retry restoring message**, it keeps the original
message, attachments, and choices while this window stays open. Retry never
replaces a newer New chat draft or a later Reset. Reloading or dismissing that
notice ends this recovery option.
Solo chats can also be shared with other people; do not describe every Solo
chat as visible only to its creator.
- **Team** contains community channels and their threads. Use the **@** picker
to address a channel member. Typing a name alone does not guarantee a
notification. Selecting someone outside the channel does not grant access;
Expand Down
138 changes: 138 additions & 0 deletions src/bundled/channels/ChannelsPage.solo.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import { createRelaySession } from "../../features/relay/session";
import { ChatNotStarted } from "../../features/relay/channel-writes";
import { keypair, signed } from "../../features/relay/testing";
import type { RelayData } from "../../features/relay/service";
import type { ChannelSummary } from "../../features/relay/contracts";
Expand All @@ -37,6 +38,11 @@ import { SoloWorkspaces } from "./SoloWorkspaces";
import type { RelaySession } from "../../features/relay/session";
import type { ReadTarget } from "../../features/relay/read-state-model";

vi.mock(
"../../features/messages/solo-model-picks-storage",
() => import("../../features/messages/solo-model-picks-storage-testing"),
);

composerDOMFixture();
const owners: ReturnType<typeof createRelaySession>[] = [];
afterEach(() => {
Expand Down Expand Up @@ -542,6 +548,138 @@ it("renders a Solo draft route as the new chat composer", async () => {
expect(f.archive.current).toBeUndefined();
});

it("keeps the newer destination when a departed New chat acquires model ownership", async () => {
const first = fixture({
target: (scope) => soloDraftTarget(scope, { agent }),
});
const descriptor = Object.getOwnPropertyDescriptor(navigator, "locks");
let release!: () => void;
const held = new Promise<void>((resolve) => {
release = resolve;
});
let requested!: () => void;
const waiting = new Promise<void>((resolve) => {
requested = resolve;
});
let completed!: () => void;
const settled = new Promise<void>((resolve) => {
completed = resolve;
});
Object.defineProperty(navigator, "locks", {
configurable: true,
value: {
request: async (_name: string, run: () => unknown) => {
requested();
await held;
try {
return run();
} finally {
completed();
}
},
},
});
const view = first.mount();
const user = userEvent.setup();
try {
await user.type(
await screen.findByRole("textbox", { name: "Message your agents" }),
"Start before navigating",
);
await user.click(screen.getByRole("button", { name: "Send message" }));
await waiting;
expect(first.send).toHaveBeenCalledOnce();
first.abort.abort();
view.unmount();
const newer = fixture({ type: "stream" });
newer.mount();
await screen.findByRole("textbox", { name: "Message #Planning" });
await act(async () => {
release();
await settled;
});
expect(first.open).not.toHaveBeenCalled();
expect(newer.open).not.toHaveBeenCalled();
expect(
screen.getByRole("textbox", { name: "Message #Planning" }),
).toBeVisible();
} finally {
release();
if (descriptor) Object.defineProperty(navigator, "locks", descriptor);
else Reflect.deleteProperty(navigator, "locks");
}
});

it("keeps refused-start restoration visibly retryable when window ownership fails again", async () => {
const f = fixture({ target: (scope) => soloDraftTarget(scope, { agent }) });
let refuse!: () => void;
const ready = new Promise<void>((_resolve, reject) => {
refuse = () => reject(new ChatNotStarted("Channel creation rejected"));
});
f.session.channelWrites.start.mockImplementationOnce((chat) => ({
create: f.session.channelWrites.create(chat),
ready,
}));
const descriptor = Object.getOwnPropertyDescriptor(navigator, "locks");
const available = {
request: async (_name: string, run: () => unknown) => run(),
};
Object.defineProperty(navigator, "locks", {
configurable: true,
value: available,
});
f.mountWithToasts();
const user = userEvent.setup();
try {
await user.type(
await screen.findByRole("textbox", { name: "Message your agents" }),
"Keep this message",
);
await user.click(screen.getByRole("button", { name: "Send message" }));
await waitFor(() => expect(f.open).toHaveBeenCalledOnce());
Object.defineProperty(navigator, "locks", {
configurable: true,
value: {
request: async () => {
throw new Error("Lock refused");
},
},
});
await act(async () => {
refuse();
await ready.catch(() => {});
});
const retry = () =>
screen.findByRole("button", { name: "Retry restoring message" });
const firstRetry = await retry();
fireEvent.click(firstRetry);
// The toast consumes its action before invoking it; a second failure must
// offer a new action retaining the same refused prompt.
await waitFor(() => expect(firstRetry).not.toBeInTheDocument());
const offeredAgain = await retry();
Object.defineProperty(navigator, "locks", {
configurable: true,
value: available,
});
fireEvent.click(offeredAgain);
await waitFor(() =>
expect(
screen.getByRole("textbox", { name: "Message your agents" }),
).toHaveTextContent("Keep this message"),
);
await waitFor(() =>
expect(
screen.queryByRole("button", { name: "Retry restoring message" }),
).toBeNull(),
);
expect(f.send).toHaveBeenCalledOnce();
expect(f.session.channelWrites.start).toHaveBeenCalledOnce();
} finally {
if (descriptor) Object.defineProperty(navigator, "locks", descriptor);
else Reflect.deleteProperty(navigator, "locks");
}
});

it("starts a Solo draft route in its project, with that project's saved draft", async () => {
const f = fixture({
target: (scope) => soloDraftTarget(scope, { project: "work" }),
Expand Down
14 changes: 12 additions & 2 deletions src/bundled/channels/ChannelsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2080,8 +2080,18 @@ function ChannelWorkspace({
signal: navigation?.signal,
}));
}}
onFailed={(channelId, message) => {
notify(message, "error");
onFailed={(channelId, message, retry) => {
notify(
message,
"error",
retry
? {
label: "Retry restoring message",
onClick: () => void retry(),
timeout: Infinity,
}
: undefined,
);
// A failure behind Settings must not navigate away from it.
if (
showing.current === channelId &&
Expand Down
7 changes: 5 additions & 2 deletions src/features/agents/react.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ it("loads models and their effort levels asynchronously before an agent runs", a
]);
});

it("offers nothing for a single model without effort levels", async () => {
it("loads a single model without effort levels for saved-choice validation", async () => {
const agents = ["fixed", "tunable"].map(
(pubkey) => ({ pubkey, type: pubkey, name: pubkey }) as Agent,
);
Expand Down Expand Up @@ -92,7 +92,10 @@ it("offers nothing for a single model without effort levels", async () => {
});
// Both lookups settle together, so the tunable one is the barrier.
await waitFor(() =>
expect(result.current.map((choice) => choice.pubkey)).toEqual(["tunable"]),
expect(result.current.map((choice) => choice.current)).toEqual([
"only",
"Tunable",
]),
);
});

Expand Down
22 changes: 14 additions & 8 deletions src/features/agents/react.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,13 +117,15 @@ export type MentionModelChoice = Models &
pubkey: string;
name: string;
loading?: boolean;
failed?: boolean;
/** Its type offers immediate controls for sessions of this scope. */
session?: "thread" | "channel";
}>;
/** The mentioned agents, of the viewer's own, whose type offers a choice of
* model or effort, or session actions. */
/** Model catalogs and session actions for the viewer's mentioned agents.
* A single model still validates saved picks even when no control is needed. */
export function useMentionModels(
pubkeys: readonly string[],
retry = 0,
): readonly MentionModelChoice[] {
const remote = useContext(MentionModelsContext);
const agents = useContext(AgentsContext);
Expand All @@ -144,6 +146,7 @@ export function useMentionModels(
snapshot: typeof snapshot;
types: typeof types;
key: string;
retry: number;
choices: readonly MentionModelChoice[];
}>();
useEffect(() => {
Expand All @@ -163,40 +166,43 @@ export function useMentionModels(
if (!agent || !type) return [];
const session = type.sessionControls?.scope(agent);
let found: Models = { current: "", models: [] };
let failed = false;
try {
if (type.models) found = await type.models(agent);
} catch (error) {
failed = true;
console.warn(
`Agent type ${type.key} could not list its models`,
error,
);
}
return found.models.length > 1 ||
found.models.some((m) => m.efforts.length) ||
session
return failed || found.models.length || session
? [
{
pubkey,
name: agent.name,
...found,
...(failed ? { failed } : {}),
...(session ? { session } : {}),
},
]
: [];
}),
).then((choices) => choices.flat())
).then((choices) => {
if (current) setResolved({ remote, snapshot, types, key, choices });
if (current)
setResolved({ remote, snapshot, types, key, retry, choices });
});
return () => {
current = false;
};
}, [snapshot, types, key, remote]);
}, [snapshot, types, key, remote, retry]);
// Never offer a previous agent/config's choices while the new lookup settles.
return resolved?.remote === remote &&
resolved?.snapshot === snapshot &&
resolved?.types === types &&
resolved?.key === key
resolved?.key === key &&
resolved?.retry === retry
? resolved.choices
: key.split(":").flatMap((pubkey) => {
const agent = snapshot?.agents.find((item) => item.pubkey === pubkey);
Expand Down
18 changes: 17 additions & 1 deletion src/features/communities/device-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ vi.mock("../relay/outbox-storage", () => ({
purgeOutboxStorage: (scope: string) => record("outbox", scope),
}));

vi.mock("../messages/solo-model-picks-storage", () => ({
purgeSoloModelPicks: (scope: string) =>
typeof indexedDB === "undefined"
? Promise.resolve()
: record("model choices", scope),
}));

vi.mock("../artifact-storage/store", () => ({
purgeArtifactStorage: (viewer: string, origin: string) =>
typeof indexedDB === "undefined"
Expand Down Expand Up @@ -142,6 +149,7 @@ it("purges the relay's heads, read state and outbox for the exact partition, in
["read state", `${origin}:${viewer}`],
["outbox", `${origin}:${viewer}`],
["artifacts", viewer, origin],
["model choices", `${origin}:${viewer}`],
]);
});

Expand All @@ -159,14 +167,17 @@ it("reports each failed relay store by name, closes heads and still clears the o
const heads = new Error("heads blocked");
const reads = new Error("read state blocked");
const artifacts = new Error("artifacts blocked");
const choices = new Error("choices blocked");
relay.fail
.set("heads.clear", heads)
.set("read state", reads)
.set("artifacts", artifacts);
.set("artifacts", artifacts)
.set("model choices", choices);
expect(await purgeCommunityDeviceState(origin, viewer)).toEqual([
{ store: "channel heads", error: heads },
{ store: "read state", error: reads },
{ store: "artifacts", error: artifacts },
{ store: "model and effort choices", error: choices },
]);
expect(relay.calls.map(([name]) => name)).toEqual([
"heads",
Expand All @@ -175,11 +186,16 @@ it("reports each failed relay store by name, closes heads and still clears the o
"read state",
"outbox",
"artifacts",
"model choices",
]);
expect(warn.mock.calls).toEqual([
[`Couldn't clear channel heads for ${origin} on this device`, heads],
[`Couldn't clear read state for ${origin} on this device`, reads],
[`Couldn't clear artifacts for ${origin} on this device`, artifacts],
[
`Couldn't clear model and effort choices for ${origin} on this device`,
choices,
],
]);
});

Expand Down
Loading
Loading