diff --git a/src/bundled/agents/AgentsPage.test.tsx b/src/bundled/agents/AgentsPage.test.tsx index d1f5f21a5a..8653537c73 100644 --- a/src/bundled/agents/AgentsPage.test.tsx +++ b/src/bundled/agents/AgentsPage.test.tsx @@ -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, }; @@ -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 }, diff --git a/src/bundled/channels/ChannelsPage.conversion.test.tsx b/src/bundled/channels/ChannelsPage.conversion.test.tsx index 9d5c25bddf..c1a66136f7 100644 --- a/src/bundled/channels/ChannelsPage.conversion.test.tsx +++ b/src/bundled/channels/ChannelsPage.conversion.test.tsx @@ -170,6 +170,7 @@ function fixture() { }, signal: visit.signal, forSession: () => navigation, + passThrough: () => () => {}, complete: () => true, resolve: () => true, }; diff --git a/src/bundled/channels/ChannelsPage.solo.test.tsx b/src/bundled/channels/ChannelsPage.solo.test.tsx index a93db216d7..581148e6f3 100644 --- a/src/bundled/channels/ChannelsPage.solo.test.tsx +++ b/src/bundled/channels/ChannelsPage.solo.test.tsx @@ -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"; @@ -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. */ @@ -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, @@ -286,6 +293,7 @@ function fixture({ target: target?.(scope) ?? conversationTarget(scope, id), signal: abort.signal, forSession: () => navigation, + passThrough: vi.fn(() => () => {}), complete, resolve: () => true, }; @@ -335,6 +343,7 @@ function fixture({ ); return { + navigation, mount: () => render(page()), mountWithSidebar: () => render(page(true)), mountWithToasts: () => render({page()}), @@ -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", { diff --git a/src/bundled/channels/ChannelsPage.tabs.test.tsx b/src/bundled/channels/ChannelsPage.tabs.test.tsx index 374961fc2c..c17768e8f2 100644 --- a/src/bundled/channels/ChannelsPage.tabs.test.tsx +++ b/src/bundled/channels/ChannelsPage.tabs.test.tsx @@ -215,6 +215,7 @@ async function page( }, resolve: () => true, complete: () => true, + passThrough: () => () => {}, } : undefined; render( diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index c0da90a17e..3e9c23377e 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -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} @@ -1848,6 +1849,7 @@ function ChannelWorkspace({ ])} linkedId={draft.linked} source={linkedSource} + visit={navigation} focusRequest={ navigation?.focus === "preserve" || (retiredSoloDraft.generation > 0 && @@ -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), { + replace: true, + }); }} /> ) : ( diff --git a/src/bundled/relay-notes/NotesPage.test.tsx b/src/bundled/relay-notes/NotesPage.test.tsx index 70a6da573a..596c03d541 100644 --- a/src/bundled/relay-notes/NotesPage.test.tsx +++ b/src/bundled/relay-notes/NotesPage.test.tsx @@ -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), }; diff --git a/src/features/direct-messages/NewMessage.recovery.test.tsx b/src/features/direct-messages/NewMessage.recovery.test.tsx index 79ac3e59ad..622f0eb7b0 100644 --- a/src/features/direct-messages/NewMessage.recovery.test.tsx +++ b/src/features/direct-messages/NewMessage.recovery.test.tsx @@ -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"; @@ -186,9 +187,11 @@ function setup( function Draft({ owner, extensions, + visit, }: { owner: ReturnType; extensions?: ConversationExtensions | undefined; + visit?: PageNavigation | undefined; }) { const { channels } = useChannelList(owner.session.channels); return ( @@ -216,13 +219,15 @@ function setup( onOpenThread={vi.fn()} onStarted={onStarted} extensions={extensions} + visit={visit} /> ); } const mount = ( owner: ReturnType, extensions?: ConversationExtensions, - ) => render(); + visit?: PageNavigation, + ) => render(); const user = userEvent.setup(); const compose = async () => { await user.click(await screen.findByRole("option", { name: "Avery" })); @@ -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(() => () => {}); + 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 () => { diff --git a/src/features/direct-messages/NewMessage.test.tsx b/src/features/direct-messages/NewMessage.test.tsx index 251a30d9ed..5f5ef73ccc 100644 --- a/src/features/direct-messages/NewMessage.test.tsx +++ b/src/features/direct-messages/NewMessage.test.tsx @@ -12,6 +12,7 @@ import { screen, waitFor, } from "@testing-library/react"; +import type { PageNavigation } from "../navigation/service"; import userEvent from "@testing-library/user-event"; import type { ChannelSummary } from "../relay/contracts"; import type { OutgoingEvent } from "../relay/outbox"; @@ -206,7 +207,7 @@ function setup() { signal: new AbortController().signal, }), }); - const mount = () => + const mount = (visit?: PageNavigation) => render( , @@ -639,6 +641,58 @@ it("reveals the highlighted person on every arrow key and when the list reopens" ); }); +it("stays out of Back history until a message is written, whoever it addresses", async () => { + const t = setup(); + const passThrough = vi.fn(() => () => {}); + t.mount({ passThrough } as unknown as PageNavigation); + const passes = passThrough.mock.lastCall?.[0]; + expect(passes?.()).toBe(true); + await t.user.click(await screen.findByRole("option", { name: "Person 1" })); + expect(passes?.()).toBe(true); + await t.user.type(screen.getByRole("textbox"), "Hello"); + expect(passes?.()).toBe(false); +}); + +it("stays in Back history once a message is written, even if it cannot be saved", async () => { + const draftStorageKey = `buzz-view.v1:${JSON.stringify([scope, "direct-message: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 { + const t = setup(); + const passThrough = vi.fn(() => () => {}); + t.mount({ passThrough } as unknown as PageNavigation); + const passes = passThrough.mock.lastCall?.[0]; + await t.user.click(await screen.findByRole("option", { name: "Person 1" })); + await t.user.type(screen.getByRole("textbox"), "Hello"); + expect(localStorage.getItem(draftStorageKey)).toBeNull(); + expect(passes?.()).toBe(false); + } finally { + vi.mocked(Storage.prototype.setItem).mockRestore(); + } +}); + +it("leaves Back history once its written message is sent", async () => { + const t = setup(); + const passThrough = vi.fn(() => () => {}); + t.mount({ passThrough } as unknown as PageNavigation); + const passes = passThrough.mock.lastCall?.[0]; + await t.user.click(await screen.findByRole("option", { name: "Person 1" })); + await t.user.type(screen.getByRole("textbox"), "Hello"); + expect(passes?.()).toBe(false); + await t.user.click(send()); + await waitFor(() => expect(t.onStarted).toHaveBeenCalledOnce()); + // The sent message is spent; the conversation it started takes its place. + expect(passes?.()).toBe(true); +}); + it("opens blank and focused, adds multiple recipients, deduplicates, and enforces eight", async () => { const t = setup(); t.directMessages.people.mockResolvedValue({ diff --git a/src/features/direct-messages/NewMessage.tsx b/src/features/direct-messages/NewMessage.tsx index f27551da87..a2efe98bf0 100644 --- a/src/features/direct-messages/NewMessage.tsx +++ b/src/features/direct-messages/NewMessage.tsx @@ -27,6 +27,8 @@ import { clearView, readView, writeView } from "../../shared/view-state"; import { Button } from "../../shared/design-system/ui/Button"; import { RecipientPicker } from "./RecipientPicker"; import type { Recipient } from "./usePeople"; +import { usePassThroughUntilDrafted } from "../messages/draft-visit"; +import type { PageNavigation } from "../navigation/service"; import styles from "./NewMessage.module.css"; const draftKey = "direct-message:new-draft"; @@ -126,9 +128,12 @@ export function NewMessage({ host, onOpenThread, onStarted, + visit, }: { session: RelaySession; scope: string; + /** The visit presenting this composer, a Back stop only once drafted. */ + visit?: PageNavigation | undefined; extensions?: ConversationExtensions | undefined; channels: readonly ChannelSummary[]; host: ConversationHostServices; @@ -141,6 +146,17 @@ export function NewMessage({ ): void; onStarted(channelId: string, messageId: string): void; }) { + // A message held for delivery or retry is shown even when its draft view is + // gone, so it counts as drafted too. + const held = useRef(undefined); + const heldDraft = useCallback(() => held.current, []); + const draftEdited = usePassThroughUntilDrafted( + visit, + session, + scope, + draftKey, + heldDraft, + ); const [recipients, setRecipients] = useState(() => savedRecipients(scope, session.viewer), ); @@ -174,16 +190,18 @@ export function NewMessage({ } catch { /* Readiness presents invalid recovery. */ } + held.current = pending?.draft; const failed = pending && session.directMessages.delivery(pending.id) === "failed"; const locked = !ready || !!busy || (!!pending && !failed); const retire = useCallback(() => { retiring.current = undefined; prepared.current = undefined; + draftEdited(undefined); setRecipients([]); setRestoredDraft(mentionDraft("")); setDraftRevision((revision) => revision + 1); - }, []); + }, [draftEdited]); useLayoutEffect(() => { const confirmed = pending && session.directMessages.delivery(pending.id); if (pending) { @@ -476,6 +494,7 @@ export function NewMessage({ submission={{ draftKey, recoveredDraft: restoredDraft, + onDraftChange: draftEdited, locked, disabled: !ready || diff --git a/src/features/messages/MessageComposer.tsx b/src/features/messages/MessageComposer.tsx index 1c5a754fd2..f02e010cac 100644 --- a/src/features/messages/MessageComposer.tsx +++ b/src/features/messages/MessageComposer.tsx @@ -273,6 +273,8 @@ export type MessageComposerProps = { disabled: boolean; /** Files upload before the conversation exists; submit sends them. */ attachments?: boolean; + /** Hears each edit, whether or not saving it succeeded. */ + onDraftChange?: ((draft: MentionDraft) => void) | undefined; submit: (draft: MentionDraft, attachments: AttachmentDraft) => void; }; }; @@ -762,8 +764,10 @@ function Composer({ valueRef.current = next; updateDraft(next); if (save && !editing.target) { - if (submission) writeView(scope, draftKey, next); - else { + if (submission) { + writeView(scope, draftKey, next); + submission.onDraftChange?.(next); + } else { dirty.current = true; if (!conflict && !accepted) persist(next); } diff --git a/src/features/messages/ThreadPanel.test.tsx b/src/features/messages/ThreadPanel.test.tsx index b7070327f9..6ab407a3b7 100644 --- a/src/features/messages/ThreadPanel.test.tsx +++ b/src/features/messages/ThreadPanel.test.tsx @@ -149,6 +149,7 @@ function ordinaryNavigation() { complete: vi.fn(() => true), resolve: vi.fn(() => true), forSession: vi.fn(), + passThrough: () => () => {}, } satisfies PageNavigation; } function messagesHarness( diff --git a/src/features/messages/attachment-panes.test.tsx b/src/features/messages/attachment-panes.test.tsx index 4477673efb..c88308ec02 100644 --- a/src/features/messages/attachment-panes.test.tsx +++ b/src/features/messages/attachment-panes.test.tsx @@ -207,6 +207,7 @@ it.each([false, true])( ), signal: new AbortController().signal, forSession: () => navigation, + passThrough: () => () => {}, complete: () => true, resolve: () => true, }; diff --git a/src/features/messages/draft-visit.ts b/src/features/messages/draft-visit.ts new file mode 100644 index 0000000000..a61298cdd3 --- /dev/null +++ b/src/features/messages/draft-visit.ts @@ -0,0 +1,44 @@ +import { useCallback, useEffect, useRef } from "react"; +import type { PageNavigation } from "../navigation/service"; +import { readView } from "../../shared/view-state"; +import { attachmentDraftCount } from "./attachment-draft"; +import type { ComposerSession } from "./composer-session"; +import { + hasDraftContent, + mentionDraft, + type MentionDraft, +} from "./mention-draft"; + +/** A new conversation's visit is a Back stop only once its composer draft + * holds something the person wrote; until then the next page replaces it. + * `initialDraft` reads what the composer shows while nothing is saved. + * Returns the composer's edit listener; the page passes `undefined` once it + * has spent the draft. */ +export function usePassThroughUntilDrafted( + visit: PageNavigation | undefined, + session: ComposerSession, + scope: string, + draftKey: string, + initialDraft?: () => MentionDraft | string | undefined, +) { + // The composer's latest edit stands for the saved draft, which may be + // missing when storage fails, until the page spends it. + const edited = useRef(undefined); + useEffect( + () => + visit?.passThrough( + () => + !hasDraftContent( + edited.current ?? + mentionDraft( + readView(scope, draftKey, initialDraft?.() ?? ""), + ), + attachmentDraftCount(session, `${scope}:${draftKey}`), + ), + ), + [visit, session, scope, draftKey, initialDraft], + ); + return useCallback((draft: MentionDraft | undefined) => { + edited.current = draft; + }, []); +} diff --git a/src/features/navigation/controller.test.ts b/src/features/navigation/controller.test.ts index d8a8dff145..26157009e7 100644 --- a/src/features/navigation/controller.test.ts +++ b/src/features/navigation/controller.test.ts @@ -267,6 +267,72 @@ describe("navigation attempts over one history driver", () => { nav.forward(); expect(nav.snapshot().entry.target).toEqual(settings); }); + describe("a visit whose page passes through", () => { + const draft: OpenTarget = { ...page, pageId: "draft" }; + const other: OpenTarget = { ...page, pageId: "other" }; + function onDraft(passes: () => boolean) { + const t = setup(); + void t.navigation.open(page); + void t.navigation.open(draft); + const stop = t.host.passThrough(t.navigation.snapshot().entry.id, passes); + return { ...t, stop }; + } + it("is replaced by the next page while it holds nothing", () => { + const { navigation: nav } = onDraft(() => true); + void nav.open(other); + nav.back(); + expect(nav.snapshot().entry.target).toEqual(page); + }); + it("returns to its opener when that page opens again", () => { + const t = setup(); + const nav = t.navigation; + void nav.open(page); + const opener = nav.snapshot().entry; + void nav.open(draft); + t.host.passThrough(nav.snapshot().entry.id, () => true); + void nav.open(page); + expect(nav.snapshot().entry).toBe(opener); + nav.back(); + expect(nav.snapshot().entry.target).not.toEqual(page); + }); + it("stays a Back stop while it holds something", () => { + const { navigation: nav } = onDraft(() => false); + void nav.open(other); + nav.back(); + expect(nav.snapshot().entry.target).toEqual(draft); + }); + it("stays under Settings, which returns to it", () => { + const { navigation: nav } = onDraft(() => true); + void nav.open(settings); + nav.back(); + expect(nav.snapshot().entry.target).toEqual(draft); + }); + it("is still replaced after returning from Settings", () => { + const { navigation: nav } = onDraft(() => true); + void nav.open(settings); + nav.back(); + expect(nav.snapshot().canGoForward).toBe(true); + void nav.open(other); + nav.back(); + expect(nav.snapshot().entry.target).toEqual(page); + }); + it("stays when its page cannot answer", () => { + const { navigation: nav } = onDraft(() => { + throw new Error("unavailable"); + }); + void nav.open(other); + expect(nav.snapshot().entry.target).toEqual(other); + nav.back(); + expect(nav.snapshot().entry.target).toEqual(draft); + }); + it("stays once its page stops reporting", () => { + const { navigation: nav, stop } = onDraft(() => true); + stop(); + void nav.open(other); + nav.back(); + expect(nav.snapshot().entry.target).toEqual(draft); + }); + }); it("uses driver-initiated traversal and bounds the memory driver without inventing history", () => { const { navigation: nav, history } = setup(2); void nav.open(settings); diff --git a/src/features/navigation/controller.ts b/src/features/navigation/controller.ts index b1842a9974..dce96887b7 100644 --- a/src/features/navigation/controller.ts +++ b/src/features/navigation/controller.ts @@ -96,6 +96,16 @@ export function createNavigationController( // while one is attached. let presenters = 0; let ingressRetry: (() => Promise) | undefined; + // Visits whose page currently reports nothing worth returning to. + const passing = new Map boolean>(); + // A page that cannot answer keeps its visit, never the opening. + const passes = (entryId: string) => { + try { + return passing.get(entryId)?.() === true; + } catch { + return false; + } + }; const listeners = new Set<() => void>(); // Commit state and capture each caller's result before invoking external code. // Reentrant opens run against committed state and own their own result; deferred @@ -355,9 +365,16 @@ export function createNavigationController( opening = true; openingFocus = options?.focus; returningFocus = undefined; + // A passing visit leaves as a dismissal does: back to its opener when + // that is the page opening, else replaced by it. Settings returns to + // that visit, so it never replaces it. Any forward entries stay. + const dismiss = + options?.dismiss || + (target.kind !== "settings" && + passes(history.snapshot().current.id)); const previous = history.snapshot().previous; if ( - options?.dismiss && + dismiss && previous && targetKey(previous.target) === targetKey(target) && history.returnTo(previous.id) @@ -365,7 +382,7 @@ export function createNavigationController( if (openingFocus) returningFocus = openingFocus; return active.promise; } - if (options?.replace || options?.dismiss) history.replace(target); + if (options?.replace || dismiss) history.replace(target); else { const { current, previous } = history.snapshot(); if ( @@ -503,6 +520,15 @@ export function createNavigationController( return true; }); }, + /** While registered, the page presenting visit `entryId` reports through + * `passes` whether it holds anything to come back to. When it holds + * nothing, the next page opened from it replaces it in history. */ + passThrough(entryId: string, passes: () => boolean) { + passing.set(entryId, passes); + return () => { + if (passing.get(entryId) === passes) passing.delete(entryId); + }; + }, /** Host-only traversal to a retained visit; does not retire dismissed content. */ returnTo(entryId: string) { return transaction(() => !disposed && history.returnTo(entryId)); @@ -542,6 +568,7 @@ export function createNavigationController( const operation = active; effects.push(() => operation.controller.abort(NAVIGATION_ABORTED)); listeners.clear(); + passing.clear(); history.dispose(); }); }, diff --git a/src/features/navigation/service.ts b/src/features/navigation/service.ts index 0d197d4cb1..d130eaffc8 100644 --- a/src/features/navigation/service.ts +++ b/src/features/navigation/service.ts @@ -22,6 +22,10 @@ export type PageNavigation = Readonly<{ forSession(relay: RelayData, connection: RelaySnapshot): PageNavigation; /** Resolve the pending visit's default destination without replacing its caller. */ resolve(target: OpenTarget): boolean; + /** While registered, `passes` says whether this visit holds nothing to come + * back to; then the next page opened from it takes its place in history. + * Returns the unregister function. */ + passThrough(passes: () => boolean): () => void; complete( result: Extract, ): boolean; @@ -91,6 +95,8 @@ export function provideNavigation( ? (attempt.focus ?? "content") : "preserve"; }, + passThrough: (passes) => + controller.passThrough(attempt.entry.id, passes), resolve: (target) => { if ( retainUnderSettings && @@ -207,6 +213,7 @@ function bindPresentation( }, resolve: (target) => valid() && parent.resolve(target), complete: (result) => valid() && parent.complete(result), + passThrough: (passes) => parent.passThrough(passes), forSession(relay, connection) { let connections = sessions.get(relay); if (!connections) { diff --git a/src/features/solo/SoloDraft.tsx b/src/features/solo/SoloDraft.tsx index 7468fcf9f7..c55c2f5d0c 100644 --- a/src/features/solo/SoloDraft.tsx +++ b/src/features/solo/SoloDraft.tsx @@ -1,4 +1,4 @@ -import { linkedDraftKey, type LinkedChat } from "./linked-chat"; +import { linkedDraftKey, readLinkedChat, type LinkedChat } from "./linked-chat"; import { canonicalDetailsName, detailsDraftErrors, @@ -15,7 +15,7 @@ import { parseChatSetup, type ChatSetup, } from "./workspace"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { RelaySession } from "../relay/session"; import { readView, @@ -40,6 +40,8 @@ import { type AttachmentDraft, } from "../messages/attachment-draft"; import type { ConversationExtensions } from "../conversation/contracts"; +import { usePassThroughUntilDrafted } from "../messages/draft-visit"; +import type { PageNavigation } from "../navigation/service"; import styles from "./Solo.module.css"; /** Starting a new chat from a project starts it in that project, even when an @@ -95,7 +97,10 @@ export function SoloDraft({ agent, linkedId, source, + visit, }: { + /** The visit presenting this draft, a Back stop only once drafted. */ + visit?: PageNavigation | undefined; linkedId?: string | undefined; source?: LinkedChat | undefined; /** Start with this agent chosen, as when search opens a new chat with it. */ @@ -118,6 +123,18 @@ export function SoloDraft({ // Before the composer reads it: a named agent replaces a saved draft that // only tags another agent, since that tag was the earlier default. useState(() => dropOtherAgentTag(scope, `${draftKey}:new-draft`, agent)); + // Read the source live: starting the chat consumes it. + const linkedSource = useCallback( + () => (source ? readLinkedChat(scope, draftKey)?.link : undefined), + [source, scope, draftKey], + ); + const draftEdited = usePassThroughUntilDrafted( + visit, + session, + scope, + `${draftKey}:new-draft`, + linkedSource, + ); const [settingsOpen, setSettingsOpen] = useState(false); // New chats tag their agent in the draft: the one the route names, else the // default. @@ -320,6 +337,7 @@ export function SoloDraft({ // next start from that source seeds its link again. writeView(scope, `${draftKey}:new-draft`, source ? null : ""); writeView(scope, `${draftKey}:new-project`, null); + draftEdited(undefined); onStarted(channelId); // Only a start that made no chat ends here; the chat and its messages // are dropped (see channelWrites.start). Any other miss leaves the chat @@ -378,6 +396,7 @@ export function SoloDraft({ locked: busy, disabled: busy || !available, attachments: true, + onDraftChange: draftEdited, submit: (draft, files) => { void start(draft, files); },