diff --git a/docs/buzz-help.md b/docs/buzz-help.md index 36af3d698e..b6c3027e26 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -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; diff --git a/src/bundled/channels/ChannelsPage.solo.test.tsx b/src/bundled/channels/ChannelsPage.solo.test.tsx index e0ae4b3697..5a129f1774 100644 --- a/src/bundled/channels/ChannelsPage.solo.test.tsx +++ b/src/bundled/channels/ChannelsPage.solo.test.tsx @@ -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"; @@ -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[] = []; afterEach(() => { @@ -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((resolve) => { + release = resolve; + }); + let requested!: () => void; + const waiting = new Promise((resolve) => { + requested = resolve; + }); + let completed!: () => void; + const settled = new Promise((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((_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" }), diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index ab748c7fa6..ceab8f31c3 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -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 && diff --git a/src/features/agents/react.test.tsx b/src/features/agents/react.test.tsx index dd362b1cf6..1a8d30138f 100644 --- a/src/features/agents/react.test.tsx +++ b/src/features/agents/react.test.tsx @@ -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, ); @@ -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", + ]), ); }); diff --git a/src/features/agents/react.ts b/src/features/agents/react.ts index d51d2e78e7..a4f40cd122 100644 --- a/src/features/agents/react.ts +++ b/src/features/agents/react.ts @@ -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); @@ -144,6 +146,7 @@ export function useMentionModels( snapshot: typeof snapshot; types: typeof types; key: string; + retry: number; choices: readonly MentionModelChoice[]; }>(); useEffect(() => { @@ -163,22 +166,23 @@ 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 } : {}), }, ] @@ -186,17 +190,19 @@ export function useMentionModels( }), ).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); diff --git a/src/features/communities/device-state.test.ts b/src/features/communities/device-state.test.ts index d151fa05f7..c3e3231c51 100644 --- a/src/features/communities/device-state.test.ts +++ b/src/features/communities/device-state.test.ts @@ -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" @@ -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}`], ]); }); @@ -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", @@ -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, + ], ]); }); diff --git a/src/features/messages/ComposerModels.tsx b/src/features/messages/ComposerModels.tsx index 63e2c41cef..477477227e 100644 --- a/src/features/messages/ComposerModels.tsx +++ b/src/features/messages/ComposerModels.tsx @@ -22,6 +22,7 @@ import { AgentsContext, useSessionState, useMentionModels, + useAgentsSnapshot, } from "../agents/react"; import type { MentionModelChoice } from "../agents/react"; import type { SessionPlace, SessionControlAction } from "../agents/service"; @@ -62,8 +63,11 @@ export function ComposerModels({ media, value, disabled, + resettable = false, + explicitOnly = false, onChange, onResolved, + onRetryInventory, startsThread, place, }: { @@ -72,28 +76,36 @@ export function ComposerModels({ media: Parameters[1]; value: DraftPicks; disabled: boolean; - onChange(value: DraftPicks): void; - onResolved(value: MentionPicks): void; + /** Solo chat picks are defaults for the chat until explicitly reset. */ + resettable?: boolean; + /** A new Solo chat keeps provider defaults unchanged unless picked. */ + explicitOnly?: boolean; + onChange(value: DraftPicks, resetPubkeys?: readonly string[]): void; + /** null waits for choices; undefined invalidates the picker's snapshot. */ + onResolved(value: MentionPicks | null | undefined, error?: string): void; + onRetryInventory?(): void; /** The message starts a thread of its own, as a channel's top-level one * does, so a thread-scoped agent has no session to act on. */ startsThread: boolean; place: SessionPlace; }) { const [open, setOpen] = useState(false); - const mentioned = useMentionModels(pubkeys); + const [retry, setRetry] = useState(0); + const mentioned = useMentionModels(pubkeys, retry); const choices = useMemo( () => - startsThread - ? mentioned.flatMap((choice) => { - if (choice.session !== "thread") return [choice]; - const { session: _, ...rest } = choice; - return rest.loading || - rest.models.length > 1 || - rest.models.some((model) => model.efforts.length) - ? [rest] - : []; - }) - : mentioned, + mentioned.flatMap((choice) => { + const { session, ...catalog } = choice; + const visible: MentionModelChoice = + startsThread && session === "thread" ? catalog : choice; + return visible.loading || + visible.failed || + visible.models.length > 1 || + visible.models.some((model) => model.efforts.length) || + visible.session + ? [visible] + : []; + }), [mentioned, startsThread], ); const profileKey = pubkeys.join(":"); @@ -114,6 +126,32 @@ export function ComposerModels({ selection.snapshot, ); const agents = useContext(AgentsContext); + const agentSnapshot = useAgentsSnapshot(agents); + const unavailableType = + resettable && + pubkeys.some((pubkey) => { + const agent = agentSnapshot?.agents.find( + (item) => item.pubkey === pubkey, + ); + return ( + (value[pubkey]?.model || value[pubkey]?.effort) && + agent && + !agents?.types().some((type) => type.key === agent.type) + ); + }); + const unresolvedInventory = + unavailableType || + (resettable && + (agentSnapshot?.status === "loading" || + agentSnapshot?.status === "error") && + pubkeys.some( + (pubkey) => + (value[pubkey]?.model || value[pubkey]?.effort) && + !agentSnapshot.agents.some((agent) => agent.pubkey === pubkey), + )); + const inventoryFailed = + unavailableType || + (unresolvedInventory && agentSnapshot?.status === "error"); // A pick keeps the type it was made under: a retype drops it, even while // the new type's choices load or once they fail to, as none showed it. // biome-ignore lint/correctness/useExhaustiveDependencies: `mentioned` follows the agents' snapshot, so a retype reruns this. @@ -131,37 +169,87 @@ export function ComposerModels({ // Publish the displayed defaults separately from the user's draft picks. // A settings refresh or model-only pick must still follow the new default. useLayoutEffect(() => { - const resolved: Record = Object.fromEntries( - Object.entries(value).map(([pubkey, { model, effort }]) => [ - pubkey, - { ...(model ? { model } : {}), ...(effort ? { effort } : {}) }, - ]), - ); - for (const choice of choices) { - if (choice.loading || !choice.sendDefaults) continue; + const waitingForSavedChoices = + resettable && + (unresolvedInventory || + mentioned.some( + (choice) => + (choice.loading || choice.failed) && + (value[choice.pubkey]?.model || value[choice.pubkey]?.effort), + )); + if (waitingForSavedChoices) { + onResolved( + null, + inventoryFailed || mentioned.some((choice) => choice.failed) + ? "Saved model and effort choices could not be loaded. Open model and effort controls to retry or reset them." + : undefined, + ); + return () => onResolved(undefined); + } + const resolved: Record = resettable + ? {} + : Object.fromEntries( + Object.entries(value).map(([pubkey, { model, effort }]) => [ + pubkey, + { ...(model ? { model } : {}), ...(effort ? { effort } : {}) }, + ]), + ); + for (const choice of resettable ? mentioned : choices) { + if (choice.loading || choice.failed) continue; const saved = value[choice.pubkey] ?? {}; + const selected = choice.models.find((item) => item.id === saved.model); const runs = runModel(choice, saved.model); - if (!runs.id) continue; - const effort = runs.efforts.some((item) => item.id === saved.effort) - ? saved.effort - : runs.efforts.some((item) => item.id === runs.effort) + if (!runs?.id) continue; + const explicitEffort = runs.efforts.find( + (item) => + item.id === saved.effort && + (!resettable || !saved.model || selected !== undefined), + )?.id; + const effort = + explicitEffort ?? + (runs.efforts.some((item) => item.id === runs.effort) ? runs.effort - : undefined; - resolved[choice.pubkey] = { - model: runs.id, - ...(effort ? { effort } : {}), - }; + : undefined); + if (choice.sendDefaults && !explicitOnly) { + resolved[choice.pubkey] = { + model: runs.id, + ...(effort ? { effort } : {}), + }; + } else if (resettable && (selected || explicitEffort)) { + resolved[choice.pubkey] = { + ...(selected ? { model: selected.id } : {}), + ...(explicitEffort ? { effort: explicitEffort } : {}), + }; + } } onResolved(resolved); - return () => onResolved({}); - }, [choices, value, onResolved]); - if (!choices.length) return null; + return () => onResolved(undefined); + }, [ + choices, + mentioned, + value, + onResolved, + resettable, + explicitOnly, + unresolvedInventory, + inventoryFailed, + ]); + const canReset = + resettable && + pubkeys.some((pubkey) => value[pubkey]?.model || value[pubkey]?.effort); + if (!choices.length && !inventoryFailed && !canReset) return null; const picked = choices.flatMap((choice) => { + if (choice.loading) return []; const { model, effort } = value[choice.pubkey] ?? {}; const runs = runModel(choice, model); + const selected = choice.models.some((item) => item.id === model); + const savedEffortApplies = !resettable || !model || selected; + const supportedEffort = savedEffortApplies + ? runs.efforts.find((item) => item.id === effort) + : undefined; const text = [ - model !== undefined && runs.label, - runs.efforts.find((item) => item.id === effort)?.label, + selected && runs.label, + effort !== undefined && supportedEffort?.label, ] .filter(Boolean) .join(" ยท "); @@ -177,8 +265,11 @@ export function ComposerModels({ ); }; const label = choices.some((choice) => choice.session) - ? "Model, effort, and session controls" - : "Model and effort for the next run"; + ? `Model, effort, and session controls${resettable ? " for this chat" : ""}` + : resettable + ? "Model and effort for this chat" + : "Model and effort for the next run"; + const target = resettable ? "this chat" : "next run"; return (