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
2 changes: 2 additions & 0 deletions src/bundled/agents/AgentsPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,7 @@ it("restores provider links and retires only that provider's visits after close
signal: new AbortController().signal,
focus: "content" as const,
forSession: () => navigation,
passThrough: () => () => {},
resolve: () => true,
complete: () => true,
};
Expand Down Expand Up @@ -741,6 +742,7 @@ it("opens the agent a link names, on Your agents", async () => {
forSession: () => navigation,
resolve: () => true,
complete: () => true,
passThrough: () => () => {},
};
setup([agent("Ada", 1), agent("Bea", 2)], {
page: { navigator, navigation },
Expand Down
1 change: 1 addition & 0 deletions src/bundled/channels/ChannelsPage.conversion.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ function fixture() {
},
signal: visit.signal,
forSession: () => navigation,
passThrough: () => () => {},
complete: () => true,
resolve: () => true,
};
Expand Down
177 changes: 173 additions & 4 deletions src/bundled/channels/ChannelsPage.solo.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import {
soloDraftTarget,
} from "../../features/channel-navigation/routes";
import { linkedDraftKey } from "../../features/solo/linked-chat";
import { attachmentDraft } from "../../features/messages/attachment-draft";
import type { ComposerSession } from "../../features/messages/composer-session";
import { readView, writeView } from "../../shared/view-state";
import { ChannelsPage, type ArchiveShortcut } from "./ChannelsPage";
import { SoloWorkspaces } from "./SoloWorkspaces";
Expand Down Expand Up @@ -56,9 +58,12 @@ function fixture({
archived = false,
viewerNamed = false,
others = [],
agentless = false,
}: {
/** Other Solo chats listed after this one in the sidebar's Chats. */
others?: readonly string[];
/** No agent can be tagged, so nothing is tagged automatically. */
agentless?: boolean;
/** The viewer's own profile has loaded too. */
viewerNamed?: boolean;
/** Someone other than the viewer or its agent created the chat. */
Expand Down Expand Up @@ -145,10 +150,12 @@ function fixture({
},
);
// The default agent comes first, so a named agent is distinguishable from it.
const agents = [
{ pubkey: "b".repeat(64), name: "Helper", managed: false },
{ pubkey: agent, name: "Scout", managed: false },
];
const agents = agentless
? []
: [
{ pubkey: "b".repeat(64), name: "Helper", managed: false },
{ pubkey: agent, name: "Scout", managed: false },
];
const choices = {
...owner.session.agentChoices.snapshot(),
status: "ready" as const,
Expand Down Expand Up @@ -286,6 +293,7 @@ function fixture({
target: target?.(scope) ?? conversationTarget(scope, id),
signal: abort.signal,
forSession: () => navigation,
passThrough: vi.fn<PageNavigation["passThrough"]>(() => () => {}),
complete,
resolve: () => true,
};
Expand Down Expand Up @@ -335,6 +343,7 @@ function fixture({
</ChannelNavigationProvider>
);
return {
navigation,
mount: () => render(page()),
mountWithSidebar: () => render(page(true)),
mountWithToasts: () => render(<ToastProvider>{page()}</ToastProvider>),
Expand Down Expand Up @@ -501,6 +510,166 @@ it("tags a Solo draft route's agent in the new chat", async () => {
).toBeVisible();
});

it("keeps a new chat out of Back history until something is written in it", async () => {
const f = fixture({ target: (scope) => soloDraftTarget(scope, { agent }) });
f.mount();
const region = await screen.findByRole("region", { name: "New chat" });
const input = within(region).getByRole("textbox", {
name: "Message your agents",
});
await waitFor(() => expect(input).toHaveTextContent("@Scout"));
const passes = vi.mocked(f.navigation.passThrough).mock.lastCall?.[0];
// Only the automatic tag, shown but not saved: nothing to come back to.
expect(readView(f.scope, "solo:new-draft", null)).toBeNull();
expect(passes?.()).toBe(true);
const user = userEvent.setup();
await user.click(input);
await user.keyboard(" Plan the week");
expect(passes?.()).toBe(false);
await user.click(
within(region).getByRole("button", { name: "Send message" }),
);
await waitFor(() => expect(f.open).toHaveBeenCalledOnce());
// The sent draft is spent; the started chat takes its place.
expect(passes?.()).toBe(true);
});

it("keeps a written new chat in Back history when its draft cannot be saved", async () => {
const f = fixture({ target: (scope) => soloDraftTarget(scope, { agent }) });
const draftStorageKey = `buzz-view.v1:${JSON.stringify([f.scope, "solo:new-draft"])}`;
const setItem = Storage.prototype.setItem;
vi.spyOn(Storage.prototype, "setItem").mockImplementation(function (
this: Storage,
key: string,
value: string,
) {
if (key === draftStorageKey)
throw new DOMException("Storage is full", "QuotaExceededError");
setItem.call(this, key, value);
});
try {
f.mount();
const region = await screen.findByRole("region", { name: "New chat" });
const input = within(region).getByRole("textbox", {
name: "Message your agents",
});
await waitFor(() => expect(input).toHaveTextContent("@Scout"));
const passes = vi.mocked(f.navigation.passThrough).mock.lastCall?.[0];
expect(passes?.()).toBe(true);
const user = userEvent.setup();
await user.click(input);
await user.keyboard(" Unsaved but written");
expect(readView(f.scope, "solo:new-draft", null)).toBeNull();
expect(passes?.()).toBe(false);
} finally {
vi.mocked(Storage.prototype.setItem).mockRestore();
}
});

it("keeps a saved draft that only tags the default agent in Back history", async () => {
const f = fixture({ target: (scope) => soloDraftTarget(scope, { agent }) });
// A tag the person typed, saved with its editor snapshot.
const typed = {
text: "@Scout ",
recipients: [{ pubkey: agent, name: "Scout", start: 0, end: 6 }],
document: {
version: 1,
content: {
type: "doc",
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: "@Scout",
marks: [
{
type: "recipient",
attrs: { pubkey: agent, name: "Scout" },
},
],
},
{ type: "text", text: " " },
],
},
],
},
},
};
writeView(f.scope, "solo:new-draft", typed);
f.mount();
const region = await screen.findByRole("region", { name: "New chat" });
await waitFor(() =>
expect(
within(region).getByRole("textbox", { name: "Message your agents" }),
).toHaveTextContent("@Scout"),
);
const passes = vi.mocked(f.navigation.passThrough).mock.lastCall?.[0];
expect(passes?.()).toBe(false);
expect(readView(f.scope, "solo:new-draft", null)).toEqual(typed);
});

it("keeps a linked chat draft in Back history, since it holds its source chat", async () => {
const linked = "22222222-2222-4222-8222-222222222222";
const f = fixture({ target: (scope) => soloDraftTarget(scope, { linked }) });
writeView(f.scope, linkedDraftKey(linked), {
channelId: "33333333-3333-4333-8333-333333333333",
link: `buzz://message?channel=33333333-3333-4333-8333-333333333333&id=${"e".repeat(64)}`,
});
f.mount();
await screen.findByRole("textbox");
expect(vi.mocked(f.navigation.passThrough).mock.lastCall?.[0]()).toBe(false);
});

it("keeps an untagged linked chat draft in Back history until its source is used", async () => {
const linked = "22222222-2222-4222-8222-222222222222";
const f = fixture({
agentless: true,
target: (scope) => soloDraftTarget(scope, { linked }),
});
const link = `buzz://message?channel=33333333-3333-4333-8333-333333333333&id=${"e".repeat(64)}`;
writeView(f.scope, linkedDraftKey(linked), {
channelId: "33333333-3333-4333-8333-333333333333",
link,
});
f.mount();
await waitFor(() =>
expect(screen.getByRole("textbox")).toHaveTextContent(link),
);
const passes = vi.mocked(f.navigation.passThrough).mock.lastCall?.[0];
expect(
readView(f.scope, `${linkedDraftKey(linked)}:new-draft`, null),
).toBeNull();
expect(passes?.()).toBe(false);
// As a successful start leaves it: source consumed, draft absent.
writeView(f.scope, linkedDraftKey(linked), null);
expect(passes?.()).toBe(true);
});

it("counts a new chat's attached file as written", async () => {
const f = fixture({ target: soloDraftTarget });
f.mount();
const region = await screen.findByRole("region", { name: "New chat" });
const passes = vi.mocked(f.navigation.passThrough).mock.lastCall?.[0];
expect(passes?.()).toBe(true);
Object.assign(f.session, { attachments: { uploadForNewChat: vi.fn() } });
attachmentDraft(
f.session as unknown as ComposerSession,
`${f.scope}:solo:new-draft`,
"",
true,
).addUploaded({
name: "notes.txt",
url: "https://media.invalid/notes.txt",
type: "text/plain",
size: 5,
sha256: "f".repeat(64),
});
expect(passes?.()).toBe(false);
expect(region).toBeVisible();
});

it("a new Solo chat finishing behind Settings stays put and retires its draft", async () => {
const f = fixture({ behindSettings: true, target: soloDraftTarget });
writeView(f.scope, "solo:new-draft", {
Expand Down
1 change: 1 addition & 0 deletions src/bundled/channels/ChannelsPage.tabs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ async function page(
},
resolve: () => true,
complete: () => true,
passThrough: () => () => {},
}
: undefined;
render(
Expand Down
7 changes: 6 additions & 1 deletion src/bundled/channels/ChannelsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1822,6 +1822,7 @@ function ChannelWorkspace({
host={{ relay, panels, navigation: navigator }}
session={queries}
scope={scope}
visit={navigation}
extensions={extensions}
onPreparing={(pubkeys) => handoff?.prepareDm(pubkeys)}
onOpenThread={openChannelThread}
Expand All @@ -1848,6 +1849,7 @@ function ChannelWorkspace({
])}
linkedId={draft.linked}
source={linkedSource}
visit={navigation}
focusRequest={
navigation?.focus === "preserve" ||
(retiredSoloDraft.generation > 0 &&
Expand Down Expand Up @@ -1876,7 +1878,10 @@ function ChannelWorkspace({
navigator &&
!behindSettings()
)
void navigator.open(soloDraftTarget(scope, draft));
// The chat was never made: its draft takes its place.
void navigator.open(soloDraftTarget(scope, draft), {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 [P3] Cover history replacement after Solo creation fails

When an empty draft starts a chat and readiness rejects with ChatNotStarted, this replacement should restore [opener, draft]. Dropping replace instead leaves [opener, chat, draft], so Back reaches a chat that was never created. Removing replace: true still leaves ChannelsPage.solo.test.tsx passing 42/42; SoloDraft.test.tsx:199–228 only asserts the failure callback.

Add a regression that rejects start with ChatNotStarted and asserts Back returns to the opener. This is a test coverage gap; the current replacement behavior is correct.

replace: true,
});
}}
/>
) : (
Expand Down
1 change: 1 addition & 0 deletions src/bundled/relay-notes/NotesPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,7 @@ it.each(["icons", "", "two\nlines"])(
target: snapshot.entry.target,
signal: snapshot.attempt.signal,
forSession: () => request,
passThrough: () => () => {},
resolve: () => false,
complete: (result) => controller.complete(snapshot.attempt, result),
};
Expand Down
31 changes: 30 additions & 1 deletion src/features/direct-messages/NewMessage.recovery.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { MentionPicker } from "../../bundled/mentions/MentionPicker";
import { MentionCompletion } from "../../bundled/mentions/MentionCompletion";
import { mentionQuery } from "../../bundled/mentions/mention-query";
import { NewMessage } from "./NewMessage";
import type { PageNavigation } from "../navigation/service";
import { OutboxStatus } from "../../bundled/channels/OutboxStatus";
import { createRelayProfiler } from "../relay/profiling";
import { readView, writeView } from "../../shared/view-state";
Expand Down Expand Up @@ -186,9 +187,11 @@ function setup(
function Draft({
owner,
extensions,
visit,
}: {
owner: ReturnType<typeof create>;
extensions?: ConversationExtensions | undefined;
visit?: PageNavigation | undefined;
}) {
const { channels } = useChannelList(owner.session.channels);
return (
Expand Down Expand Up @@ -216,13 +219,15 @@ function setup(
onOpenThread={vi.fn()}
onStarted={onStarted}
extensions={extensions}
visit={visit}
/>
);
}
const mount = (
owner: ReturnType<typeof create>,
extensions?: ConversationExtensions,
) => render(<Draft owner={owner} extensions={extensions} />);
visit?: PageNavigation,
) => render(<Draft owner={owner} extensions={extensions} visit={visit} />);
const user = userEvent.setup();
const compose = async () => {
await user.click(await screen.findByRole("option", { name: "Avery" }));
Expand Down Expand Up @@ -424,6 +429,30 @@ it("removing a failed operation in Diagnostics unlocks the preserved draft on re
expect(t.publish).toHaveBeenCalledTimes(2);
});

it("keeps a failed message in Back history when only the outbox holds it", async () => {
const t = setup();
t.publish.mockRejectedValueOnce(new PublishRejected("Not sent"));
const owner = t.create();
const outbox = owner.session.outbox;
assert.exists(outbox);
const page = t.mount(owner);
await t.compose();
await t.user.click(send());
await screen.findByRole("alert");
await waitFor(() => expect(outbox.snapshot()[0]?.delivery).toBe("failed"));
page.unmount();
// The disposable draft view is gone; the durable outbox entry remains.
writeView(scope, "direct-message:new-draft", null);
const passThrough = vi.fn<PageNavigation["passThrough"]>(() => () => {});
t.mount(owner, undefined, { passThrough } as unknown as PageNavigation);
await waitFor(() =>
expect(screen.getByRole("textbox")).toHaveTextContent(
"Durable first message",
),
);
expect(passThrough.mock.lastCall?.[0]()).toBe(false);
});

it("never publishes if the durable recovery write fails", async () => {
const t = setup();
t.storage.save = async () => {
Expand Down
Loading
Loading