mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Thomas Petersen
parent
07c2be37a0
commit
a9852a9f37
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user