fix(desktop): address second-round review findings on project agent workspaces

- Sign channel/agent messages before submission so the response created_at
  matches the signed event, and admit e-tag replies to the conversation
  opener regardless of same-second ordering
- Validate stored agent-conversation pointers: restore only real DM
  channels whose participants are exactly {agent, self}
- Render the user's own messages verbatim (drop context-footer stripping)
  so the exact signed payload is always visible
- Scope agent chat panel state and remount key by relay URL + repo
  address so state never crosses a community boundary
- Reset branch/tag selection during render on repository switch to avoid
  a stale same-named ref leaking for one frame

Co-authored-by: Thomas Petersen <thomasp@squareup.com>
Signed-off-by: Thomas Petersen <thomasp@squareup.com>
This commit is contained in:
Wintermute
2026-08-16 18:48:50 -04:00
co-authored by Thomas Petersen
parent 07c2be37a0
commit a9852a9f37
13 changed files with 336 additions and 85 deletions
+13 -6
View File
@@ -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,
})
}
+2 -1
View File
@@ -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.
+36
View File
@@ -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))
}
@@ -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);
@@ -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 };
}
@@ -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);
});
@@ -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+$/, "");
}
@@ -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<StoredProjectsAgentConversation | null>(() =>
@@ -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}
@@ -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 (
<ProjectAgentChatPanel
canResetWidth={repositoryProps.canResetWidth}
context={context}
key={context.repoAddress}
key={`${relayScope}:${context.repoAddress}`}
onResetWidth={repositoryProps.onResetWidth}
onResizeStart={repositoryProps.onResizeStart}
sharedHeaderBackdrop={sharedHeaderBackdrop}
@@ -144,6 +144,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
defaultBranch,
projectAvailable: Boolean(repository),
projectPending: projectQuery.isPending,
repositoryId: repository?.id ?? null,
tags: repoStateQuery.data?.tags ?? [],
});
const activeTag =
@@ -120,16 +120,6 @@ function repoContextBlock(projects: readonly Project[]) {
.join("\n");
}
/** Hides the machine-readable repo footer when rendering the user's own
* prompt back in the inline conversation. */
export function stripRepoContext(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${REPO_CONTEXT_MARKER}`);
if (markerIndex === -1) return content;
return content.slice(0, markerIndex).replace(/\n+$/, "");
}
function buildSuggestions(projects: readonly Project[]) {
const firstRepo = projects[0]?.name;
return [
@@ -208,14 +198,17 @@ export function useAgentCandidates() {
}
/** Live message feed for the conversation's backing DM channel, reduced to
* plain chat rows (kind 9 / 40002 only). */
* plain chat rows (kind 9 / 40002 only). The user's own messages render
* verbatim including any machine-appended context footer so the exact
* payload signed under the user's identity is always visible. Hiding it
* while sending it would let relay-controlled metadata ride invisibly on
* the user's signature. */
export function ConversationThread({
channel,
agent,
agentAvatarUrl,
currentPubkey,
selfAvatarUrl,
stripSelfContent = stripRepoContext,
opener,
}: {
channel: Channel;
@@ -223,7 +216,6 @@ export function ConversationThread({
agentAvatarUrl: string | null;
currentPubkey: string | null;
selfAvatarUrl: string | null;
stripSelfContent?: (content: string) => 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(() => {
@@ -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("<!doctype html><html><body></body></html>", {
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");
});
@@ -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<string | null>(
null,
);
const [selectedTag, setSelectedTag] = React.useState<string | null>(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,
};
}