diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index 168be1ecf..1ef95ee4b 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -17,7 +17,9 @@ use crate::{ SendChannelMessageResponse, ThreadRepliesResponse, }, nostr_convert, - relay::{query_relay, submit_event, submit_event_with_keys}, + relay::{ + query_relay, submit_event, submit_event_with_created_at, submit_event_with_keys_created_at, + }, }; // ── Reads (pure-nostr) ────────────────────────────────────────────────────── @@ -558,7 +560,9 @@ pub async fn send_channel_message( } }; - let result = submit_event(builder, &state).await?; + // `created_at` is the signed event's own second, not a post-publication + // clock read — persisted as an event cursor by the Projects opener. + let (result, created_at) = submit_event_with_created_at(builder, &state).await?; let depth = match (&parent_event_id, &resolved_root) { (None, _) => 0, @@ -572,7 +576,7 @@ pub async fn send_channel_message( root_event_id: resolved_root, parent_event_id, depth, - created_at: chrono::Utc::now().timestamp(), + created_at, }) } @@ -820,15 +824,18 @@ pub async fn send_managed_agent_channel_message( &mentions, &client_tags, )?; - let result = - submit_event_with_keys(builder, &state, &keys, submission_auth_tag.as_deref()).await?; + // Same contract as `send_channel_message`: `created_at` is the signed + // event's, not a post-publication clock read. + let (result, created_at) = + submit_event_with_keys_created_at(builder, &state, &keys, submission_auth_tag.as_deref()) + .await?; Ok(SendChannelMessageResponse { event_id: result.event_id, parent_event_id: parent_event_id.clone(), root_event_id: thread_ref.map(|reference| reference.root_event_id.to_hex()), depth: if parent_event_id.is_some() { 1 } else { 0 }, - created_at: chrono::Utc::now().timestamp(), + created_at, }) } diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index 685f83b79..830128dbb 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -537,7 +537,8 @@ pub use get::get_relay_json; mod submit; pub use submit::{ - submit_event, submit_event_at_with_keys, submit_signed_event_at_with_keys, SubmitEventResponse, + submit_event, submit_event_at_with_keys, submit_event_with_created_at, + submit_event_with_keys_created_at, submit_signed_event_at_with_keys, SubmitEventResponse, }; /// Sign an event with explicit keys and POST it to `/events` with NIP-98 auth. diff --git a/desktop/src-tauri/src/relay/submit.rs b/desktop/src-tauri/src/relay/submit.rs index eaad29d3b..52c552523 100644 --- a/desktop/src-tauri/src/relay/submit.rs +++ b/desktop/src-tauri/src/relay/submit.rs @@ -76,3 +76,39 @@ pub async fn submit_event( let keys = state.signing_keys()?; submit_event_at_with_keys(builder, state, &api_base_url, &keys).await } + +/// Like [`submit_event`], but also returns the signed event's `created_at`. +/// +/// Callers that persist a timestamp as an event cursor (e.g. the Projects +/// conversation opener) need the signed event's own second — a +/// post-publication clock read can land a second later and permanently +/// exclude other events stamped in the event's real second. +pub async fn submit_event_with_created_at( + builder: nostr::EventBuilder, + state: &AppState, +) -> Result<(SubmitEventResponse, i64), String> { + let api_base_url = relay_api_base_url_with_override(state); + let keys = state.signing_keys()?; + let event = builder + .sign_with_keys(&keys) + .map_err(|e| format!("failed to sign event: {e}"))?; + let created_at = event.created_at.as_secs() as i64; + let result = submit_signed_event_at_with_keys(&event, state, &api_base_url, &keys).await?; + Ok((result, created_at)) +} + +/// Like `submit_event_with_keys`, but also returns the signed event's +/// `created_at` — same cursor rationale as [`submit_event_with_created_at`]. +pub async fn submit_event_with_keys_created_at( + builder: nostr::EventBuilder, + state: &AppState, + keys: &nostr::Keys, + auth_tag: Option<&str>, +) -> Result<(SubmitEventResponse, i64), String> { + let event = builder + .sign_with_keys(keys) + .map_err(|e| format!("failed to sign event: {e}"))?; + let created_at = event.created_at.as_secs() as i64; + let result = super::submit_signed_event_with_keys(&event, state, keys, auth_tag).await?; + Ok((result, created_at)) +} diff --git a/desktop/src/features/projects/lib/projectAgentConversation.test.mjs b/desktop/src/features/projects/lib/projectAgentConversation.test.mjs index 55a779171..5d5d929b1 100644 --- a/desktop/src/features/projects/lib/projectAgentConversation.test.mjs +++ b/desktop/src/features/projects/lib/projectAgentConversation.test.mjs @@ -18,6 +18,7 @@ import { } from "@/shared/constants/kinds"; const AGENT_PUBKEY = "a".repeat(64); +const SELF_PUBKEY = "b".repeat(64); const WORKSPACE_ID = "wss://relay.example.com"; // The user opened the Projects prompt at this instant (epoch seconds). const PROMPT_AT = 1_752_570_000; @@ -32,7 +33,7 @@ const AGENT = { pubkey: AGENT_PUBKEY, name: "Brain" }; const EXISTING_DM = { id: "dm-channel-1", channelType: "dm", - participantPubkeys: [AGENT_PUBKEY, "b".repeat(64)], + participantPubkeys: [AGENT_PUBKEY, SELF_PUBKEY], lastMessageAt: new Date((PROMPT_AT - 60) * 1_000).toISOString(), }; @@ -54,6 +55,7 @@ test("an existing agent DM is never auto-restored without a stored pointer", () stored: null, channels: [EXISTING_DM], candidates: [AGENT], + currentPubkey: SELF_PUBKEY, }); assert.equal(restored, null); }); @@ -67,6 +69,7 @@ test("restores exactly the conversation this feature persisted", () => { }, channels: [EXISTING_DM], candidates: [AGENT], + currentPubkey: SELF_PUBKEY, }); assert.equal(restored?.channel, EXISTING_DM); assert.equal(restored?.agent, AGENT); @@ -84,6 +87,7 @@ test("pointers to unknown channels or agents are not restorable", () => { stored, channels: [], candidates: [AGENT], + currentPubkey: SELF_PUBKEY, }), null, ); @@ -92,6 +96,60 @@ test("pointers to unknown channels or agents are not restorable", () => { stored, channels: [EXISTING_DM], candidates: [], + currentPubkey: SELF_PUBKEY, + }), + null, + ); +}); + +test("a pointer naming a non-DM or foreign-participant channel is not restorable", () => { + const stored = { + agentPubkey: AGENT_PUBKEY, + channelId: EXISTING_DM.id, + opener: OPENER, + }; + // Same id, but not a DM — a stale/colliding pointer must not render it. + assert.equal( + restoreProjectsAgentConversation({ + stored, + channels: [{ ...EXISTING_DM, channelType: "stream" }], + candidates: [AGENT], + currentPubkey: SELF_PUBKEY, + }), + null, + ); + // A DM with a third participant is someone else's conversation. + assert.equal( + restoreProjectsAgentConversation({ + stored, + channels: [ + { + ...EXISTING_DM, + participantPubkeys: [AGENT_PUBKEY, SELF_PUBKEY, "c".repeat(64)], + }, + ], + candidates: [AGENT], + currentPubkey: SELF_PUBKEY, + }), + null, + ); + // A DM that does not include the agent proves nothing about the pointer. + assert.equal( + restoreProjectsAgentConversation({ + stored, + channels: [{ ...EXISTING_DM, participantPubkeys: [SELF_PUBKEY] }], + candidates: [AGENT], + currentPubkey: SELF_PUBKEY, + }), + null, + ); + // Without a current identity there is nothing to validate against. + assert.equal( + restoreProjectsAgentConversation({ + stored, + channels: [EXISTING_DM], + candidates: [AGENT], + currentPubkey: null, }), null, ); @@ -138,6 +196,31 @@ test("unrelated DM history sharing the opener's second is excluded", () => { assert.equal(isAtOrAfterConversationOpener(opener, OPENER), true); }); +test("a fast reply in the opener's second is admitted via its reply reference", () => { + // An agent reply signed within the opener's second can carry an id greater + // than the opener's, which sorts "older" in relay order. Its `e` tag names + // the opener — causality that must win over the id tiebreak. + const fastReply = { + ...message(PROMPT_AT, KIND_STREAM_MESSAGE_V2, `f${"a".repeat(63)}`), + tags: [["e", OPENER.eventId, "", "reply"]], + }; + // An unrelated same-second event with the same unlucky id ordering and no + // reference to the opener stays excluded. + const unrelated = { + ...message(PROMPT_AT, KIND_STREAM_MESSAGE, `f${"b".repeat(63)}`), + tags: [["e", `9${"9".repeat(63)}`, "", "reply"]], + }; + + assert.equal(isAtOrAfterConversationOpener(fastReply, OPENER), true); + assert.equal(isAtOrAfterConversationOpener(unrelated, OPENER), false); + const opener = message(PROMPT_AT, KIND_STREAM_MESSAGE, OPENER.eventId); + // Same-second sort is stable, so relay arrival order (opener first) holds. + assert.deepEqual( + visibleConversationMessages([unrelated, opener, fastReply], OPENER), + [opener, fastReply], + ); +}); + test("root questions and separately queried replies stay in conversation order", () => { const firstQuestion = message(PROMPT_AT, KIND_STREAM_MESSAGE, OPENER.eventId); const firstAnswer = message(PROMPT_AT + 2, KIND_STREAM_MESSAGE_V2); diff --git a/desktop/src/features/projects/lib/projectAgentConversation.ts b/desktop/src/features/projects/lib/projectAgentConversation.ts index 41e3ebd2f..878de3575 100644 --- a/desktop/src/features/projects/lib/projectAgentConversation.ts +++ b/desktop/src/features/projects/lib/projectAgentConversation.ts @@ -14,18 +14,25 @@ import { normalizePubkey } from "@/shared/lib/pubkey"; * timeline's `(created_at, event_id)` ordering (`compareRelayOrder` in * `channelWindowStore.ts`). A bare timestamp cannot make this call — every * unrelated event sharing the opener's second would pass — which is why the - * opener's exact event id participates. Id equality is checked first because - * the send command stamps its response timestamp after the relay round-trip, - * so the persisted `createdAt` may trail the signed event's by a second. + * opener's exact event id participates. Id equality is checked first so the + * opener itself is admitted even against a legacy persisted `createdAt` that + * trails the signed event's by a second. + * + * Event ids are random within a second, so a fast reply signed in the + * opener's own second can sort "older" than the opener in relay order. A + * reply carries an `e` tag naming the opener — causality the id ordering + * cannot fake — so events that reference the opener are always admitted. */ export function isAtOrAfterConversationOpener( - event: { created_at: number; id: string }, + event: { created_at: number; id: string; tags?: readonly string[][] }, opener: ProjectsConversationOpener, ): boolean { return ( event.id === opener.eventId || event.created_at > opener.createdAt || - (event.created_at === opener.createdAt && event.id <= opener.eventId) + (event.created_at === opener.createdAt && event.id <= opener.eventId) || + (event.tags?.some((tag) => tag[0] === "e" && tag[1] === opener.eventId) ?? + false) ); } @@ -34,6 +41,11 @@ export function isAtOrAfterConversationOpener( * feature persisted earlier. DM channels are reused across the app, so * inferring a conversation from "the most recent agent DM" would surface * unrelated chat history on the Projects page — never infer one here. + * + * A stored channel id alone is not proof either: ids can collide across + * relays, and a stale pointer could name a group channel. The channel must + * be a DM whose participants are exactly the agent and the current user — + * anything else renders someone else's conversation and is not restorable. */ export function restoreProjectsAgentConversation< Agent extends { pubkey: string }, @@ -41,10 +53,12 @@ export function restoreProjectsAgentConversation< stored, channels, candidates, + currentPubkey, }: { stored: StoredProjectsAgentConversation | null; channels: readonly Channel[]; candidates: readonly Agent[]; + currentPubkey: string | null; }): { channel: Channel; agent: Agent; @@ -52,7 +66,7 @@ export function restoreProjectsAgentConversation< } | null { // Only pointers anchored to a concrete opener event are restorable — // anything weaker would render DM history that predates the conversation. - if (!stored) return null; + if (!stored || !currentPubkey) return null; const channel = channels.find( (candidate) => candidate.id === stored.channelId, ); @@ -60,7 +74,14 @@ export function restoreProjectsAgentConversation< const agent = candidates.find( (candidate) => candidate.pubkey === agentPubkey, ); - if (!channel || !agent) return null; + if (!channel || !agent || channel.channelType !== "dm") return null; + const participants = channel.participantPubkeys.map(normalizePubkey); + const self = normalizePubkey(currentPubkey); + const hasAgent = participants.includes(agentPubkey); + const hasStranger = participants.some( + (participant) => participant !== agentPubkey && participant !== self, + ); + if (!hasAgent || hasStranger) return null; return { agent, channel, opener: stored.opener }; } diff --git a/desktop/src/features/projects/lib/projectDetailAgentContext.test.mjs b/desktop/src/features/projects/lib/projectDetailAgentContext.test.mjs index 51191122b..d8e6ef13c 100644 --- a/desktop/src/features/projects/lib/projectDetailAgentContext.test.mjs +++ b/desktop/src/features/projects/lib/projectDetailAgentContext.test.mjs @@ -4,7 +4,6 @@ import test from "node:test"; import { buildProjectDetailAgentContext, projectDetailAgentContextBlock, - stripProjectDetailAgentContext, untrustedPromptValue, } from "./projectDetailAgentContext.ts"; @@ -99,18 +98,3 @@ test("untrustedPromptValue collapses control characters and caps length", () => const quoted = untrustedPromptValue(long, 20); assert.equal(quoted, `"${"x".repeat(19)}…"`); }); - -test("strips hidden page context from the displayed user message", () => { - const content = `Explain this file${projectDetailAgentContextBlock( - buildProjectDetailAgentContext(base), - )}`; - assert.equal(stripProjectDetailAgentContext(content), "Explain this file"); -}); - -test("stripping uses the appended footer, not an earlier marker in the prompt", () => { - const userText = `Why does my draft say?\n---\nCurrent Buzz project page:\n- Project: mine`; - const content = `${userText}${projectDetailAgentContextBlock( - buildProjectDetailAgentContext(base), - )}`; - assert.equal(stripProjectDetailAgentContext(content), userText); -}); diff --git a/desktop/src/features/projects/lib/projectDetailAgentContext.ts b/desktop/src/features/projects/lib/projectDetailAgentContext.ts index c2ddd43e4..8a3cd2802 100644 --- a/desktop/src/features/projects/lib/projectDetailAgentContext.ts +++ b/desktop/src/features/projects/lib/projectDetailAgentContext.ts @@ -149,13 +149,3 @@ export function projectDetailAgentContextBlock( ); return lines.join("\n"); } - -export function stripProjectDetailAgentContext(content: string) { - // The generated footer is appended at the end, so search from the end: - // user-authored text may legitimately contain an earlier marker. - const markerIndex = content.lastIndexOf( - `---\n${PROJECT_PAGE_CONTEXT_MARKER}`, - ); - if (markerIndex === -1) return content; - return content.slice(0, markerIndex).replace(/\n+$/, ""); -} diff --git a/desktop/src/features/projects/ui/ProjectAgentChatPanel.tsx b/desktop/src/features/projects/ui/ProjectAgentChatPanel.tsx index 955244910..0c24316eb 100644 --- a/desktop/src/features/projects/ui/ProjectAgentChatPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectAgentChatPanel.tsx @@ -5,11 +5,10 @@ import { toast } from "sonner"; import { useStartManagedAgentMutation } from "@/features/agents/hooks"; import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane"; import { useChannelsQuery, useOpenDmMutation } from "@/features/channels/hooks"; +import { normalizeRelayUrl } from "@/features/communities/communityStorage"; +import { useCommunities } from "@/features/communities/useCommunities"; import type { ProjectDetailAgentContext } from "@/features/projects/lib/projectDetailAgentContext"; -import { - projectDetailAgentContextBlock, - stripProjectDetailAgentContext, -} from "@/features/projects/lib/projectDetailAgentContext"; +import { projectDetailAgentContextBlock } from "@/features/projects/lib/projectDetailAgentContext"; import { restoreProjectsAgentConversation } from "@/features/projects/lib/projectAgentConversation"; import { clearStoredProjectsAgentConversation, @@ -53,7 +52,19 @@ export function ProjectAgentChatPanel({ sharedHeaderBackdrop?: boolean; widthPx: number; }) { - const storageScope = `detail:${context.repoAddress}`; + const { activeCommunity } = useCommunities(); + // Repository coordinates (`kind:owner:dtag`) are not globally unique — the + // same address can exist on two relays. Scope persistence, drafts, and + // restore to the community's relay identity so a community switch can + // never surface the other tenant's conversation. No relay identity means + // nothing to safely restore against, so the scope stays null (no-op reads + // and writes). + const relayScope = activeCommunity?.relayUrl + ? normalizeRelayUrl(activeCommunity.relayUrl) + : null; + const storageScope = relayScope + ? `detail:${relayScope}:${context.repoAddress}` + : null; const [isSending, setIsSending] = React.useState(false); const [storedConversation, setStoredConversation] = React.useState(() => @@ -81,9 +92,15 @@ export function ProjectAgentChatPanel({ restoreProjectsAgentConversation({ candidates, channels: channelsQuery.data ?? [], + currentPubkey: identityQuery.data?.pubkey ?? null, stored: storedConversation, }), - [candidates, channelsQuery.data, storedConversation], + [ + candidates, + channelsQuery.data, + identityQuery.data?.pubkey, + storedConversation, + ], ); React.useEffect(() => { @@ -187,7 +204,6 @@ export function ProjectAgentChatPanel({ channel={conversation.channel} currentPubkey={identityQuery.data?.pubkey ?? null} selfAvatarUrl={profileQuery.data?.avatarUrl ?? null} - stripSelfContent={stripProjectDetailAgentContext} opener={conversation.opener} /> ) : ( @@ -207,7 +223,7 @@ export function ProjectAgentChatPanel({ channelType="dm" containerClassName="px-3 pb-3" disabled={!selectedAgent || isSending} - draftKey={`project-agent:${storageScope}`} + draftKey={`project-agent:${storageScope ?? "unscoped"}`} isSending={isSending} layoutMode="standalone" onSend={handleSubmit} diff --git a/desktop/src/features/projects/ui/ProjectDetailRightPanel.tsx b/desktop/src/features/projects/ui/ProjectDetailRightPanel.tsx index 98bbedeb4..3fbf8d7a1 100644 --- a/desktop/src/features/projects/ui/ProjectDetailRightPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectDetailRightPanel.tsx @@ -1,5 +1,7 @@ import type * as React from "react"; +import { normalizeRelayUrl } from "@/features/communities/communityStorage"; +import { useCommunities } from "@/features/communities/useCommunities"; import type { ProjectDetailAgentContext } from "@/features/projects/lib/projectDetailAgentContext"; import { ProjectAgentChatPanel } from "./ProjectAgentChatPanel"; import { ProjectRepositoryActionsPanel } from "./ProjectRepositoryActionsPanel"; @@ -21,12 +23,19 @@ export function ProjectDetailRightPanel({ mode: ProjectRightPanelMode; sharedHeaderBackdrop?: boolean; }) { + const { activeCommunity } = useCommunities(); if (mode === "chat") { + // Remount on community identity as well as repository address: the same + // repo coordinate can exist in two communities, and retained panel state + // (conversation, opener) must never cross that tenant boundary. + const relayScope = activeCommunity?.relayUrl + ? normalizeRelayUrl(activeCommunity.relayUrl) + : ""; return ( string; opener: ProjectsConversationOpener; }) { useChannelSubscription(channel); @@ -290,31 +282,21 @@ export function ConversationThread({ currentPubkey ?? undefined, selfAvatarUrl, profiles, - ) - .filter( - (message) => - (message.kind === KIND_STREAM_MESSAGE || - message.kind === KIND_STREAM_MESSAGE_V2) && - isAtOrAfterConversationOpener( - { created_at: message.createdAt, id: message.id }, - opener, - ), - ) - .map((message) => - normalizedCurrent && - message.pubkey && - normalizePubkey(message.pubkey) === normalizedCurrent - ? { ...message, body: stripSelfContent(message.body) } - : message, - ); + ).filter( + (message) => + (message.kind === KIND_STREAM_MESSAGE || + message.kind === KIND_STREAM_MESSAGE_V2) && + isAtOrAfterConversationOpener( + { created_at: message.createdAt, id: message.id, tags: message.tags }, + opener, + ), + ); }, [ channel, currentPubkey, messagesQuery.data, - normalizedCurrent, profiles, selfAvatarUrl, - stripSelfContent, threadReplies.events, opener, ]); @@ -422,9 +404,15 @@ export function ProjectsAgentPromptPage({ restoreProjectsAgentConversation({ candidates, channels: channelsQuery.data ?? [], + currentPubkey: identityQuery.data?.pubkey ?? null, stored: storedConversation, }), - [candidates, channelsQuery.data, storedConversation], + [ + candidates, + channelsQuery.data, + identityQuery.data?.pubkey, + storedConversation, + ], ); React.useEffect(() => { diff --git a/desktop/src/features/projects/useProjectRepositoryRefSelection.test.mjs b/desktop/src/features/projects/useProjectRepositoryRefSelection.test.mjs new file mode 100644 index 000000000..347a17ac1 --- /dev/null +++ b/desktop/src/features/projects/useProjectRepositoryRefSelection.test.mjs @@ -0,0 +1,95 @@ +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + }); +}); + +afterEach(async () => { + const { cleanup } = await import("@testing-library/react"); + cleanup(); +}); + +after(() => dom.window.close()); + +const REPO_A = { + branchOptions: ["main", "release"], + defaultBranch: "main", + projectAvailable: true, + projectPending: false, + repositoryId: "30617:owner:repo-a", + tags: [{ name: "v1" }], +}; + +const REPO_B = { + branchOptions: ["trunk", "release"], + defaultBranch: "trunk", + projectAvailable: true, + projectPending: false, + repositoryId: "30617:owner:repo-b", + tags: [{ name: "v1" }], +}; + +async function renderSelection(initialProps) { + const { act, renderHook } = await import("@testing-library/react"); + const rendered = renderHook( + (props) => { + const { useProjectRepositoryRefSelection } = hookModule; + return useProjectRepositoryRefSelection(props); + }, + { initialProps }, + ); + return { act, ...rendered }; +} + +let hookModule; +before(async () => { + hookModule = await import("./useProjectRepositoryRefSelection.ts"); +}); + +test("a repository switch resets a same-named branch selection to the new default", async () => { + const { act, rerender, result } = await renderSelection(REPO_A); + + act(() => result.current.selectBranch("release")); + assert.equal(result.current.activeBranch, "release"); + + // Repo B also has a `release` branch — it must NOT survive the switch. + rerender(REPO_B); + assert.equal(result.current.activeBranch, "trunk"); + assert.equal(result.current.selectedTag, null); +}); + +test("a repository switch resets a same-named tag selection", async () => { + const { act, rerender, result } = await renderSelection(REPO_A); + + act(() => result.current.selectTag("v1")); + assert.equal(result.current.selectedTag, "v1"); + + // Repo B also has a `v1` tag — it must NOT survive the switch. + rerender(REPO_B); + assert.equal(result.current.selectedTag, null); + assert.equal(result.current.activeBranch, "trunk"); +}); + +test("selections survive option refreshes within the same repository", async () => { + const { act, rerender, result } = await renderSelection(REPO_A); + + act(() => result.current.selectBranch("release")); + act(() => result.current.selectTag("v1")); + + // Same repository, new option arrays (a refetch) — selection is kept. + rerender({ ...REPO_A, branchOptions: ["main", "release", "feature"] }); + assert.equal(result.current.activeBranch, "release"); + assert.equal(result.current.selectedTag, "v1"); +}); diff --git a/desktop/src/features/projects/useProjectRepositoryRefSelection.ts b/desktop/src/features/projects/useProjectRepositoryRefSelection.ts index 9eb911228..8b7e30f4b 100644 --- a/desktop/src/features/projects/useProjectRepositoryRefSelection.ts +++ b/desktop/src/features/projects/useProjectRepositoryRefSelection.ts @@ -5,14 +5,34 @@ export function useProjectRepositoryRefSelection(input: { defaultBranch: string | null; projectAvailable: boolean; projectPending: boolean; + /** Identity of the repository the options describe. A switch to a + * different repository resets the selection even when the new repository + * happens to have a branch or tag with the same name — carrying `release` + * from repo A into repo B would silently retarget files, actions, and + * agent context at a ref the user never chose. */ + repositoryId: string | null; tags: Array<{ name: string }>; }) { const [selectedBranch, setSelectedBranch] = React.useState( null, ); const [selectedTag, setSelectedTag] = React.useState(null); + const [selectionRepositoryId, setSelectionRepositoryId] = React.useState( + input.repositoryId, + ); + // Reset during render (not in an effect) so a repository switch can never + // paint one frame with the previous repository's same-named selection. + if (selectionRepositoryId !== input.repositoryId) { + setSelectionRepositoryId(input.repositoryId); + setSelectedBranch(null); + setSelectedTag(null); + } + const staleSelection = selectionRepositoryId !== input.repositoryId; const activeBranch = - selectedBranch ?? input.defaultBranch ?? input.branchOptions[0] ?? null; + (staleSelection ? null : selectedBranch) ?? + input.defaultBranch ?? + input.branchOptions[0] ?? + null; React.useEffect(() => { if (!input.projectAvailable) { @@ -52,7 +72,7 @@ export function useProjectRepositoryRefSelection(input: { return { activeBranch, selectBranch, - selectedTag, + selectedTag: staleSelection ? null : selectedTag, selectTag, }; }