From a526dca9bcaa08dfb5db77999cc1f584a17a9d64 Mon Sep 17 00:00:00 2001 From: Kalvin C Date: Tue, 25 Aug 2026 11:34:16 -0700 Subject: [PATCH 1/3] feat: navigate images across message threads (#6705) ## Summary - navigate across image occurrences that are currently rendered inside the nearest explicit open-thread DOM scope - preserve rendered DOM order, duplicate occurrence identity, hidden-spoiler exclusion, preview-image entry parity, and the exact thumbnail return target - keep adjacent timeline messages and unrelated surfaces as separate galleries - make Copy and Download follow the current gallery item, including when a link-preview image opens the gallery first and navigation reaches Markdown media ## Scope contract This is intentionally a **rendered-media gallery only**. Gallery membership comes from mounted image triggers under the nearest explicit thread scope. Collapsed or unloaded descendants are excluded until the thread UI renders them. There is no canonical thread fetch, descendant projection, depth override, or synchronous message reparse in this change. `ChannelPane.tsx` remains unchanged from `main`; `useSearchHighlightProps` and both timeline/thread prop spreads are preserved. ## Verification - rebased onto `origin/main` at `113a33b7e49b7173ee1767c49ef2f49c63803034` - `git diff --check` - differential file-size check - full Desktop check (Biome plus text/pubkey policy checks; only pre-existing informational diagnostics) - Desktop TypeScript typecheck - Desktop production build and rebuilt E2E build - full Desktop JS units: `5,476/5,476` passed - full Desktop Tauri workspace tests passed (one documented native performance test ignored) - rebuilt image gallery Playwright spec: `14/14` passed - search-highlight smoke cases: `5/5` passed - reduced diff: 6 files, `+417/-41`; production `+78/-41`, E2E `+339/-0` The four added E2E cases cover cross-message thread navigation and return targeting, rendered-only exclusion of collapsed descendants, preview-first current-item actions, and adjacent-message isolation. --------- Signed-off-by: Kalvin Chau Signed-off-by: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Co-authored-by: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> --- .../messages/ui/MessageThreadPanel.tsx | 4 + desktop/src/shared/ui/markdown.tsx | 62 ++-- .../shared/ui/markdown/ImageGalleryStatus.tsx | 31 ++ .../ui/markdown/LinkPreviewImageLightbox.tsx | 10 +- .../src/shared/ui/markdown/imageLightbox.ts | 12 +- .../e2e/image-attachment-gallery.spec.ts | 339 ++++++++++++++++++ 6 files changed, 417 insertions(+), 41 deletions(-) create mode 100644 desktop/src/shared/ui/markdown/ImageGalleryStatus.tsx diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index d3b1394829..8797bec7a0 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -510,8 +510,12 @@ export function MessageThreadPanel({ tabIndex={-1} ref={threadBodyRef} > + {/* The gallery is intentionally DOM-scoped: only media currently rendered + in this open thread participates. Collapsed or unloaded descendants + join only after the thread UI renders them. */}
{ + toast.success("Copied to clipboard"); + }) + .catch((err: unknown) => { + const msg = err instanceof Error ? err.message : "Copy failed"; + toast.error(msg); + }); +} + +function downloadImage(src: string | undefined) { + if (!src) return; + invokeTauri("download_image", { url: src }).catch((err: unknown) => { + const msg = err instanceof Error ? err.message : "Download failed"; + toast.error(msg); + }); +} + function ImageZoomOverlay({ alt, galleryIndex = 0, galleryItems, - onCopy, - onDownload, onClose, resolvedSrc, sourceBox, @@ -161,8 +180,6 @@ function ImageZoomOverlay({ alt: string | undefined; galleryIndex?: number; galleryItems?: ImageGalleryItem[]; - onCopy: (src: string | undefined) => void; - onDownload: (src: string | undefined) => void; onClose: () => void; resolvedSrc: string; sourceBox: ImageLightboxBox; @@ -718,13 +735,13 @@ function ImageZoomOverlay({ const handleMenuCopy = React.useCallback(() => { setMenu(null); markControlGesture(); - onCopy(currentItem.src); - }, [currentItem.src, markControlGesture, onCopy]); + copyImageToClipboard(currentItem.src); + }, [currentItem.src, markControlGesture]); const handleMenuDownload = React.useCallback(() => { setMenu(null); markControlGesture(); - onDownload(currentItem.src); - }, [currentItem.src, markControlGesture, onDownload]); + downloadImage(currentItem.src); + }, [currentItem.src, markControlGesture]); return createPortal(
{ event.stopPropagation(); - onDownload(currentItem.src); + downloadImage(currentItem.src); }} > @@ -952,6 +969,7 @@ function ImageZoomOverlay({ updateZoom={updateZoom} zoom={zoom} /> +
{menu && canActOnCurrentImage ? ( @@ -1000,7 +1018,6 @@ function ImageBlock({ alt, dim, resolvedSrc, src, thumbSrc }: ImageBlockProps) { const triggerRef = React.useRef(null); useSmoothCorners(inlineImageRef); useSmoothCorners(thumbnailImageRef); - const [spoilerMediaSize, setSpoilerMediaSize] = React.useState<{ height: number; src: string; @@ -1078,7 +1095,6 @@ function ImageBlock({ alt, dim, resolvedSrc, src, thumbSrc }: ImageBlockProps) { return () => observer.disconnect(); }, []); - const closeMenu = React.useCallback(() => setMenu(null), []); useDismissMediaContextMenu(Boolean(menu), closeMenu); @@ -1089,7 +1105,6 @@ function ImageBlock({ alt, dim, resolvedSrc, src, thumbSrc }: ImageBlockProps) { e.nativeEvent.stopImmediatePropagation(); setMenu({ x: e.clientX, y: e.clientY }); }; - const openLightbox = React.useCallback( (image: HTMLImageElement) => { if (!resolvedSrc || isInsideHiddenSpoiler(image)) { @@ -1113,6 +1128,7 @@ function ImageBlock({ alt, dim, resolvedSrc, src, thumbSrc }: ImageBlockProps) { { alt, dim, + trigger: triggerRef.current, resolvedSrc, src, thumbnailBox: sourceBox, @@ -1140,27 +1156,13 @@ function ImageBlock({ alt, dim, resolvedSrc, src, thumbSrc }: ImageBlockProps) { const handleCopyImage = React.useCallback((copySrc: string | undefined) => { setMenu(null); - if (!copySrc) return; - invokeTauri("copy_image_to_clipboard", { url: copySrc }) - .then(() => { - toast.success("Copied to clipboard"); - }) - .catch((err: unknown) => { - const msg = err instanceof Error ? err.message : "Copy failed"; - toast.error(msg); - }); + copyImageToClipboard(copySrc); }, []); const handleDownload = React.useCallback( (downloadSrc: string | undefined) => { setMenu(null); - if (!downloadSrc) return; - invokeTauri("download_image", { url: downloadSrc }).catch( - (err: unknown) => { - const msg = err instanceof Error ? err.message : "Download failed"; - toast.error(msg); - }, - ); + downloadImage(downloadSrc); }, [], ); @@ -1215,8 +1217,6 @@ function ImageBlock({ alt, dim, resolvedSrc, src, thumbSrc }: ImageBlockProps) { alt={alt} galleryIndex={lightboxState.galleryIndex} galleryItems={lightboxState.galleryItems} - onCopy={handleCopyImage} - onDownload={handleDownload} onClose={() => setLightboxState(null)} resolvedSrc={resolvedSrc} sourceBox={lightboxState.sourceBox} diff --git a/desktop/src/shared/ui/markdown/ImageGalleryStatus.tsx b/desktop/src/shared/ui/markdown/ImageGalleryStatus.tsx new file mode 100644 index 0000000000..66e7b68b8b --- /dev/null +++ b/desktop/src/shared/ui/markdown/ImageGalleryStatus.tsx @@ -0,0 +1,31 @@ +type ImageGalleryStatusProps = { + currentIndex: number; + itemCount: number; +}; + +export function ImageGalleryStatus({ + currentIndex, + itemCount, +}: ImageGalleryStatusProps) { + if (itemCount <= 1) { + return null; + } + + const position = currentIndex + 1; + return ( + <> + Promise; placeholder?: string; profiles?: UserProfileLookup; + /** Explicit mention pubkeys from the loaded channel window, newest first. */ + recentMentionPubkeys?: readonly string[]; replyTarget?: { author: string; body: string; diff --git a/desktop/src/features/messages/ui/MessageComposerToolbar.tsx b/desktop/src/features/messages/ui/MessageComposerToolbar.tsx index d4e2284b85..5d5a1f5876 100644 --- a/desktop/src/features/messages/ui/MessageComposerToolbar.tsx +++ b/desktop/src/features/messages/ui/MessageComposerToolbar.tsx @@ -26,6 +26,7 @@ const ignoreAddressRemoval = () => {}; export const MessageComposerToolbar = React.memo( function MessageComposerToolbar({ addressedAgents = NO_ADDRESSED_AGENTS, + autoPinConfirmationTitle, composerDisabled, editor, extraActions, @@ -35,6 +36,8 @@ export const MessageComposerToolbar = React.memo( isSending, isUploading, onCaptureSelection, + onAutoPinConfirmationDismiss, + onAutoPinConfirmationTurnOff, onEmojiPickerOpenChange, onEmojiSelect, onFormattingToggle, @@ -47,6 +50,7 @@ export const MessageComposerToolbar = React.memo( shakeVersionByPubkey, }: { addressedAgents?: readonly ComposerAddressAgent[]; + autoPinConfirmationTitle?: string | null; composerDisabled: boolean; editor: Editor | null; extraActions?: React.ReactNode; @@ -56,6 +60,8 @@ export const MessageComposerToolbar = React.memo( isSending: boolean; isUploading: boolean; onCaptureSelection: () => void; + onAutoPinConfirmationDismiss?: () => void; + onAutoPinConfirmationTurnOff?: () => void; onEmojiPickerOpenChange: (open: boolean) => void; onEmojiSelect: (emoji: string) => void; onFormattingToggle: (pressed: boolean) => void; @@ -175,7 +181,10 @@ export const MessageComposerToolbar = React.memo( > import("./DiffMessage")); @@ -280,10 +280,12 @@ export const MessageRow = React.memo( return Object.keys(values).length > 0 ? values : undefined; }, [isKnownAgentPubkey, mentionPubkeysByName]); const addressedAgentPubkeys = React.useMemo(() => { - return getAgentAddressMentionPubkeys(message.tags).filter( - isKnownAgentPubkey, + return getVisibleAgentAddressPubkeys( + message.body, + getAgentAddressMentionPubkeys(message.tags).filter(isKnownAgentPubkey), + mentionPubkeysByName, ); - }, [isKnownAgentPubkey, message.tags]); + }, [isKnownAgentPubkey, mentionPubkeysByName, message.body, message.tags]); const agentAddressPrefix = addressedAgentPubkeys.length > 0 ? ( Promise; profiles?: UserProfileLookup; + recentMentionPubkeys?: readonly string[]; replyTargetMessage: TimelineMessage | null; scrollTargetId: string | null; threadHead: TimelineMessage | null; @@ -184,6 +185,7 @@ export function MessageThreadPanel({ onToggleReaction, onUnfollowThread, profiles, + recentMentionPubkeys, replyTargetMessage, scrollTargetId, scrollTargetHighlights = true, @@ -855,6 +857,7 @@ export function MessageThreadPanel({ : `Reply in thread to ${threadHead.author}` } profiles={profiles} + recentMentionPubkeys={recentMentionPubkeys} replyTarget={composerReplyTarget} typingParentEventId={threadHead.id} typingRootEventId={threadHead.rootId} diff --git a/desktop/src/features/messages/ui/composerAgentKeyboard.test.mjs b/desktop/src/features/messages/ui/composerAgentKeyboard.test.mjs index e98763a4a5..b95528dd93 100644 --- a/desktop/src/features/messages/ui/composerAgentKeyboard.test.mjs +++ b/desktop/src/features/messages/ui/composerAgentKeyboard.test.mjs @@ -44,13 +44,13 @@ test("agent picker preference skips people", async () => { assert.equal(view.result.current.mentionSelectedIndex, 1); }); -test("primary+Shift+Enter opens the picker or toggles in place", async () => { +test("primary+Shift+M addresses the default agent or toggles the tray selection", async () => { const { act, renderHook } = await import("@testing-library/react"); const { useAlwaysAddressShortcut } = await import( "./useAlwaysAddressShortcut.ts" ); const { isMacPlatform } = await import("@/shared/lib/platform"); - const opened = []; + const selected = []; const toggled = []; const suggestion = { displayName: "Agent Ada", @@ -59,8 +59,9 @@ test("primary+Shift+Enter opens the picker or toggles in place", async () => { }; const createEvent = () => ({ altKey: false, + code: "KeyM", ctrlKey: !isMacPlatform(), - key: "Enter", + key: "M", metaKey: isMacPlatform(), preventDefault() {}, repeat: false, @@ -71,24 +72,123 @@ test("primary+Shift+Enter opens the picker or toggles in place", async () => { useAlwaysAddressShortcut({ enabled: true, mentions: { + getDefaultAgentSuggestion: () => suggestion, isMentionOpen, mentionSelectedIndex: 0, suggestions: [suggestion], }, - onOpenPicker: (insertTrigger) => opened.push(insertTrigger), + onOpenPicker: () => {}, + onSelect: (value) => selected.push(value), onToggle: (value) => toggled.push(value), }), { initialProps: { isMentionOpen: false } }, ); act(() => assert.equal(view.result.current(createEvent()), true)); - assert.deepEqual(opened, [false]); - assert.deepEqual(toggled, []); + assert.deepEqual(toggled, [suggestion]); + assert.deepEqual(selected, []); view.rerender({ isMentionOpen: true }); act(() => assert.equal(view.result.current(createEvent()), true)); assert.deepEqual(toggled, [suggestion]); + assert.deepEqual(selected, [suggestion]); act(() => assert.equal(view.result.current(createEvent()), true)); - assert.deepEqual(toggled, [suggestion, suggestion]); + assert.deepEqual(toggled, [suggestion]); + assert.deepEqual(selected, [suggestion, suggestion]); +}); + +test("primary+Shift+M removes the current locked agent before choosing a new default", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAlwaysAddressShortcut } = await import( + "./useAlwaysAddressShortcut.ts" + ); + const { isMacPlatform } = await import("@/shared/lib/platform"); + const lockedAgent = { + avatarUrl: null, + displayName: "Agent Ada", + pubkey: "agent-a", + }; + const defaultAgent = { + displayName: "Agent Bea", + isAgent: true, + pubkey: "agent-b", + }; + const toggled = []; + const { result } = renderHook(() => + useAlwaysAddressShortcut({ + enabled: true, + lockedAgent, + mentions: { + getDefaultAgentSuggestion: () => defaultAgent, + isMentionOpen: false, + mentionSelectedIndex: 0, + suggestions: [], + }, + onOpenPicker: () => {}, + onSelect: () => {}, + onToggle: (value) => toggled.push(value), + }), + ); + + act(() => + assert.equal( + result.current({ + altKey: false, + code: "KeyM", + ctrlKey: !isMacPlatform(), + key: "m", + metaKey: isMacPlatform(), + preventDefault() {}, + repeat: false, + shiftKey: true, + }), + true, + ), + ); + + assert.deepEqual(toggled, [{ ...lockedAgent, isAgent: true }]); +}); + +test("primary+Shift+M opens the picker when no default agent is ready", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAlwaysAddressShortcut } = await import( + "./useAlwaysAddressShortcut.ts" + ); + const { isMacPlatform } = await import("@/shared/lib/platform"); + let opened = 0; + const { result } = renderHook(() => + useAlwaysAddressShortcut({ + enabled: true, + mentions: { + getDefaultAgentSuggestion: () => null, + isMentionOpen: false, + mentionSelectedIndex: 0, + suggestions: [], + }, + onOpenPicker: () => { + opened += 1; + }, + onSelect: () => {}, + onToggle: () => {}, + }), + ); + + act(() => + assert.equal( + result.current({ + altKey: false, + code: "KeyM", + ctrlKey: !isMacPlatform(), + key: "m", + metaKey: isMacPlatform(), + preventDefault() {}, + repeat: false, + shiftKey: true, + }), + true, + ), + ); + + assert.equal(opened, 1); }); diff --git a/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs b/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs index 4acd89cf0a..95e0af9e06 100644 --- a/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs +++ b/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs @@ -23,7 +23,7 @@ afterEach(async () => { after(() => dom.window.close()); -test("always addressing an agent keeps autocomplete open, adds the lock, and pulses", async () => { +test("always addressing an agent keeps autocomplete open, inserts the chip, adds the lock, and pulses", async () => { const { act, renderHook } = await import("@testing-library/react"); const { useAgentAddressLockPicker } = await import( "./useAgentAddressLockPicker.ts" @@ -32,7 +32,7 @@ test("always addressing an agent keeps autocomplete open, adds the lock, and pul const addedPubkeys = []; const pulsedPubkeys = []; let cancelCount = 0; - const text = "Ask @Agent Ada later @"; + const text = "@"; const mentions = { cancelMentionAutocomplete: () => { cancelCount += 1; @@ -45,6 +45,9 @@ test("always addressing an agent keeps autocomplete open, adds the lock, and pul }, ], getMentionDisplayName: () => "Agent Ada", + isInlineMentionSelection: () => false, + isMentionOpen: true, + registerMentionPubkey: () => {}, mentionStartIndex: text.lastIndexOf("@"), }; const audience = { @@ -73,7 +76,14 @@ test("always addressing an agent keeps autocomplete open, adds the lock, and pul }); }); - assert.deepEqual(appliedEdits, []); + assert.deepEqual(appliedEdits, [ + { + replaceFromOffset: 0, + replaceToOffset: 0, + insertText: "@Agent Ada ", + preserveSelection: true, + }, + ]); assert.equal(cancelCount, 0); assert.deepEqual(addedPubkeys, ["agent-pubkey"]); assert.deepEqual(pulsedPubkeys, ["agent-pubkey"]); @@ -83,6 +93,49 @@ test("always addressing an agent keeps autocomplete open, adds the lock, and pul ); }); +test("always addressing a new agent delegates the first add for immediate confirmation", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAgentAddressLockPicker } = await import( + "./useAgentAddressLockPicker.ts" + ); + const addressedSuggestions = []; + const addedPubkeys = []; + const pulsedPubkeys = []; + const suggestion = { + pubkey: "agent-pubkey", + displayName: "Agent Ada", + isAgent: true, + }; + const { result } = renderHook(() => + useAgentAddressLockPicker({ + applyAutocompleteEdit: () => {}, + audience: { + pubkeys: [], + addPubkey: (pubkey) => addedPubkeys.push(pubkey), + }, + audienceScope: "channel-scope", + mentions: { + getDraftMentionRefs: () => [], + getMentionDisplayName: () => "Agent Ada", + isInlineMentionSelection: () => false, + isMentionOpen: false, + registerMentionPubkey: () => {}, + }, + onAddressAgentMention: (value) => addressedSuggestions.push(value), + onPulseAddressLock: (pubkey) => pulsedPubkeys.push(pubkey), + richText: { + getPlainTextAndCursor: () => ({ text: "@Agent Ada ", cursor: 11 }), + }, + }), + ); + + act(() => result.current.toggleAlwaysAddressAgent(suggestion)); + + assert.deepEqual(addressedSuggestions, [suggestion]); + assert.deepEqual(addedPubkeys, []); + assert.deepEqual(pulsedPubkeys, []); +}); + test("toggling an addressed agent keeps autocomplete open and removes the lock", async () => { const { act, renderHook } = await import("@testing-library/react"); const { useAgentAddressLockPicker } = await import( @@ -105,6 +158,7 @@ test("toggling an addressed agent keeps autocomplete open and removes the lock", }, ], getMentionDisplayName: () => "Agent Ada", + registerMentionPubkey: () => {}, mentionStartIndex: text.lastIndexOf("@"), }; const audience = { @@ -136,7 +190,13 @@ test("toggling an addressed agent keeps autocomplete open and removes the lock", }); }); - assert.deepEqual(appliedEdits, []); + assert.deepEqual(appliedEdits, [ + { + replaceFromOffset: 4, + replaceToOffset: 15, + insertText: "", + }, + ]); assert.equal(cancelCount, 0); assert.deepEqual(removedPubkeys, ["agent-pubkey"]); assert.deepEqual(pulsedPubkeys, []); @@ -158,10 +218,13 @@ test("selecting an already addressed agent from the explicit picker pulses its b cancelMentionAutocomplete: () => {}, getDraftMentionRefs: () => [], getMentionDisplayName: () => "Agent Ada", + registerMentionPubkey: () => {}, isInlineMentionSelection: () => false, - insertMention: () => { - throw new Error("an already addressed agent must not be inserted"); - }, + insertMention: () => ({ + replaceFromOffset: 5, + replaceToOffset: 5, + insertText: "@Agent Ada ", + }), mentionStartIndex: 5, }; const audience = { @@ -190,16 +253,23 @@ test("selecting an already addressed agent from the explicit picker pulses its b }); }); - assert.deepEqual(appliedEdits, []); + assert.deepEqual(appliedEdits, [ + { + replaceFromOffset: 5, + replaceToOffset: 5, + insertText: "@Agent Ada ", + }, + ]); assert.deepEqual(addedPubkeys, []); assert.deepEqual(pulsedPubkeys, ["agent-pubkey"]); }); -test("selecting an agent from a typed query leaves the inline mention for send", async () => { +test("selecting an agent from a typed query immediately auto-addresses it", async () => { const { act, renderHook } = await import("@testing-library/react"); const { useAgentAddressLockPicker } = await import( "./useAgentAddressLockPicker.ts" ); + const autoPinnedSuggestions = []; const appliedEdits = []; const addedPubkeys = []; const pulsedPubkeys = []; @@ -207,6 +277,7 @@ test("selecting an agent from a typed query leaves the inline mention for send", cancelMentionAutocomplete: () => {}, getDraftMentionRefs: () => [], getMentionDisplayName: () => "Agent Ada", + registerMentionPubkey: () => {}, isInlineMentionSelection: () => true, insertMention: () => ({ replaceFromOffset: 5, @@ -224,6 +295,206 @@ test("selecting an agent from a typed query leaves the inline mention for send", // makes the editor text/cursor insufficient to re-detect the typed query. getPlainTextAndCursor: () => ({ text: "ping ", cursor: 5 }), }; + const { result } = renderHook(() => + useAgentAddressLockPicker({ + applyAutocompleteEdit: (edit) => appliedEdits.push(edit), + audience, + audienceScope: "channel-scope", + mentions, + onAutoPinAgentMention: (suggestion) => + autoPinnedSuggestions.push(suggestion), + onPulseAddressLock: (pubkey) => pulsedPubkeys.push(pubkey), + richText, + }), + ); + + const suggestion = { + pubkey: "agent-pubkey", + displayName: "Agent Ada", + isAgent: true, + }; + act(() => result.current.selectMentionSuggestion(suggestion)); + + assert.deepEqual(appliedEdits, [ + { + replaceFromOffset: 5, + replaceToOffset: 6, + insertText: "@Agent Ada ", + }, + ]); + assert.deepEqual(autoPinnedSuggestions, [suggestion]); + assert.deepEqual(addedPubkeys, []); + assert.deepEqual(pulsedPubkeys, []); + assert.equal(result.current.announcement, ""); +}); + +test("selecting a human mention never changes automatic addressing", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAgentAddressLockPicker } = await import( + "./useAgentAddressLockPicker.ts" + ); + const autoPinnedSuggestions = []; + const appliedEdits = []; + const { result } = renderHook(() => + useAgentAddressLockPicker({ + applyAutocompleteEdit: (edit) => appliedEdits.push(edit), + audience: { pubkeys: [], addPubkey: () => {} }, + audienceScope: "channel-scope", + mentions: { + getMentionDisplayName: () => "Alice", + insertMention: () => ({ + replaceFromOffset: 0, + replaceToOffset: 3, + insertText: "@Alice ", + }), + }, + onAutoPinAgentMention: (suggestion) => + autoPinnedSuggestions.push(suggestion), + onPulseAddressLock: () => {}, + richText: { + getPlainTextAndCursor: () => ({ text: "@Al", cursor: 3 }), + }, + }), + ); + + act(() => + result.current.selectMentionSuggestion({ + pubkey: "human-pubkey", + displayName: "Alice", + isAgent: false, + }), + ); + + assert.deepEqual(appliedEdits, [ + { + replaceFromOffset: 0, + replaceToOffset: 3, + insertText: "@Alice ", + }, + ]); + assert.deepEqual(autoPinnedSuggestions, []); +}); + +test("removing the last agent chip clears its automatic address", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAgentAddressLockPicker } = await import( + "./useAgentAddressLockPicker.ts" + ); + const removedPubkeys = []; + const mentionRefsByText = { + "@Agent Ada first @Agent Ada second": [ + { displayName: "Agent Ada", pubkey: "agent-pubkey", isAgent: true }, + { displayName: "Agent Ada", pubkey: "agent-pubkey", isAgent: true }, + ], + "@Agent Ada second": [ + { displayName: "Agent Ada", pubkey: "agent-pubkey", isAgent: true }, + ], + "": [], + }; + const { result } = renderHook(() => + useAgentAddressLockPicker({ + applyAutocompleteEdit: () => {}, + audience: { + pubkeys: ["agent-pubkey", "existing-lock"], + removePubkey: (pubkey) => removedPubkeys.push(pubkey), + }, + audienceScope: "channel-scope", + mentions: { + getDraftMentionRefs: (text) => mentionRefsByText[text] ?? [], + getMentionDisplayName: () => "Agent Ada", + }, + onPulseAddressLock: () => {}, + richText: { getPlainTextAndCursor: () => ({ text: "", cursor: 0 }) }, + }), + ); + + act(() => result.current.trackMentionAddressedAgent("agent-pubkey")); + act(() => + result.current.syncAddressedAgentsFromText( + "@Agent Ada first @Agent Ada second", + ), + ); + act(() => result.current.syncAddressedAgentsFromText("@Agent Ada second")); + assert.deepEqual(removedPubkeys, []); + + act(() => result.current.syncAddressedAgentsFromText("")); + assert.deepEqual(removedPubkeys, ["agent-pubkey"]); +}); + +test("removing human mentions is ignored while removing a restored agent chip clears its lock", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAgentAddressLockPicker } = await import( + "./useAgentAddressLockPicker.ts" + ); + const removedPubkeys = []; + const { result } = renderHook(() => + useAgentAddressLockPicker({ + applyAutocompleteEdit: () => {}, + audience: { + pubkeys: ["existing-lock"], + removePubkey: (pubkey) => removedPubkeys.push(pubkey), + }, + audienceScope: "channel-scope", + mentions: { + getDraftMentionRefs: (text) => { + if (text === "@Alice @Existing Agent") { + return [ + { displayName: "Alice", pubkey: "human-pubkey", isAgent: false }, + { + displayName: "Existing Agent", + pubkey: "existing-lock", + isAgent: true, + }, + ]; + } + return text + ? [{ displayName: "Alice", pubkey: "human-pubkey", isAgent: false }] + : []; + }, + getMentionDisplayName: () => "Existing Agent", + }, + onPulseAddressLock: () => {}, + richText: { getPlainTextAndCursor: () => ({ text: "", cursor: 0 }) }, + }), + ); + + act(() => + result.current.syncAddressedAgentsFromText("@Alice @Existing Agent"), + ); + act(() => result.current.syncAddressedAgentsFromText("@Alice")); + assert.deepEqual(removedPubkeys, ["existing-lock"]); + act(() => result.current.syncAddressedAgentsFromText("")); + assert.deepEqual(removedPubkeys, ["existing-lock"]); +}); + +test("selecting an agent from the explicit picker auto-addresses it", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAgentAddressLockPicker } = await import( + "./useAgentAddressLockPicker.ts" + ); + const appliedEdits = []; + const addedPubkeys = []; + const pulsedPubkeys = []; + const mentions = { + cancelMentionAutocomplete: () => {}, + getDraftMentionRefs: () => [], + getMentionDisplayName: () => "Agent Ada", + registerMentionPubkey: () => {}, + isInlineMentionSelection: () => false, + insertMention: () => ({ + replaceFromOffset: 5, + replaceToOffset: 5, + insertText: "@Agent Ada ", + }), + mentionStartIndex: 5, + }; + const audience = { + pubkeys: [], + addPubkey: (pubkey) => addedPubkeys.push(pubkey), + }; + const richText = { + getPlainTextAndCursor: () => ({ text: "ping ", cursor: 5 }), + }; const { result } = renderHook(() => useAgentAddressLockPicker({ applyAutocompleteEdit: (edit) => appliedEdits.push(edit), @@ -246,60 +517,10 @@ test("selecting an agent from a typed query leaves the inline mention for send", assert.deepEqual(appliedEdits, [ { replaceFromOffset: 5, - replaceToOffset: 6, + replaceToOffset: 5, insertText: "@Agent Ada ", }, ]); - assert.deepEqual(addedPubkeys, []); - assert.deepEqual(pulsedPubkeys, []); - assert.equal(result.current.announcement, ""); -}); - -test("selecting an agent from the explicit picker auto-addresses it", async () => { - const { act, renderHook } = await import("@testing-library/react"); - const { useAgentAddressLockPicker } = await import( - "./useAgentAddressLockPicker.ts" - ); - const appliedEdits = []; - const addedPubkeys = []; - const pulsedPubkeys = []; - const mentions = { - cancelMentionAutocomplete: () => {}, - getDraftMentionRefs: () => [], - getMentionDisplayName: () => "Agent Ada", - isInlineMentionSelection: () => false, - insertMention: () => { - throw new Error("explicit picker selections must become addressing"); - }, - mentionStartIndex: 5, - }; - const audience = { - pubkeys: [], - addPubkey: (pubkey) => addedPubkeys.push(pubkey), - }; - const richText = { - getPlainTextAndCursor: () => ({ text: "ping ", cursor: 5 }), - }; - const { result } = renderHook(() => - useAgentAddressLockPicker({ - applyAutocompleteEdit: (edit) => appliedEdits.push(edit), - audience, - audienceScope: "channel-scope", - mentions, - onPulseAddressLock: (pubkey) => pulsedPubkeys.push(pubkey), - richText, - }), - ); - - act(() => { - result.current.selectMentionSuggestion({ - pubkey: "agent-pubkey", - displayName: "Agent Ada", - isAgent: true, - }); - }); - - assert.deepEqual(appliedEdits, []); assert.deepEqual(addedPubkeys, ["agent-pubkey"]); assert.deepEqual(pulsedPubkeys, ["agent-pubkey"]); assert.equal( @@ -319,8 +540,11 @@ test("selecting an explicitly unpinned agent inserts a mention until send", asyn const pulsedPubkeys = []; const mentions = { cancelMentionAutocomplete: () => {}, - getDraftMentionRefs: () => [], + getDraftMentionRefs: () => [ + { displayName: "Agent Ada", pubkey: "agent-pubkey", isAgent: true }, + ], getMentionDisplayName: () => "Agent Ada", + registerMentionPubkey: () => {}, isInlineMentionSelection: () => false, insertMention: () => ({ replaceFromOffset: 0, @@ -330,7 +554,10 @@ test("selecting an explicitly unpinned agent inserts a mention until send", asyn mentionStartIndex: 0, }; const richText = { - getPlainTextAndCursor: () => ({ text: "", cursor: 0 }), + getPlainTextAndCursor: () => ({ + text: "@Agent Ada keep this authored text", + cursor: 35, + }), }; const { result, rerender } = renderHook( ({ pubkeys }) => @@ -350,6 +577,7 @@ test("selecting an explicitly unpinned agent inserts a mention until send", asyn ); act(() => result.current.removeAddressedAgent("AGENT-PUBKEY")); + assert.deepEqual(appliedEdits, []); rerender({ pubkeys: [] }); act(() => { result.current.selectMentionSuggestion({ diff --git a/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts b/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts index 8d5a87b9b8..17883900ec 100644 --- a/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts +++ b/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts @@ -16,8 +16,7 @@ import type { MentionSuggestion } from "./MentionAutocomplete"; function buildMentionRemovalEdits( text: string, displayNames: readonly string[], - queryStart: number, - cursor: number, + queryRange?: { start: number; end: number }, ): AutocompleteEdit[] { const ranges = displayNames.flatMap((displayName) => getMentionOffsets(text, displayName).map((start) => { @@ -26,10 +25,12 @@ function buildMentionRemovalEdits( return { start, end }; }), ); - ranges.push({ - start: Math.max(0, Math.min(queryStart, text.length)), - end: Math.max(0, Math.min(cursor, text.length)), - }); + if (queryRange) { + ranges.push({ + start: Math.max(0, Math.min(queryRange.start, text.length)), + end: Math.max(0, Math.min(queryRange.end, text.length)), + }); + } const merged = ranges .filter(({ start, end }) => start < end) @@ -56,6 +57,8 @@ export function useAgentAddressLockPicker({ audience, audienceScope, mentions, + onAddressAgentMention, + onAutoPinAgentMention, onPulseAddressLock, profiles, richText, @@ -64,6 +67,8 @@ export function useAgentAddressLockPicker({ audience: ReturnType; audienceScope: string | null; mentions: UseMentionsResult; + onAddressAgentMention?: (suggestion: MentionSuggestion) => void; + onAutoPinAgentMention?: (suggestion: MentionSuggestion) => void; onPulseAddressLock: (pubkey: string) => void; profiles?: UserProfileLookup; richText: UseRichTextEditorResult; @@ -79,6 +84,12 @@ export function useAgentAddressLockPicker({ unpinnedAgentPubkeysRef.current.clear(); } const lockedAgentNamesRef = React.useRef(new Map()); + const visibleAgentMentionPubkeysRef = React.useRef(new Set()); + const mentionSyncScopeRef = React.useRef(audienceScope); + if (mentionSyncScopeRef.current !== audienceScope) { + mentionSyncScopeRef.current = audienceScope; + visibleAgentMentionPubkeysRef.current.clear(); + } const [announcement, setAnnouncement] = React.useState(""); const lockedAgents = React.useMemo( () => @@ -104,41 +115,42 @@ export function useAgentAddressLockPicker({ }), [audience.pubkeys, mentions.getMentionDisplayName, profiles], ); - const consumeAddressSuggestion = React.useCallback( - ( - suggestion: MentionSuggestion, - { removeInlineMentions }: { removeInlineMentions: boolean }, - ): string | null => { - const pubkey = normalizePubkey(suggestion.pubkey ?? ""); - if (!audienceScope || !pubkey || !suggestion.isAgent) return null; - - const { text, cursor } = richText.getPlainTextAndCursor(); - const matchingDisplayNames = removeInlineMentions - ? mentions - .getDraftMentionRefs(text) - .filter((ref) => normalizePubkey(ref.pubkey) === pubkey) - .map((ref) => ref.displayName) - : []; - mentions.cancelMentionAutocomplete(); - for (const edit of buildMentionRemovalEdits( - text, - matchingDisplayNames, - mentions.mentionStartIndex, - cursor, - )) { - applyAutocompleteEdit(edit); + const trackMentionAddressedAgent = React.useCallback( + (pubkey: string) => { + const normalized = normalizePubkey(pubkey); + if (audienceScope && normalized) { + visibleAgentMentionPubkeysRef.current.add(normalized); } - return pubkey; + }, + [audienceScope], + ); + const syncAddressedAgentsFromText = React.useCallback( + (text: string) => { + if (!audienceScope) return; + const presentAgentPubkeys = new Set( + mentions + .getDraftMentionRefs(text) + .filter((ref) => ref.isAgent) + .map((ref) => normalizePubkey(ref.pubkey)), + ); + for (const pubkey of visibleAgentMentionPubkeysRef.current) { + if ( + !presentAgentPubkeys.has(pubkey) && + lockedAgentPubkeys.has(pubkey) + ) { + audience.removePubkey(pubkey); + } + } + visibleAgentMentionPubkeysRef.current = presentAgentPubkeys; }, [ - applyAutocompleteEdit, + audience.removePubkey, audienceScope, - mentions.cancelMentionAutocomplete, + lockedAgentPubkeys, mentions.getDraftMentionRefs, - mentions.mentionStartIndex, - richText.getPlainTextAndCursor, ], ); + const removeAddressedAgent = React.useCallback( (pubkey: string) => { const normalized = normalizePubkey(pubkey); @@ -148,24 +160,63 @@ export function useAgentAddressLockPicker({ }, [audience.removePubkey, audienceScope], ); + const removeAddressedAgentMentions = React.useCallback( + (pubkey: string) => { + const normalized = normalizePubkey(pubkey); + if (!audienceScope || !normalized) return; + const { text } = richText.getPlainTextAndCursor(); + const matchingDisplayNames = mentions + .getDraftMentionRefs(text) + .filter((ref) => normalizePubkey(ref.pubkey) === normalized) + .map((ref) => ref.displayName); + for (const edit of buildMentionRemovalEdits(text, matchingDisplayNames)) { + applyAutocompleteEdit(edit); + } + removeAddressedAgent(normalized); + }, + [ + applyAutocompleteEdit, + audienceScope, + mentions.getDraftMentionRefs, + removeAddressedAgent, + richText.getPlainTextAndCursor, + ], + ); const toggleAlwaysAddressAgent = React.useCallback( (suggestion: MentionSuggestion) => { const pubkey = normalizePubkey(suggestion.pubkey ?? ""); if (!audienceScope || !pubkey || !suggestion.isAgent) return; if (lockedAgentPubkeys.has(pubkey)) { - removeAddressedAgent(pubkey); + removeAddressedAgentMentions(pubkey); setAnnouncement( `Stopped automatically mentioning ${suggestion.displayName}`, ); } else { unpinnedAgentPubkeysRef.current.delete(pubkey); - audience.addPubkey(pubkey); - onPulseAddressLock(pubkey); + mentions.registerMentionPubkey(suggestion.displayName, pubkey, { + isAgent: true, + }); + const { text } = richText.getPlainTextAndCursor(); + if (getMentionOffsets(text, suggestion.displayName).length === 0) { + applyAutocompleteEdit({ + replaceFromOffset: 0, + replaceToOffset: 0, + insertText: `@${suggestion.displayName} `, + preserveSelection: true, + }); + } + trackMentionAddressedAgent(pubkey); + if (onAddressAgentMention) { + onAddressAgentMention(suggestion); + } else { + audience.addPubkey(pubkey); + onPulseAddressLock(pubkey); + } setAnnouncement(`Automatically mentioning ${suggestion.displayName}`); } - if (mentions.isMentionOpen) { + if (mentions.isMentionOpen && mentions.isInlineMentionSelection()) { const { text, cursor } = richText.getPlainTextAndCursor(); const activeMention = detectPrefixQuery("@", text, cursor, [ suggestion.displayName.toLowerCase(), @@ -190,12 +241,16 @@ export function useAgentAddressLockPicker({ audience.addPubkey, audienceScope, lockedAgentPubkeys, + mentions.isInlineMentionSelection, mentions.isMentionOpen, mentions.mentionStartIndex, mentions.openMentionPicker, + mentions.registerMentionPubkey, + onAddressAgentMention, onPulseAddressLock, - removeAddressedAgent, + removeAddressedAgentMentions, richText.getPlainTextAndCursor, + trackMentionAddressedAgent, ], ); @@ -209,15 +264,25 @@ export function useAgentAddressLockPicker({ unpinnedAgentPubkeysRef.current.has(pubkey); if (mentions.isInlineMentionSelection() || wasUnpinned) { applyAutocompleteEdit(mentions.insertMention(suggestion, cursor)); + if (wasUnpinned) unpinnedAgentPubkeysRef.current.delete(pubkey); + trackMentionAddressedAgent(pubkey); + onAutoPinAgentMention?.(suggestion); return; } - consumeAddressSuggestion(suggestion, { removeInlineMentions: false }); + applyAutocompleteEdit(mentions.insertMention(suggestion, cursor)); if (!lockedAgentPubkeys.has(pubkey)) { - audience.addPubkey(pubkey); + trackMentionAddressedAgent(pubkey); + if (onAddressAgentMention) { + onAddressAgentMention(suggestion); + } else { + audience.addPubkey(pubkey); + onPulseAddressLock(pubkey); + } setAnnouncement(`Automatically mentioning ${suggestion.displayName}`); + } else { + onPulseAddressLock(pubkey); } - onPulseAddressLock(pubkey); return; } @@ -228,12 +293,84 @@ export function useAgentAddressLockPicker({ applyAutocompleteEdit, audience.addPubkey, audienceScope, - consumeAddressSuggestion, lockedAgentPubkeys, mentions.isInlineMentionSelection, mentions.insertMention, + onAddressAgentMention, + onAutoPinAgentMention, onPulseAddressLock, richText.getPlainTextAndCursor, + trackMentionAddressedAgent, + ], + ); + + const restoreAddressedAgentMentions = React.useCallback( + ( + pubkeys?: readonly string[], + allowedUnpinnedPubkeys: readonly string[] = [], + ) => { + const restorePubkeys = pubkeys + ? new Set(pubkeys.map(normalizePubkey)) + : null; + const allowedUnpinned = new Set( + allowedUnpinnedPubkeys.map(normalizePubkey), + ); + const currentAudiencePubkeys = new Set( + audience.pubkeys.map(normalizePubkey), + ); + const targetAgents = [...(restorePubkeys ?? currentAudiencePubkeys)] + .filter( + (pubkey) => + currentAudiencePubkeys.has(pubkey) || allowedUnpinned.has(pubkey), + ) + .map((pubkey) => { + const profile = profiles?.[pubkey]; + const displayName = + profile?.displayName?.trim() || + profile?.name?.trim() || + profile?.nip05Handle?.trim() || + mentions.getMentionDisplayName(pubkey)?.trim() || + lockedAgentNamesRef.current.get(pubkey) || + truncatePubkey(pubkey); + return { pubkey, displayName }; + }); + const { text } = richText.getPlainTextAndCursor(); + for (const agent of targetAgents) { + if (getMentionOffsets(text, agent.displayName).length > 0) { + visibleAgentMentionPubkeysRef.current.add(agent.pubkey); + } + } + const missingAgents = targetAgents.filter( + (agent) => + (!unpinnedAgentPubkeysRef.current.has(agent.pubkey) || + allowedUnpinned.has(agent.pubkey)) && + getMentionOffsets(text, agent.displayName).length === 0, + ); + if (missingAgents.length === 0) return text; + for (const agent of missingAgents) { + mentions.registerMentionPubkey(agent.displayName, agent.pubkey, { + isAgent: true, + }); + visibleAgentMentionPubkeysRef.current.add(agent.pubkey); + } + const insertedText = `${missingAgents + .map((agent) => `@${agent.displayName}`) + .join(" ")} `; + applyAutocompleteEdit({ + replaceFromOffset: 0, + replaceToOffset: 0, + insertText: insertedText, + preserveSelection: true, + }); + return `${insertedText}${text}`; + }, + [ + applyAutocompleteEdit, + audience.pubkeys, + mentions.getMentionDisplayName, + mentions.registerMentionPubkey, + profiles, + richText.getPlainTextAndCursor, ], ); @@ -242,7 +379,10 @@ export function useAgentAddressLockPicker({ lockedAgents, lockedAgentPubkeys, removeAddressedAgent, + restoreAddressedAgentMentions, selectMentionSuggestion, + syncAddressedAgentsFromText, toggleAlwaysAddressAgent, + trackMentionAddressedAgent, }; } diff --git a/desktop/src/features/messages/ui/useAlwaysAddressShortcut.ts b/desktop/src/features/messages/ui/useAlwaysAddressShortcut.ts index 8fd253df22..ed92e7b3f0 100644 --- a/desktop/src/features/messages/ui/useAlwaysAddressShortcut.ts +++ b/desktop/src/features/messages/ui/useAlwaysAddressShortcut.ts @@ -6,21 +6,30 @@ import type { MentionSuggestion } from "./MentionAutocomplete"; export function useAlwaysAddressShortcut({ enabled, + lockedAgent, mentions, onOpenPicker, + onSelect, onToggle, }: { enabled: boolean; + lockedAgent?: Pick; mentions: UseMentionsResult; onOpenPicker: (insertTrigger?: boolean) => void; + onSelect: (suggestion: MentionSuggestion) => void; onToggle: (suggestion: MentionSuggestion) => void; }) { - const { isMentionOpen, mentionSelectedIndex, suggestions } = mentions; + const { + getDefaultAgentSuggestion, + isMentionOpen, + mentionSelectedIndex, + suggestions, + } = mentions; return React.useCallback( (event: React.KeyboardEvent): boolean => { if ( !enabled || - event.key !== "Enter" || + event.code !== "KeyM" || !hasPrimaryShortcutModifier(event) || event.altKey || !event.shiftKey @@ -30,21 +39,30 @@ export function useAlwaysAddressShortcut({ event.preventDefault(); if (event.repeat) return true; - if (!isMentionOpen) { - onOpenPicker(false); + const suggestion = isMentionOpen + ? suggestions[mentionSelectedIndex] + : lockedAgent + ? { ...lockedAgent, isAgent: true } + : getDefaultAgentSuggestion(); + if (!suggestion?.isAgent || !suggestion.pubkey) { + if (!isMentionOpen) onOpenPicker(false); return true; } - - const suggestion = suggestions[mentionSelectedIndex]; - if (!suggestion?.isAgent || !suggestion.pubkey) return true; - onToggle(suggestion); + if (isMentionOpen) { + onSelect(suggestion); + } else { + onToggle(suggestion); + } return true; }, [ enabled, + getDefaultAgentSuggestion, isMentionOpen, + lockedAgent, mentionSelectedIndex, onOpenPicker, + onSelect, onToggle, suggestions, ], diff --git a/desktop/src/features/messages/ui/useAutoPinMentionedAgents.ts b/desktop/src/features/messages/ui/useAutoPinMentionedAgents.ts index 44b87c9f1d..d0557567fd 100644 --- a/desktop/src/features/messages/ui/useAutoPinMentionedAgents.ts +++ b/desktop/src/features/messages/ui/useAutoPinMentionedAgents.ts @@ -1,36 +1,62 @@ import * as React from "react"; -import { toast } from "sonner"; import { + getPersistentAgentAudienceRevision, promotePersistentAgentAudienceIfUnchanged, removePersistentAgentAudienceMembersIfUnchanged, } from "@/features/messages/lib/persistentAgentAudience"; import { normalizePubkey } from "@/shared/lib/pubkey"; +const CONFIRMATION_DURATION_MS = 4_000; + +type Confirmation = { + expectedRevision: number; + pubkeys: readonly string[]; + scope: string; + title: string; +}; + type Options = { audienceScope: string | null; enabled: boolean; getDisplayName: (pubkey: string) => string | null | undefined; - onOpenOptions: () => void; onPulse: (pubkey: string) => void; + onTurnOff: () => void; }; export function useAutoPinMentionedAgents({ audienceScope, enabled, getDisplayName, - onOpenOptions, onPulse, + onTurnOff, }: Options) { - return React.useCallback( + const [confirmation, setConfirmation] = React.useState( + null, + ); + + React.useEffect(() => { + if (!confirmation) return; + const timeout = window.setTimeout( + () => setConfirmation(null), + CONFIRMATION_DURATION_MS, + ); + return () => window.clearTimeout(timeout); + }, [confirmation]); + + const promoteAgents = React.useCallback( ({ - expectedRevision, + expectedRevision = audienceScope + ? getPersistentAgentAudienceRevision(audienceScope) + : 0, pubkeys, + requirePreference, }: { - expectedRevision: number; + expectedRevision?: number; pubkeys: readonly string[]; + requirePreference: boolean; }) => { - if (!audienceScope || !enabled) return; + if (!audienceScope || (requirePreference && !enabled)) return; const normalizedPubkeys = [ ...new Set(pubkeys.map(normalizePubkey)), ].filter(Boolean); @@ -52,23 +78,47 @@ export function useAutoPinMentionedAgents({ : promotedPubkeys.length === 1 ? "Agent will be mentioned automatically" : `${promotedPubkeys.length} agents will be mentioned automatically`; - toast.success(title, { - action: { - label: "Undo", - onClick: () => { - if ( - removePersistentAgentAudienceMembersIfUnchanged({ - expectedRevision: revision, - pubkeys: promotedPubkeys, - scope: audienceScope, - }) - ) { - onOpenOptions(); - } - }, - }, + setConfirmation({ + expectedRevision: revision, + pubkeys: promotedPubkeys, + scope: audienceScope, + title, }); }, - [audienceScope, enabled, getDisplayName, onOpenOptions, onPulse], + [audienceScope, enabled, getDisplayName, onPulse], ); + const promoteMentionedAgents = React.useCallback( + (promotion: { expectedRevision?: number; pubkeys: readonly string[] }) => + promoteAgents({ ...promotion, requirePreference: true }), + [promoteAgents], + ); + const promoteExplicitlyAddressedAgents = React.useCallback( + (promotion: { expectedRevision?: number; pubkeys: readonly string[] }) => + promoteAgents({ ...promotion, requirePreference: false }), + [promoteAgents], + ); + + const dismissConfirmation = React.useCallback( + () => setConfirmation(null), + [], + ); + const turnOffConfirmation = React.useCallback(() => { + if (!confirmation) return; + setConfirmation(null); + removePersistentAgentAudienceMembersIfUnchanged({ + expectedRevision: confirmation.expectedRevision, + pubkeys: confirmation.pubkeys, + scope: confirmation.scope, + }); + onTurnOff(); + }, [confirmation, onTurnOff]); + + return { + confirmationTitle: + confirmation?.scope === audienceScope ? confirmation.title : null, + dismissConfirmation, + promoteExplicitlyAddressedAgents, + promoteMentionedAgents, + turnOffConfirmation, + }; } diff --git a/desktop/src/features/messages/ui/useComposerPasteHandler.ts b/desktop/src/features/messages/ui/useComposerPasteHandler.ts new file mode 100644 index 0000000000..8e56da071a --- /dev/null +++ b/desktop/src/features/messages/ui/useComposerPasteHandler.ts @@ -0,0 +1,73 @@ +import * as React from "react"; +import type { Editor } from "@tiptap/react"; +import { handleAgentSnapshotPaste } from "@/features/messages/lib/agentSnapshotClipboard"; +import type { BlobDescriptor } from "@/shared/api/tauri"; +import { + hasMentionClipboardHtml, + normalizeMentionClipboardHtml, +} from "@/features/messages/lib/normalizeMentionClipboard"; +import { getBuzzCodeBlockClipboardText } from "@/shared/lib/codeBlockClipboard"; + +export function useComposerPasteHandler(options: { + editor: Editor | null; + scrollToBottom: () => void; + setPendingImeta: ( + update: (current: BlobDescriptor[]) => BlobDescriptor[], + ) => void; + uploadFile: (file: File) => Promise; +}) { + const uploadFileRef = React.useRef(options.uploadFile); + uploadFileRef.current = options.uploadFile; + React.useEffect(() => { + const editor = options.editor; + if (!editor) return; + editor.setOptions({ + editorProps: { + ...editor.options.editorProps, + handlePaste: (view, event) => { + const mediaItem = Array.from(event.clipboardData?.items ?? []).find( + (item) => item.kind === "file", + ); + if (mediaItem) { + const file = mediaItem.getAsFile(); + if (file) void uploadFileRef.current(file); + return true; + } + const codeBlockText = getBuzzCodeBlockClipboardText( + event.clipboardData, + ); + if (codeBlockText !== null) { + event.preventDefault(); + editor + .chain() + .focus() + .insertContent([ + { + type: "codeBlock", + content: + codeBlockText.length > 0 + ? [{ type: "text", text: codeBlockText }] + : [], + }, + { type: "paragraph" }, + ]) + .run(); + options.scrollToBottom(); + return true; + } + if (handleAgentSnapshotPaste(event, options.setPendingImeta)) + return true; + const html = event.clipboardData?.getData("text/html"); + if (html && hasMentionClipboardHtml(html)) { + event.preventDefault(); + view.pasteHTML(normalizeMentionClipboardHtml(html)); + return true; + } + if ((event.clipboardData?.getData("text/plain") ?? "").includes("\n")) + options.scrollToBottom(); + return false; + }, + }, + }); + }, [options.editor, options.scrollToBottom, options.setPendingImeta]); +} diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts index 8176bb13bb..ab7d8c2f4d 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts @@ -13,7 +13,6 @@ export { MENTION_REFERENCE_TAG }; export type PendingNonMemberMentionSend = { addressedAgentPubkeys: string[]; - audienceRevision: number; inlineAgentMentionPubkeys: string[]; capturedChannelId: string | null; capturedThreadContext: { @@ -38,7 +37,6 @@ export type PendingNonMemberMentionSend = { export type SendMessageWithMentionFlowInput = { addressedAgentPubkeys?: readonly string[]; - audienceRevision?: number; capturedChannelId: string | null; capturedThreadContext?: PendingNonMemberMentionSend["capturedThreadContext"]; pendingImeta: ImetaMedia[]; diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index 601c3b4113..10ca033272 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -17,21 +17,14 @@ import { dmThreadAgentMentionError } from "@/features/messages/lib/dmThreadAgent import { prepareBackgroundMediaUpload, saveQueuedAttachmentsForDraft, - type QueuedMediaAttachment, } from "@/features/messages/lib/backgroundMediaUploadStore"; -import type { UseChannelLinksResult } from "@/features/messages/lib/useChannelLinks"; -import type { UseEmojiAutocompleteResult } from "@/features/messages/lib/useEmojiAutocomplete"; import { buildOutgoingMessage, type ImetaMedia, } from "@/features/messages/lib/imetaMediaMarkdown"; -import type { UseMentionsResult } from "@/features/messages/lib/useMentions"; -import type { UseRichTextEditorResult } from "@/features/messages/lib/useRichTextEditor"; -import type { UseDraftsResult } from "@/features/messages/lib/useDrafts"; import { useActivePreparedLinkPreviews } from "./useActivePreparedLinkPreviews"; import { invokeTauri } from "@/shared/api/tauri"; -import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; -import type { AcpRuntime, ChannelType, ManagedAgent } from "@/shared/api/types"; +import type { AcpRuntime, ManagedAgent } from "@/shared/api/types"; import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags"; import { @@ -47,46 +40,8 @@ import { uniqueNormalizedPubkeys, } from "./useMentionSendFlow.helpers"; import { buildAgentAddressMentionTags } from "@/features/messages/lib/agentAddressMention.mjs"; -type UseMentionSendFlowOptions = { - channelId: string | null; - channelLinks: Pick; - channelType: ChannelType | null; - contentRef: React.MutableRefObject; - customEmoji: CustomEmoji[]; - drafts: Pick; - emojiAutocomplete: Pick; - mentions: UseMentionsResult; - onPrepareSendChannel?: (pubkeys?: string[]) => Promise; - onAddressedAgentsSendStarted?: (pubkeys: readonly string[]) => void; - onAddressedAgentsSendFailed?: (pubkeys: readonly string[]) => void; - onInlineAgentMentionsSent?: (promotion: { - expectedRevision: number; - pubkeys: readonly string[]; - }) => void; - onSendRef: React.MutableRefObject< - ( - content: string, - mentionPubkeys: string[], - mediaTags?: string[][], - channelId?: string | null, - threadContext?: { - parentEventId: string | null; - threadHeadId: string | null; - } | null, - forceRest?: boolean, - ) => Promise - >; - richText: Pick; - setContent: (content: string) => void; - setIsEmojiPickerOpen: React.Dispatch>; - setPendingImeta: (pendingImeta: ImetaMedia[]) => void; - hasUnsavedMedia: () => boolean; - clearQueuedAttachments: () => void; - restoreQueuedAttachments: (attachments: QueuedMediaAttachment[]) => void; - setSpoileredAttachmentUrls?: React.Dispatch< - React.SetStateAction> - >; -}; +import type { UseMentionSendFlowOptions } from "./useMentionSendFlow.types"; + export function useMentionSendFlow({ channelId, channelLinks, @@ -98,8 +53,9 @@ export function useMentionSendFlow({ mentions, onPrepareSendChannel, onAddressedAgentsSendStarted, + onAddressedAgentsComposerCleared, onAddressedAgentsSendFailed, - onInlineAgentMentionsSent, + onAddressedAgentsSendSucceeded, onSendRef, richText, setContent, @@ -406,6 +362,7 @@ export function useMentionSendFlow({ ); }; let composerCleared = false; + let optimisticComposerContent = ""; const restoreComposerAfterFailure = () => { if (!composerCleared) return; composerCleared = false; @@ -422,7 +379,7 @@ export function useMentionSendFlow({ } const canRestoreCurrentComposer = canAnimateCurrentComposer && - contentRef.current.trim().length === 0 && + contentRef.current.trim() === optimisticComposerContent.trim() && !hasUnsavedMedia(); if (!canRestoreCurrentComposer && draft.recoveryDraftKey) { saveQueuedAttachmentsForDraft( @@ -451,6 +408,12 @@ export function useMentionSendFlow({ onAddressedAgentsSendStarted?.(draft.addressedAgentPubkeys); } clearComposer(); + if (draft.addressedAgentPubkeys.length > 0) { + optimisticComposerContent = + onAddressedAgentsComposerCleared?.(draft.addressedAgentPubkeys) ?? + ""; + contentRef.current = optimisticComposerContent; + } composerCleared = true; } let uploadStarted = false; @@ -587,12 +550,23 @@ export function useMentionSendFlow({ const sentMentionPubkeys = new Set( revalidatedMentionPubkeys.map(normalizePubkey), ); - onInlineAgentMentionsSent?.({ - expectedRevision: draft.audienceRevision, - pubkeys: draft.inlineAgentMentionPubkeys.filter((pubkey) => - sentMentionPubkeys.has(normalizePubkey(pubkey)), - ), - }); + const newlyPinnedPubkeys = draft.inlineAgentMentionPubkeys.filter( + (pubkey) => sentMentionPubkeys.has(normalizePubkey(pubkey)), + ); + if ( + draft.capturedChannelId === channelIdRef.current || + channelIdRef.current === null + ) { + onAddressedAgentsSendSucceeded?.( + [ + ...new Set([ + ...draft.addressedAgentPubkeys, + ...newlyPinnedPubkeys, + ]), + ], + newlyPinnedPubkeys, + ); + } if (draft.sentDraftKey) { drafts.markDraftSent( draft.sentDraftKey, @@ -604,12 +578,18 @@ export function useMentionSendFlow({ } }; if (preparedUpload) { + let settleUpload!: () => void; + const uploadSettled = new Promise((resolve) => { + settleUpload = resolve; + }); uploadStarted = preparedUpload.start({ onComplete: async (uploaded, signal) => { try { await finishSend(uploaded, signal); } catch { restoreComposerAfterFailure(); + } finally { + settleUpload(); } }, onError: (error) => { @@ -617,14 +597,18 @@ export function useMentionSendFlow({ toast.error( `Upload failed: ${getErrorMessage(error, "Unknown error")}`, ); + settleUpload(); }, onCancel: () => { restoreComposerAfterFailure(); + settleUpload(); }, }); if (!uploadStarted) { + settleUpload(); return restoreComposerAfterFailure(); } + await uploadSettled; } if (!preparedUpload) { try { @@ -657,8 +641,9 @@ export function useMentionSendFlow({ mentions.isAgentPubkey, mentions.revalidateMentionPubkeys, onAddressedAgentsSendStarted, + onAddressedAgentsComposerCleared, onAddressedAgentsSendFailed, - onInlineAgentMentionsSent, + onAddressedAgentsSendSucceeded, onPrepareSendChannel, onSendRef, richText.setContent, @@ -674,7 +659,6 @@ export function useMentionSendFlow({ const sendMessageWithMentionFlow = React.useCallback( async ({ addressedAgentPubkeys = [], - audienceRevision = 0, capturedChannelId, capturedThreadContext = null, pendingImeta, @@ -783,7 +767,6 @@ export function useMentionSendFlow({ const savedMentionRefs = mentions.getDraftMentionRefs(trimmed); const pendingDraft: PendingNonMemberMentionSend = { addressedAgentPubkeys: uniqueNormalizedPubkeys(addressedAgentPubkeys), - audienceRevision, inlineAgentMentionPubkeys: uniqueNormalizedPubkeys( savedMentionRefs .filter((ref) => ref.isAgent) diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.types.ts b/desktop/src/features/messages/ui/useMentionSendFlow.types.ts new file mode 100644 index 0000000000..496a73a94a --- /dev/null +++ b/desktop/src/features/messages/ui/useMentionSendFlow.types.ts @@ -0,0 +1,52 @@ +import type * as React from "react"; +import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; +import type { ChannelType } from "@/shared/api/types"; +import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; +import type { QueuedMediaAttachment } from "@/features/messages/lib/backgroundMediaUploadStore"; +import type { UseChannelLinksResult } from "@/features/messages/lib/useChannelLinks"; +import type { UseEmojiAutocompleteResult } from "@/features/messages/lib/useEmojiAutocomplete"; +import type { UseMentionsResult } from "@/features/messages/lib/useMentions"; +import type { UseRichTextEditorResult } from "@/features/messages/lib/useRichTextEditor"; +import type { UseDraftsResult } from "@/features/messages/lib/useDrafts"; + +export type UseMentionSendFlowOptions = { + channelId: string | null; + channelLinks: Pick; + channelType: ChannelType | null; + contentRef: React.MutableRefObject; + customEmoji: CustomEmoji[]; + drafts: Pick; + emojiAutocomplete: Pick; + mentions: UseMentionsResult; + onPrepareSendChannel?: (pubkeys?: string[]) => Promise; + onAddressedAgentsSendStarted?: (pubkeys: readonly string[]) => void; + onAddressedAgentsComposerCleared?: (pubkeys: readonly string[]) => string; + onAddressedAgentsSendFailed?: (pubkeys: readonly string[]) => void; + onAddressedAgentsSendSucceeded?: ( + pubkeys: readonly string[], + newlyPinnedPubkeys: readonly string[], + ) => void; + onSendRef: React.MutableRefObject< + ( + content: string, + mentionPubkeys: string[], + mediaTags?: string[][], + channelId?: string | null, + threadContext?: { + parentEventId: string | null; + threadHeadId: string | null; + } | null, + forceRest?: boolean, + ) => Promise + >; + richText: Pick; + setContent: (content: string) => void; + setIsEmojiPickerOpen: React.Dispatch>; + setPendingImeta: (pendingImeta: ImetaMedia[]) => void; + hasUnsavedMedia: () => boolean; + clearQueuedAttachments: () => void; + restoreQueuedAttachments: (attachments: QueuedMediaAttachment[]) => void; + setSpoileredAttachmentUrls?: React.Dispatch< + React.SetStateAction> + >; +}; diff --git a/desktop/src/shared/lib/keyboard-shortcuts.ts b/desktop/src/shared/lib/keyboard-shortcuts.ts index e8b388d0dc..d8e1550549 100644 --- a/desktop/src/shared/lib/keyboard-shortcuts.ts +++ b/desktop/src/shared/lib/keyboard-shortcuts.ts @@ -166,9 +166,9 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ { id: "always-address-agent", label: "Always address agent", - description: "Open the agent picker, or toggle the highlighted agent", - keys: "⇧⌘↵", - keysWindows: "Ctrl+Shift+Enter", + description: "Address the default agent, or select the highlighted agent", + keys: "⇧⌘M", + keysWindows: "Ctrl+Shift+M", category: "Messages", }, { diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 6726a22402..2136e2e12f 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -430,7 +430,7 @@ test("duplicate owned agents preserve provenance and exact pubkey selection", as await expect .poll(() => readOutgoingMentionPubkeys(page, "local")) .toEqual([managedPubkey]); - await expect(input).toBeEmpty(); + await expect(input).toHaveText("@carl "); await page.getByTestId(`composer-address-lock-${managedPubkey}`).click(); await input.fill("@carl"); @@ -720,7 +720,7 @@ test("defers agent mentions until DM members finish loading", async ({ expect(commandCount(await readCommandLog(page), "add_channel_members")).toBe( commandCount(baselineCommands, "add_channel_members"), ); - await expect(input).toBeEmpty(); + await expect(input).toHaveText("@alice "); await expect(threadPanel).toContainText("before members resolve"); }); @@ -1290,7 +1290,8 @@ test("managed relay-profile agents with member roles use the agent address tray" await expect(dropdown.getByText("agent")).toBeVisible(); await input.press("Enter"); - await expect(input).toBeEmpty(); + await expect(input).toHaveText("@charlie "); + await expect(input.locator(".agent-mention-highlight")).toHaveText("charlie"); await expect( page.getByTestId(`composer-address-lock-${TEST_IDENTITIES.charlie.pubkey}`), ).toBeVisible(); @@ -2642,8 +2643,8 @@ test("selecting a managed non-member agent from a DM addresses it", async ({ await expect(input.locator(".mention-chip")).toHaveCount(0); await input.press("Enter"); - await expect(input).toBeEmpty(); - await expect(input.locator(".mention-chip")).toHaveCount(0); + await expect(input).toHaveText("@charlie "); + await expect(input.locator(".agent-mention-highlight")).toHaveText("charlie"); await expect( page.getByTestId(`composer-address-lock-${TEST_IDENTITIES.charlie.pubkey}`), ).toBeVisible(); diff --git a/desktop/tests/e2e/persistent-agent-audience.spec.ts b/desktop/tests/e2e/persistent-agent-audience.spec.ts index be1dbc86c3..f9481c91f0 100644 --- a/desktop/tests/e2e/persistent-agent-audience.spec.ts +++ b/desktop/tests/e2e/persistent-agent-audience.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page } from "@playwright/test"; +import { expect, test, type Locator, type Page } from "@playwright/test"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge } from "../helpers/bridge"; @@ -28,6 +28,9 @@ async function automaticallyMention( .getByTestId("mention-autocomplete") .getByRole("button", { name: `Automatically mention ${displayName}` }) .click(); + await expect(composer.getByTestId("message-input")).toContainText( + `@${displayName}`, + ); await composer.locator("[data-mention-picker-trigger]").click(); } @@ -54,7 +57,20 @@ function threadComposer(page: Page) { return page.getByTestId("thread-composer-overlay"); } -async function pressPrimaryShift(page: Page, key: "Enter" | "M") { +async function readComposerCaret(input: Locator) { + return input.evaluate((element) => { + const selection = window.getSelection(); + if (!selection?.anchorNode || !element.contains(selection.anchorNode)) { + return null; + } + const range = document.createRange(); + range.selectNodeContents(element); + range.setEnd(selection.anchorNode, selection.anchorOffset); + return range.toString().length; + }); +} + +async function pressPrimaryShift(page: Page, key: "M") { const isMac = await page.evaluate(() => /mac|iphone|ipad|ipod/i.test(navigator.platform), ); @@ -106,11 +122,38 @@ async function readOutgoingMentionPubkeys(page: Page, content: string) { }, content); } +async function emitMockMessage( + page: Page, + content: string, + mentionPubkeys: string[], +) { + await page.evaluate( + ({ body, mentions }) => { + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "general", + content: body, + mentionPubkeys: mentions, + }); + }, + { body: content, mentions: mentionPubkeys }, + ); +} + async function installAudienceFixtures( page: Page, options: { + deferredComposerUploads?: boolean; sendMessageDelayMs?: number; sendMessageErrors?: string[]; + uploadDelayMs?: number; + uploadDescriptors?: Array<{ + filename: string; + sha256: string; + size: number; + type: string; + uploaded: number; + url: string; + }>; usersBatchDelayMs?: number; } = {}, ) { @@ -133,6 +176,80 @@ async function installAudienceFixtures( }); } +test("keeps a queued-attachment send locked through upload and send settlement", async ({ + page, +}) => { + await installAudienceFixtures(page, { + deferredComposerUploads: true, + uploadDelayMs: 2_000, + sendMessageDelayMs: 2_000, + uploadDescriptors: [ + { + filename: "delayed-video.mp4", + sha256: "d".repeat(64), + size: 16, + type: "video/mp4", + uploaded: 1, + url: `https://mock.relay/media/${"d".repeat(64)}.mp4`, + }, + ], + }); + await openGeneral(page); + + const composer = channelComposer(page); + const composerForm = composer.getByTestId("message-composer"); + const input = composer.getByTestId("message-input"); + await input.fill("delayed upload"); + + const [chooser] = await Promise.all([ + page.waitForEvent("filechooser"), + composer.getByRole("button", { name: "Attach file" }).click(), + ]); + await chooser.setFiles({ + buffer: Buffer.from("delayed video"), + mimeType: "video/mp4", + name: "delayed-video.mp4", + }); + + const sendAttempts = () => + page.evaluate( + () => + window.__BUZZ_E2E_COMMAND_LOG__?.filter( + (entry) => entry.command === "send_channel_message", + ).length ?? 0, + ); + const sendAttemptsBefore = await sendAttempts(); + await input.press("Enter"); + await expect(composer.getByTestId("composer-upload-progress")).toBeVisible(); + + // Observe the synchronous lock itself after upload has started. The exact + // premature-release mutation (`void uploadSettled; await Promise.resolve()`) + // has already cleared this attribute by this boundary, before any retryable + // downstream command or restored-audience timing can obscure the defect. + await expect(composerForm).toHaveAttribute("data-submit-locked", "true"); + await input.press("Enter"); + await input.press("Enter"); + await expect(composerForm).toHaveAttribute("data-submit-locked", "true"); + + await expect.poll(sendAttempts).toBe(sendAttemptsBefore + 1); + + // Command entry marks the independently delayed finishSend() window. Keep + // the synchronous lock held and fence repeated submits until it settles. + await expect(composer.getByTestId("composer-upload-progress")).toBeVisible(); + await expect(composerForm).toHaveAttribute("data-submit-locked", "true"); + await input.press("Enter"); + await input.press("Enter"); + await expect(composerForm).toHaveAttribute("data-submit-locked", "true"); + + await expect( + page + .getByTestId("message-row") + .filter({ hasText: "delayed upload" }) + .last(), + ).toBeVisible({ timeout: 5_000 }); + await expect(composerForm).toHaveAttribute("data-submit-locked", "false"); +}); + test("automatically mentions multiple agents from the mention picker", async ({ page, }) => { @@ -154,9 +271,7 @@ test("automatically mentions multiple agents from the mention picker", async ({ ).toBeVisible(); }); -test("Tab keeps a manually selected agent as an inline mention", async ({ - page, -}) => { +test("Tab immediately selects a manually mentioned agent", async ({ page }) => { await installAudienceFixtures(page); await openGeneral(page); @@ -169,12 +284,21 @@ test("Tab keeps a manually selected agent as an inline mention", async ({ await expect(input).toHaveText("@Morgarita "); await expect(composer.getByTestId("mention-autocomplete")).toHaveCount(0); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toBeVisible(); + const selectAllShortcut = await page.evaluate(() => + /mac|iphone|ipad|ipod/i.test(navigator.platform) ? "Meta+A" : "Control+A", + ); + await input.press(selectAllShortcut); + await input.press("Backspace"); + await expect(input).toHaveText(""); await expect( composer.getByTestId(`composer-address-lock-${AGENT_A}`), ).toHaveCount(0); }); -test("primary+Shift+Enter opens the picker, then pins the highlighted agent", async ({ +test("primary+Shift+M addresses the default agent, then selects the highlighted agent", async ({ page, }) => { await installAudienceFixtures(page); @@ -183,23 +307,65 @@ test("primary+Shift+Enter opens the picker, then pins the highlighted agent", as const composer = channelComposer(page); const input = composer.getByTestId("message-input"); await input.fill("draft text"); - await pressPrimaryShift(page, "Enter"); + await pressPrimaryShift(page, "M"); - const menu = composer.getByTestId("mention-autocomplete"); - await expect(menu).toBeVisible(); + await expect(input).toHaveText("@alice draft text"); + await expect(input.locator(".agent-mention-highlight")).toHaveText("alice"); + await expect( + page.getByTestId("composer-auto-pin-confirmation"), + ).toContainText("alice will be mentioned automatically"); + await pressPrimaryShift(page, "M"); await expect(input).toHaveText("draft text"); + await pressPrimaryShift(page, "M"); + await expect(input).toHaveText("@alice draft text"); + await expect( + composer + .getByTestId("composer-address-locks") + .getByRole("button", { name: /^Stop automatically mentioning / }), + ).toHaveCount(1); - await input.fill("@Mor"); - await expect(menu.getByTestId(`mention-suggestion-${AGENT_A}`)).toHaveClass( + await input.fill("@Vog"); + const menu = composer.getByTestId("mention-autocomplete"); + await expect(menu.getByTestId(`mention-suggestion-${AGENT_B}`)).toHaveClass( /(?:^|\s)bg-accent(?:\s|$)/, ); - await pressPrimaryShift(page, "Enter"); + await pressPrimaryShift(page, "M"); - await expect(menu).toBeVisible(); - await expect(input).toHaveText(""); + await expect(menu).toHaveCount(0); + await expect(input).toHaveText("@Vogue "); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_B}`), + ).toBeVisible(); await expect( composer.getByTestId(`composer-address-lock-${AGENT_A}`), - ).toBeVisible(); + ).toHaveCount(0); + await expect( + composer + .getByTestId("composer-address-locks") + .getByRole("button", { name: /^Stop automatically mentioning / }), + ).toHaveCount(1); +}); + +test("primary+Shift+M favors the most recently mentioned eligible agent", async ({ + page, +}) => { + await installAudienceFixtures(page); + await openGeneral(page); + await emitMockMessage(page, "Please ask Vogue", [AGENT_B]); + + const input = channelComposer(page).getByTestId("message-input"); + await input.fill("draft text"); + await input.press("ArrowLeft"); + await input.press("ArrowLeft"); + await expect.poll(() => readComposerCaret(input)).toBe(8); + await pressPrimaryShift(page, "M"); + + await expect(input).toHaveText("@Vogue draft text"); + await expect.poll(() => readComposerCaret(input)).toBe(15); + await pressPrimaryShift(page, "M"); + await expect(input).toHaveText("draft text"); + await pressPrimaryShift(page, "M"); + await expect(input).toHaveText("@Vogue draft text"); }); test("the mention button opens settings and can undo an address", async ({ @@ -215,11 +381,11 @@ test("the mention button opens settings and can undo an address", async ({ name: "Manage automatic agent mentions", }); - await input.fill("draft text"); + await input.type("draft text"); await ingress.click(); const menu = composer.getByTestId("mention-autocomplete"); await expect(menu).toBeVisible(); - await expect(input).toHaveText("draft text"); + await expect(input).toHaveText("@Morgarita draft text"); await page.getByTestId("mention-options-trigger").click(); await expect( page.getByTestId("mention-keep-agents-pinned-toggle"), @@ -235,7 +401,7 @@ test("the mention button opens settings and can undo an address", async ({ if (!layerBox || !optionsBox) throw new Error("Mention tray is not laid out"); await page.mouse.click(layerBox.x + 4, optionsBox.y + optionsBox.height / 2); await expect(menu).toHaveCount(0); - await expect(input).toHaveText("draft text"); + await expect(input).toHaveText("@Morgarita draft text"); await ingress.click(); await expect(menu).toBeVisible(); await expect(page.getByTestId("mention-options-trigger")).toHaveAttribute( @@ -247,7 +413,7 @@ test("the mention button opens settings and can undo an address", async ({ ).toHaveCount(0); await ingress.click(); await expect(menu).toHaveCount(0); - await input.fill(""); + await expect(input).toHaveText("@Morgarita draft text"); await ingress.click(); await expect(menu).toBeVisible(); await expect(page.getByTestId("user-profile-panel")).toHaveCount(0); @@ -260,10 +426,11 @@ test("the mention button opens settings and can undo an address", async ({ await menu .getByRole("button", { name: "Stop automatically mentioning Morgarita" }) .click(); - await expect(input).toHaveText(""); + await expect(input).toHaveText("draft text"); await expect( composer.getByRole("button", { name: "Mention someone" }), ).toBeVisible(); + await input.fill(""); await menu .getByRole("button", { name: "Mention Morgarita", exact: true }) @@ -271,11 +438,11 @@ test("the mention button opens settings and can undo an address", async ({ await expect(input).toHaveText("@Morgarita "); await expect( composer.getByTestId(`composer-address-lock-${AGENT_A}`), - ).toHaveCount(0); + ).toBeVisible(); await input.type("later"); await input.press("Enter"); - await expect(input).toHaveText(""); + await expect(input).toHaveText("@Morgarita "); await expect .poll(() => readOutgoingMentionPubkeys(page, "@Morgarita later")) .toContain(AGENT_A); @@ -296,15 +463,41 @@ test("always-mentioned agents remain in the mention button while Enter-send reso const composer = threadComposer(page); await automaticallyMention(composer, "Morgarita"); const input = composer.getByTestId("message-input"); - const send = composer.getByTestId("send-message"); + await input.evaluate((element) => { + const snapshots = [element.textContent ?? ""]; + new MutationObserver(() => + snapshots.push(element.textContent ?? ""), + ).observe(element, { childList: true, characterData: true, subtree: true }); + ( + window as typeof window & { __BUZZ_COMPOSER_TEXT_SNAPSHOTS__?: string[] } + ).__BUZZ_COMPOSER_TEXT_SNAPSHOTS__ = snapshots; + }); const avatar = composer.getByTestId(`composer-address-lock-${AGENT_A}`); const initialPulseVersion = Number( await avatar.getAttribute("data-pulse-version"), ); - await input.fill("hello"); + await input.type("hello"); + await input.evaluate((element) => { + const snapshots = ( + window as typeof window & { __BUZZ_COMPOSER_TEXT_SNAPSHOTS__?: string[] } + ).__BUZZ_COMPOSER_TEXT_SNAPSHOTS__; + snapshots?.splice(0, snapshots.length, element.textContent ?? ""); + }); await input.press("Enter"); - await expect(input).toHaveText("", { timeout: 500 }); + await expect(input).toHaveText("@Morgarita ", { timeout: 500 }); + await expect + .poll(() => + page.evaluate( + () => + ( + window as typeof window & { + __BUZZ_COMPOSER_TEXT_SNAPSHOTS__?: string[]; + } + ).__BUZZ_COMPOSER_TEXT_SNAPSHOTS__ ?? [], + ), + ) + .not.toContain(""); await expect(avatar).toHaveAttribute( "data-pulse-version", String(initialPulseVersion + 1), @@ -316,10 +509,10 @@ test("always-mentioned agents remain in the mention button while Enter-send reso await expect(input).toBeFocused(); await expect(composer.getByTestId("mention-autocomplete")).toHaveCount(0); - await expect(send).toBeDisabled(); await expect - .poll(() => readOutgoingMentionPubkeys(page, "hello")) + .poll(() => readOutgoingMentionPubkeys(page, "@Morgarita hello")) .toContain(AGENT_A); + await expect(input).toHaveText("@Morgarita "); const sentRow = page .getByTestId("message-row") @@ -331,6 +524,18 @@ test("always-mentioned agents remain in the mention button while Enter-send reso ); await expect(addressPrefix).toBeVisible(); await expect(addressPrefix).toHaveText("Morgarita"); + + await input.type("follow up"); + await input.press("Enter"); + const inlineMentionRow = page + .getByTestId("message-row") + .filter({ hasText: "follow up" }) + .last(); + await expect( + inlineMentionRow.locator("[data-mention].agent-mention-highlight", { + hasText: "Morgarita", + }), + ).toHaveCount(1); }); test("a failed always-mentioned send shakes the composer avatar", async ({ @@ -351,7 +556,7 @@ test("a failed always-mentioned send shakes the composer avatar", async ({ ); await expect(avatar).toHaveAttribute("data-shake-version", "0"); - await input.fill("please retry"); + await input.type("please retry"); await input.press("Enter"); await expect(avatar).toHaveAttribute( @@ -359,11 +564,11 @@ test("a failed always-mentioned send shakes the composer avatar", async ({ String(initialPulseVersion + 1), { timeout: 500 }, ); - await expect(input).toHaveText("please retry"); + await expect(input).toHaveText("@Morgarita please retry"); await expect(avatar).toHaveAttribute("data-shake-version", "1"); }); -test("a manually mentioned agent becomes selected after the message sends", async ({ +test("a manually mentioned agent becomes selected immediately", async ({ page, }) => { await installAudienceFixtures(page, { sendMessageDelayMs: 1_500 }); @@ -377,42 +582,74 @@ test("a manually mentioned agent becomes selected after the message sends", asyn await expect(input).toHaveText("@Morgarita "); await expect( composer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toBeVisible(); + + const autoPinConfirmation = page.getByTestId( + "composer-auto-pin-confirmation", + ); + await expect(autoPinConfirmation).toContainText( + "Morgarita will be mentioned automatically", + ); + await expect(autoPinConfirmation).not.toContainText( + "Future messages in this channel will include this agent.", + ); + await expect(autoPinConfirmation).toHaveAttribute("data-side", "right"); + await expect(autoPinConfirmation.locator("span")).toHaveCSS( + "white-space", + "nowrap", + ); + await expect( + page + .locator("[data-sonner-toast]") + .filter({ hasText: "Morgarita will be mentioned automatically" }), ).toHaveCount(0); + const addressControlBox = await composer + .getByTestId("composer-address-locks") + .locator("..") + .boundingBox(); + const confirmationBox = await autoPinConfirmation.boundingBox(); + expect(addressControlBox).not.toBeNull(); + expect(confirmationBox).not.toBeNull(); + if (!addressControlBox || !confirmationBox) { + throw new Error("Automatic mention confirmation is not laid out"); + } + expect(confirmationBox.x).toBeGreaterThan( + addressControlBox.x + addressControlBox.width, + ); + const turnOffAction = autoPinConfirmation.getByRole("button", { + name: "Turn off", + }); + await expect(turnOffAction).toHaveRole("button"); + await expect(turnOffAction).toHaveText("Turn off"); + + await input.press("Escape"); + await expect(autoPinConfirmation).toHaveCount(0); await input.type("hello"); await input.press("Enter"); - await expect(input).toHaveText("", { timeout: 500 }); - await expect(input.locator("[data-placeholder]").first()).toHaveAttribute( - "data-placeholder", - "Message #general", - { timeout: 500 }, - ); + await expect(input).toHaveText("@Morgarita ", { timeout: 2_500 }); + await expect(input.locator("[data-placeholder]")).toHaveCount(0); await expect(input).toBeFocused(); await expect( composer.getByTestId(`composer-address-lock-${AGENT_A}`), ).toBeVisible({ timeout: 2_500 }); - const autoPinToast = page - .locator("[data-sonner-toast][data-removed='false']") - .filter({ hasText: "Morgarita will be mentioned automatically" }); - await expect(autoPinToast).not.toContainText( - "Future messages in this channel will include this agent.", - ); - const undoAction = autoPinToast.locator("[data-action]"); - await expect(undoAction).toHaveRole("button"); - await expect(undoAction).toHaveText("Undo"); + await expect(autoPinConfirmation).toHaveCount(0); await expect .poll(() => readOutgoingMentionPubkeys(page, "@Morgarita hello")) .toContain(AGENT_A); await input.fill("follow up"); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toHaveCount(0); await input.press("Enter"); await expect .poll(() => readOutgoingMentionPubkeys(page, "follow up")) - .toContain(AGENT_A); + .not.toContain(AGENT_A); }); -test("the auto-pin toast can undo and the picker can restore the agent", async ({ +test("the auto-pin popover can turn off automatic agent mentions", async ({ page, }) => { await installAudienceFixtures(page); @@ -424,41 +661,32 @@ test("the auto-pin toast can undo and the picker can restore the agent", async ( await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); await input.press("Tab"); await expect(input).toHaveText("@Morgarita "); - await input.press("Space"); - await input.type("undo me"); - await input.press("Enter"); await expect( composer.getByTestId(`composer-address-lock-${AGENT_A}`), ).toBeVisible(); - const autoPinToast = page - .locator("[data-sonner-toast][data-removed='false']") - .filter({ hasText: "Morgarita will be mentioned automatically" }); - await autoPinToast.locator("[data-action]").click(); + const autoPinConfirmation = page.getByTestId( + "composer-auto-pin-confirmation", + ); + await expect(autoPinConfirmation).toContainText( + "Morgarita will be mentioned automatically", + ); + await autoPinConfirmation.getByRole("button", { name: "Turn off" }).click(); await expect( composer.getByTestId(`composer-address-lock-${AGENT_A}`), ).toHaveCount(0); + await expect(autoPinConfirmation).toHaveCount(0); + + await composer.getByTestId("message-insert-mention").click(); await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); - await expect(composer.getByTestId("mention-options-trigger")).toHaveAttribute( - "aria-expanded", - "true", - ); + await composer.getByTestId("mention-options-trigger").click(); await expect( composer.getByTestId("mention-keep-agents-pinned-toggle"), - ).toBeVisible(); + ).toHaveAttribute("data-state", "unchecked"); await input.press("Escape"); await expect(composer.getByTestId("mention-autocomplete")).toHaveCount(0); - await composer.locator("[data-mention-picker-trigger]").click(); - await composer - .getByTestId("mention-autocomplete") - .getByRole("button", { name: "Mention Morgarita", exact: true }) - .click(); - await expect(input).toHaveText(""); - await expect( - composer.getByTestId(`composer-address-lock-${AGENT_A}`), - ).toBeVisible(); }); test("channel automatic mentions carry into threads and stay synchronized", async ({ @@ -479,12 +707,36 @@ test("channel automatic mentions carry into threads and stay synchronized", asyn await expect(threadAutomaticMention).toBeVisible(); await threadComposer(page) - .getByRole("button", { name: "Stop automatically mentioning Morgarita" }) + .getByTestId(`composer-address-lock-remove-${AGENT_A}`) .click(); await expect(threadAutomaticMention).toHaveCount(0); await expect(channelAutomaticMention).toHaveCount(0); }); +test("reduced motion removes addressed agents without spatial animation", async ({ + page, +}) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await installAudienceFixtures(page); + await openGeneral(page); + + const composer = channelComposer(page); + const input = composer.getByTestId("message-input"); + await input.fill("@Mor"); + await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); + await input.press("Tab"); + const removeButton = composer.getByTestId( + `composer-address-lock-remove-${AGENT_A}`, + ); + await expect(removeButton).toBeVisible(); + await expect(removeButton).toHaveAttribute("style", /opacity: 1/); + await expect(removeButton).toHaveCSS("transform", "none"); + + await removeButton.click(); + await expect(input).toHaveText("@Morgarita "); + await expect(removeButton).toHaveCount(0); +}); + for (const theme of ["buzz", "buzz-dark"]) { test(`captures the mention-button placement in ${theme}`, async ({ page, @@ -519,3 +771,45 @@ test("the mention-button placement fits the narrow composer", async ({ await waitForAnimations(page); await composer.screenshot({ path: `${SHOTS}/narrow-mention-button.png` }); }); + +test("captures the lightweight auto-pin popover", async ({ page }) => { + await seedTheme(page, "buzz-dark"); + await installAudienceFixtures(page); + await openGeneral(page); + + const composer = channelComposer(page); + const input = composer.getByTestId("message-input"); + await input.fill("draft text"); + await pressPrimaryShift(page, "M"); + await expect(input).toHaveText("@alice draft text"); + + const addressControl = composer + .getByTestId("composer-address-locks") + .locator(".."); + const confirmation = page.getByTestId("composer-auto-pin-confirmation"); + await expect(confirmation).toContainText( + "alice will be mentioned automatically", + ); + await waitForAnimations(page); + + const addressBox = await addressControl.boundingBox(); + const confirmationBox = await confirmation.boundingBox(); + expect(addressBox).not.toBeNull(); + expect(confirmationBox).not.toBeNull(); + if (!addressBox || !confirmationBox) { + throw new Error("Popover is not laid out"); + } + + const left = addressBox.x - 14; + const top = Math.min(addressBox.y, confirmationBox.y) - 14; + const right = confirmationBox.x + confirmationBox.width + 14; + const bottom = + Math.max( + addressBox.y + addressBox.height, + confirmationBox.y + confirmationBox.height, + ) + 14; + await page.screenshot({ + path: `${SHOTS}/auto-pin-popover-dark.png`, + clip: { x: left, y: top, width: right - left, height: bottom - top }, + }); +}); diff --git a/desktop/tests/e2e/send-channel-binding.spec.ts b/desktop/tests/e2e/send-channel-binding.spec.ts index b0edbf6630..3a7fdf7613 100644 --- a/desktop/tests/e2e/send-channel-binding.spec.ts +++ b/desktop/tests/e2e/send-channel-binding.spec.ts @@ -84,8 +84,9 @@ test("message with agent mention lands in compose-time channel despite mid-send await input.press("Enter"); await page.keyboard.type(` ${MESSAGE_TEXT}`); - // Verify the agent is addressed before submitting. - await expect(input).toHaveText(` ${MESSAGE_TEXT}`); + // Verify the inline mention and persistent address are present before submitting. + await expect(input).toHaveText(`@BotA ${MESSAGE_TEXT}`); + await expect(input.locator(".agent-mention-highlight")).toHaveText("BotA"); await expect( page.getByTestId(`composer-address-lock-${OUT_OF_CHANNEL_BOT_PUBKEY}`), ).toBeVisible(); From f24971033178926153b49d320bd876d15d9cb2bf Mon Sep 17 00:00:00 2001 From: Luke Tornquist Date: Tue, 25 Aug 2026 14:44:49 -0400 Subject: [PATCH 3/3] Qualify canonical relay images for staged delivery (#6781) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Phase 1 of progressive delivery needs an explicit eligibility boundary before deployment automation can safely consume relay artifacts. Today the canonical Docker workflow can publish independently of the same-SHA CI result, the relay does not expose its source/build identity, and the Helm chart only renders mutable tags. The existing `ghcr.io/block/buzz-staging-dev` workflow in #6709 remains a manual pre-merge preview lane. It is deliberately not a canonical promotion input. ## What changed - Compile the full source SHA and GitHub Actions build identity into canonical relay binaries and expose it at `/_status`. - Gate creation of tagged multi-architecture manifests on the latest exact-SHA `CI` push run succeeding on `main` or `release`. - Preserve the signed SLSA provenance and add a signed, release-only deployment-eligibility predicate with source, build run, qualifying CI run, and exact compatible Buzz chart version. - Keep failed or pending builds untagged and without an eligibility predicate; preview-package artifacts are excluded by package and signer contract. - Add optional `image.digest` support to Buzz chart `0.1.8`, retaining the existing tag fallback. - Document verification and the intentional boundary between image/chart compatibility and backwards-compatible database migrations. ## Risk and rollout Canonical tag publication now waits for same-SHA CI and fails closed when the newest matching run fails. Per-architecture blobs may remain untagged after a failed qualification; they are not promotion inputs. Debug manifests are also CI-gated but do not receive the release eligibility predicate. There are no migration or schema-orchestration changes. The companion BuilderBot infrastructure PR will pin a separately verified canonical main digest and expose a read-only running-deployment inspection command. Signed eligibility is available only for artifacts published after this workflow lands. ## Verification - Blox: workflow contract fixtures, including stale-green rejection and predicate structure - Blox: `actionlint` for `ci.yml` and `docker.yml` - Blox: 46 Helm unit tests, chart lint, and production/quickstart render fixtures - Blox: relay status unit test, workspace Rust formatting, and workspace all-target clippy - Live evidence: exact-SHA CI selector chose successful run `32857345153` for source `9d1e4b257657f382d3111ce748f3da8d063b7671` - Existing canonical digest provenance verified with `gh attestation verify` ## References - #6709 — developer-preview precursor; intentionally separate from canonical eligibility - `docs/deployment-identity.md` *Generated with Codex* --------- Signed-off-by: Luke Tornquist --- .github/workflows/ci.yml | 2 + .github/workflows/docker.yml | 151 +++++++++++++++++- Dockerfile | 8 + crates/buzz-relay/src/build_info.rs | 16 ++ crates/buzz-relay/src/lib.rs | 1 + crates/buzz-relay/src/router.rs | 35 +++- deploy/charts/buzz/Chart.yaml | 4 +- deploy/charts/buzz/README.md | 11 +- deploy/charts/buzz/templates/_helpers.tpl | 4 + deploy/charts/buzz/tests/render_test.yaml | 18 +++ deploy/charts/buzz/tests/validation_test.yaml | 10 ++ deploy/charts/buzz/values.schema.json | 5 + deploy/charts/buzz/values.yaml | 1 + docs/deployment-identity.md | 68 ++++++++ ...create-deployment-eligibility-predicate.jq | 26 +++ scripts/select-qualified-ci-run.jq | 9 ++ .../test-relay-image-eligibility-workflow.sh | 108 +++++++++++++ 17 files changed, 467 insertions(+), 10 deletions(-) create mode 100644 crates/buzz-relay/src/build_info.rs create mode 100644 docs/deployment-identity.md create mode 100644 scripts/create-deployment-eligibility-predicate.jq create mode 100644 scripts/select-qualified-ci-run.jq create mode 100755 scripts/test-relay-image-eligibility-workflow.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1fa6fb94ad..f2bc8db65e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,6 +75,8 @@ jobs: - '.github/workflows/ci.yml' - name: Release workflow source contract run: scripts/test-release-ref-contract.sh + - name: Relay image eligibility contract + run: scripts/test-relay-image-eligibility-workflow.sh - name: Desktop release candidate contract run: scripts/test-desktop-release-candidate.sh - name: OSS desktop promotion contract diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 564cd74e9d..ddb55f8542 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -49,6 +49,10 @@ on: - "Dockerfile.push-gateway" - ".dockerignore" - ".github/workflows/docker.yml" + - "deploy/charts/buzz/Chart.yaml" + - "scripts/create-deployment-eligibility-predicate.jq" + - "scripts/select-qualified-ci-run.jq" + - "scripts/test-relay-image-eligibility-workflow.sh" - "Cargo.toml" - "Cargo.lock" - "rust-toolchain.toml" @@ -172,6 +176,10 @@ jobs: target: runtime platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} + build-args: | + BUZZ_SOURCE_SHA=${{ github.sha }} + BUZZ_BUILD_ID=github-actions:${{ github.run_id }}:${{ github.run_attempt }} + BUZZ_BUILD_URL=https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }} # Push by digest, not by tag — the merge job assembles the tags # into one multi-arch manifest. This is what makes the native-arm # matrix possible. @@ -190,6 +198,10 @@ jobs: target: runtime-debug platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} + build-args: | + BUZZ_SOURCE_SHA=${{ github.sha }} + BUZZ_BUILD_ID=github-actions:${{ github.run_id }}:${{ github.run_attempt }} + BUZZ_BUILD_URL=https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }} outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }} cache-from: | type=registry,ref=${{ env.IMAGE_NAME }}-buildcache:${{ matrix.arch }} @@ -222,11 +234,86 @@ jobs: if-no-files-found: error retention-days: 1 + qualify: + name: Qualify relay image source + if: github.event_name != 'pull_request' + runs-on: ubuntu-24.04 + timeout-minutes: 70 + permissions: + actions: read + contents: read + outputs: + source_sha: ${{ steps.qualify.outputs.source_sha }} + ci_run_id: ${{ steps.qualify.outputs.ci_run_id }} + ci_run_attempt: ${{ steps.qualify.outputs.ci_run_attempt }} + ci_run_url: ${{ steps.qualify.outputs.ci_run_url }} + chart_version: ${{ steps.qualify.outputs.chart_version }} + steps: + - name: Checkout source + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Require successful same-SHA CI run + id: qualify + env: + GH_TOKEN: ${{ github.token }} + SOURCE_SHA: ${{ github.sha }} + run: | + set -euo pipefail + + if [[ ! "$SOURCE_SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::Invalid source SHA: $SOURCE_SHA" + exit 1 + fi + + deadline=$((SECONDS + 3900)) + while (( SECONDS < deadline )); do + payload=$(gh api \ + "/repos/${GITHUB_REPOSITORY}/actions/workflows/ci.yml/runs?head_sha=${SOURCE_SHA}&event=push&per_page=100") + successful_run=$(jq -c --arg source_sha "$SOURCE_SHA" \ + -f "$GITHUB_WORKSPACE/scripts/select-qualified-ci-run.jq" <<<"$payload") + + if [[ -n "$successful_run" ]]; then + ci_run_id=$(jq -r '.id' <<<"$successful_run") + ci_run_attempt=$(jq -r '.run_attempt' <<<"$successful_run") + ci_run_url=$(jq -r '.html_url' <<<"$successful_run") + chart_version=$(awk '/^version:/ { print $2; exit }' deploy/charts/buzz/Chart.yaml) + if [[ ! "$chart_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$ ]]; then + echo "::error::Invalid Buzz chart version: $chart_version" + exit 1 + fi + { + echo "source_sha=$SOURCE_SHA" + echo "ci_run_id=$ci_run_id" + echo "ci_run_attempt=$ci_run_attempt" + echo "ci_run_url=$ci_run_url" + echo "chart_version=$chart_version" + } >>"$GITHUB_OUTPUT" + echo "Qualified source $SOURCE_SHA with CI run $ci_run_id (attempt $ci_run_attempt)." + exit 0 + fi + + matching=$(jq --arg source_sha "$SOURCE_SHA" '[.workflow_runs[] | select(.head_sha == $source_sha) | select(.event == "push") | select(.head_branch == "main" or .head_branch == "release")] | length' <<<"$payload") + pending=$(jq --arg source_sha "$SOURCE_SHA" '[.workflow_runs[] | select(.head_sha == $source_sha) | select(.event == "push") | select(.head_branch == "main" or .head_branch == "release") | select(.status != "completed")] | length' <<<"$payload") + if (( matching > 0 && pending == 0 )); then + echo "::error::No successful CI run exists for source $SOURCE_SHA" + jq -r --arg source_sha "$SOURCE_SHA" '.workflow_runs[] | select(.head_sha == $source_sha) | "run=\(.id) attempt=\(.run_attempt) status=\(.status) conclusion=\(.conclusion)"' <<<"$payload" + exit 1 + fi + + echo "Waiting for same-SHA CI qualification ($matching matching, $pending pending)..." + sleep 30 + done + + echo "::error::Timed out waiting for successful CI qualification of $SOURCE_SHA" + exit 1 + merge: name: Merge ${{ matrix.variant }} multi-arch manifest if: github.event_name != 'pull_request' runs-on: ubuntu-24.04 - needs: build + needs: [build, qualify] timeout-minutes: 15 permissions: contents: read @@ -286,6 +373,10 @@ jobs: env: IMAGE_NAME: ${{ env.IMAGE_NAME }} META_TAGS: ${{ steps.meta.outputs.tags }} + SOURCE_SHA: ${{ needs.qualify.outputs.source_sha }} + CI_RUN_ID: ${{ needs.qualify.outputs.ci_run_id }} + CI_RUN_ATTEMPT: ${{ needs.qualify.outputs.ci_run_attempt }} + CHART_VERSION: ${{ needs.qualify.outputs.chart_version }} run: | set -euo pipefail # Build -t flags from the metadata-action output. @@ -300,7 +391,15 @@ jobs: digests+=("${IMAGE_NAME}@sha256:${digest}") done - docker buildx imagetools create "${tags[@]}" "${digests[@]}" + annotations=( + --annotation "index:org.opencontainers.image.revision=${SOURCE_SHA}" + --annotation "index:xyz.block.buzz.build.id=github-actions:${GITHUB_RUN_ID}:${GITHUB_RUN_ATTEMPT}" + --annotation "index:xyz.block.buzz.qualification.ci-run-id=${CI_RUN_ID}" + --annotation "index:xyz.block.buzz.qualification.ci-run-attempt=${CI_RUN_ATTEMPT}" + --annotation "index:xyz.block.buzz.qualification.ci-conclusion=success" + --annotation "index:xyz.block.buzz.helm-chart.version=${CHART_VERSION}" + ) + docker buildx imagetools create "${tags[@]}" "${annotations[@]}" "${digests[@]}" # Capture the merged manifest digest for the attestation step. first_tag=$(echo "$META_TAGS" | head -n1) @@ -317,17 +416,58 @@ jobs: subject-digest: ${{ steps.manifest.outputs.digest }} push-to-registry: true + - name: Create deployment eligibility predicate + if: matrix.variant == 'release' + env: + SOURCE_SHA: ${{ needs.qualify.outputs.source_sha }} + CI_RUN_ID: ${{ needs.qualify.outputs.ci_run_id }} + CI_RUN_ATTEMPT: ${{ needs.qualify.outputs.ci_run_attempt }} + CI_RUN_URL: ${{ needs.qualify.outputs.ci_run_url }} + CHART_VERSION: ${{ needs.qualify.outputs.chart_version }} + run: | + jq -n \ + --arg source_repository "$GITHUB_REPOSITORY" \ + --arg source_ref "$GITHUB_REF" \ + --arg source_sha "$SOURCE_SHA" \ + --arg build_workflow ".github/workflows/docker.yml" \ + --argjson build_run_id "$GITHUB_RUN_ID" \ + --argjson build_run_attempt "$GITHUB_RUN_ATTEMPT" \ + --arg build_run_url "https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/attempts/${GITHUB_RUN_ATTEMPT}" \ + --arg qualification_workflow ".github/workflows/ci.yml" \ + --argjson qualification_run_id "$CI_RUN_ID" \ + --argjson qualification_run_attempt "$CI_RUN_ATTEMPT" \ + --arg qualification_run_url "$CI_RUN_URL" \ + --arg chart_version "$CHART_VERSION" \ + -f "$GITHUB_WORKSPACE/scripts/create-deployment-eligibility-predicate.jq" \ + >/tmp/deployment-eligibility.json + + - name: Attest deployment eligibility + if: matrix.variant == 'release' + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-name: ${{ env.IMAGE_NAME }} + subject-digest: ${{ steps.manifest.outputs.digest }} + predicate-type: https://buzz.block.xyz/attestations/deployment-eligibility/v1 + predicate-path: /tmp/deployment-eligibility.json + push-to-registry: true + - name: Summary env: IMAGE_NAME: ${{ env.IMAGE_NAME }} VARIANT: ${{ matrix.variant }} MERGED_DIGEST: ${{ steps.manifest.outputs.digest }} META_TAGS: ${{ steps.meta.outputs.tags }} + SOURCE_SHA: ${{ needs.qualify.outputs.source_sha }} + CI_RUN_URL: ${{ needs.qualify.outputs.ci_run_url }} + CHART_VERSION: ${{ needs.qualify.outputs.chart_version }} run: | { echo "### Published \`${IMAGE_NAME}\` (${VARIANT})" echo echo "**Digest:** \`${MERGED_DIGEST}\`" + echo "**Source:** \`${SOURCE_SHA}\`" + echo "**Compatible Buzz chart:** \`${CHART_VERSION}\`" + echo "**Qualifying CI:** ${CI_RUN_URL} (success)" echo echo "**Tags:**" echo '```' @@ -338,6 +478,13 @@ jobs: echo '```' echo "gh attestation verify oci://${IMAGE_NAME}@${MERGED_DIGEST} --owner block" echo '```' + if [[ "$VARIANT" == "release" ]]; then + echo + echo "Verify deployment eligibility:" + echo '```' + echo "gh attestation verify oci://${IMAGE_NAME}@${MERGED_DIGEST} --repo block/buzz --signer-workflow block/buzz/.github/workflows/docker.yml --predicate-type https://buzz.block.xyz/attestations/deployment-eligibility/v1 --source-digest ${SOURCE_SHA}" + echo '```' + fi } >> "$GITHUB_STEP_SUMMARY" push-gateway-build: diff --git a/Dockerfile b/Dockerfile index d883ac6b01..2c1e8adbb5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -67,6 +67,14 @@ COPY --from=planner /build/recipe.json recipe.json # scoping to -p buzz-relay misses transitive deps and re-builds them later. RUN cargo chef cook --release --recipe-path recipe.json COPY . . +# Compile immutable artifact identity into the relay. Defaults preserve local +# and third-party builds that do not run in provenance-aware CI. +ARG BUZZ_SOURCE_SHA=unknown +ARG BUZZ_BUILD_ID=local +ARG BUZZ_BUILD_URL=unknown +ENV BUZZ_SOURCE_SHA=${BUZZ_SOURCE_SHA} \ + BUZZ_BUILD_ID=${BUZZ_BUILD_ID} \ + BUZZ_BUILD_URL=${BUZZ_BUILD_URL} RUN cargo build --release --locked -p buzz-relay --bin buzz-relay \ -p buzz-admin --bin buzz-admin \ -p buzz-pair-relay --bin buzz-pair-relay diff --git a/crates/buzz-relay/src/build_info.rs b/crates/buzz-relay/src/build_info.rs new file mode 100644 index 0000000000..c7073505b3 --- /dev/null +++ b/crates/buzz-relay/src/build_info.rs @@ -0,0 +1,16 @@ +//! Build-time identity compiled into the relay binary. + +/// Full source commit SHA, or `unknown` outside a provenance-aware build. +pub(crate) fn source_sha() -> &'static str { + option_env!("BUZZ_SOURCE_SHA").unwrap_or("unknown") +} + +/// Stable build identifier, or `local` outside CI. +pub(crate) fn build_id() -> &'static str { + option_env!("BUZZ_BUILD_ID").unwrap_or("local") +} + +/// Build details URL, or `unknown` outside CI. +pub(crate) fn build_url() -> &'static str { + option_env!("BUZZ_BUILD_URL").unwrap_or("unknown") +} diff --git a/crates/buzz-relay/src/lib.rs b/crates/buzz-relay/src/lib.rs index 314adad92e..800433a849 100644 --- a/crates/buzz-relay/src/lib.rs +++ b/crates/buzz-relay/src/lib.rs @@ -3,6 +3,7 @@ //! NIP-01 WebSocket relay for Buzz private team communication. mod admission; +mod build_info; /// REST API route handlers. pub mod api; diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 1dce66e91e..653e21f093 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -413,14 +413,22 @@ async fn readiness_handler(State(state): State>) -> impl IntoRespo } } -/// Status endpoint — service name, version, uptime. -async fn status_handler(State(state): State>) -> impl IntoResponse { - let uptime_secs = state.started_at.elapsed().as_secs(); - Json(json!({ +fn status_payload(uptime_secs: u64) -> serde_json::Value { + json!({ "service": "buzz-relay", "version": env!("CARGO_PKG_VERSION"), "uptime_seconds": uptime_secs, - })) + "build": { + "source_sha": crate::build_info::source_sha(), + "id": crate::build_info::build_id(), + "url": crate::build_info::build_url(), + }, + }) +} + +/// Status endpoint — service name, version, uptime, and intrinsic build identity. +async fn status_handler(State(state): State>) -> impl IntoResponse { + Json(status_payload(state.started_at.elapsed().as_secs())) } /// `/_mesh` — live mesh status: peer table, connection/phi state, per-peer @@ -506,6 +514,23 @@ mod tests { assert!(!should_serve_spa("/arbitrary", true)); } + #[test] + fn status_payload_exposes_source_and_build_identity() { + let payload = status_payload(42); + + assert_eq!(payload["service"], "buzz-relay"); + assert_eq!(payload["version"], env!("CARGO_PKG_VERSION")); + assert_eq!(payload["uptime_seconds"], 42); + for field in ["source_sha", "id", "url"] { + assert!( + payload["build"][field] + .as_str() + .is_some_and(|value| !value.is_empty()), + "build.{field} must be a non-empty string" + ); + } + } + #[tokio::test(flavor = "current_thread")] async fn http_and_datastore_spans_are_exported_in_the_same_trace() { let exporter = InMemorySpanExporter::default(); diff --git a/deploy/charts/buzz/Chart.yaml b/deploy/charts/buzz/Chart.yaml index 9309074895..49a6fafd19 100644 --- a/deploy/charts/buzz/Chart.yaml +++ b/deploy/charts/buzz/Chart.yaml @@ -7,7 +7,7 @@ description: | PostgreSQL and Redis. Configurable for single-node evaluation (subcharts on) and HA production (external services, existingSecret). type: application -version: 0.1.7 +version: 0.1.8 appVersion: "0.1.0" home: https://github.com/block/buzz sources: @@ -24,7 +24,7 @@ maintainers: annotations: artifacthub.io/changes: | - kind: added - description: Generic init-container, volume, volume-mount, command, and args extension points for the relay Pod. + description: Optional immutable relay image digest pinning with backwards-compatible tag fallback. artifacthub.io/license: Apache-2.0 # Optional eval-only subcharts. Production deploys disable both and point diff --git a/deploy/charts/buzz/README.md b/deploy/charts/buzz/README.md index 30cee4f406..e0645ef6fb 100644 --- a/deploy/charts/buzz/README.md +++ b/deploy/charts/buzz/README.md @@ -12,7 +12,7 @@ This chart has two operating profiles selected by values: ## Quickstart (eval only) ```sh -helm install buzz oci://ghcr.io/block/buzz/charts/buzz --version 0.1.7 \ +helm install buzz oci://ghcr.io/block/buzz/charts/buzz --version 0.1.8 \ --create-namespace --namespace buzz \ --set quickstart=true \ --set postgresql.enabled=true \ @@ -29,6 +29,15 @@ intent marker surfaced in NOTES.txt; the bundled services are opted in via the four `*.enabled` flags above (see `ci/quickstart-values.yaml` for the exact set CI installs). Eval-only: every bundled service is a single replica with no HA. +For immutable delivery, pin the OCI digest instead of a tag. `image.digest` +overrides `image.tag` when both are present: + +```yaml +image: + repository: ghcr.io/block/buzz + digest: sha256:<64-lowercase-hex-characters> +``` + ## Production (GitOps) The chart is designed for ArgoCD and Flux. Both render charts with `helm template`, in which mode Helm's `lookup` function returns empty — any chart-side `randAlphaNum` call would regenerate secrets on every sync. The chart-managed Secret path is **only** safe for `helm install` / `helm upgrade`. diff --git a/deploy/charts/buzz/templates/_helpers.tpl b/deploy/charts/buzz/templates/_helpers.tpl index ff070379eb..13efe0d1fd 100644 --- a/deploy/charts/buzz/templates/_helpers.tpl +++ b/deploy/charts/buzz/templates/_helpers.tpl @@ -53,9 +53,13 @@ app.kubernetes.io/component: relay {{- end -}} {{- define "buzz.image" -}} +{{- if .Values.image.digest -}} +{{- printf "%s@%s" .Values.image.repository .Values.image.digest -}} +{{- else -}} {{- $tag := default .Chart.AppVersion .Values.image.tag -}} {{- printf "%s:%s" .Values.image.repository $tag -}} {{- end -}} +{{- end -}} {{/* Name of the chart-managed Secret holding relay-identity material and any diff --git a/deploy/charts/buzz/tests/render_test.yaml b/deploy/charts/buzz/tests/render_test.yaml index 10a1a34d1f..f288f8df2d 100644 --- a/deploy/charts/buzz/tests/render_test.yaml +++ b/deploy/charts/buzz/tests/render_test.yaml @@ -195,6 +195,24 @@ tests: path: spec.template.spec.containers[0].args template: templates/deployment.yaml + - it: renders an immutable digest instead of a configured tag + set: + relayUrl: wss://buzz.example.com + ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" + externalPostgresql.url: postgres://u:p@h:5432/d + externalRedis.url: redis://h:6379 + s3.endpoint: http://minio:9000 + s3.accessKey: a + s3.secretKey: s + image.repository: ghcr.io/block/buzz + image.tag: sha-deadbee + image.digest: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + asserts: + - equal: + path: spec.template.spec.containers[0].image + value: ghcr.io/block/buzz@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + template: templates/deployment.yaml + - it: appends generic Pod extensions and overrides the relay command set: relayUrl: wss://buzz.example.com diff --git a/deploy/charts/buzz/tests/validation_test.yaml b/deploy/charts/buzz/tests/validation_test.yaml index a5a0050a86..3498189c8a 100644 --- a/deploy/charts/buzz/tests/validation_test.yaml +++ b/deploy/charts/buzz/tests/validation_test.yaml @@ -2,6 +2,16 @@ suite: validation templates: - templates/deployment.yaml tests: + - it: rejects a malformed image digest + set: + relayUrl: wss://buzz.example.com + ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" + externalPostgresql.url: postgres://u:p@h:5432/d + image.digest: sha256:not-a-digest + asserts: + - failedTemplate: + errorPattern: "image.digest: Does not match pattern" + - it: fails when relayUrl is missing set: relayUrl: "" diff --git a/deploy/charts/buzz/values.schema.json b/deploy/charts/buzz/values.schema.json index 94d369c890..aaab848fd3 100644 --- a/deploy/charts/buzz/values.schema.json +++ b/deploy/charts/buzz/values.schema.json @@ -15,6 +15,11 @@ "properties": { "repository": { "type": "string", "minLength": 1 }, "tag": { "type": "string" }, + "digest": { + "type": "string", + "pattern": "^$|^sha256:[0-9a-f]{64}$", + "description": "Optional immutable OCI image digest. When set, the chart renders repository@digest and ignores tag." + }, "pullPolicy": { "type": "string", "enum": ["Always", "IfNotPresent", "Never"] }, "pullSecrets": { "type": "array", diff --git a/deploy/charts/buzz/values.yaml b/deploy/charts/buzz/values.yaml index 448e15ca97..738c50eec3 100644 --- a/deploy/charts/buzz/values.yaml +++ b/deploy/charts/buzz/values.yaml @@ -25,6 +25,7 @@ quickstart: false image: repository: ghcr.io/block/buzz tag: "" # empty → .Chart.AppVersion + digest: "" # optional sha256:...; when set, overrides tag pullPolicy: IfNotPresent pullSecrets: [] diff --git a/docs/deployment-identity.md b/docs/deployment-identity.md new file mode 100644 index 0000000000..32d8c426e7 --- /dev/null +++ b/docs/deployment-identity.md @@ -0,0 +1,68 @@ +# Relay deployment identity + +Canonical relay images from `ghcr.io/block/buzz` carry two signed +attestations: + +- SLSA build provenance maps the immutable image digest to the source commit + and Docker workflow run. +- The Buzz deployment-eligibility predicate records the successful same-SHA + CI run and the exact Buzz Helm chart version from that source commit. + +The Docker workflow creates tagged multi-architecture manifests only after the +same full source SHA has a successful `CI` push run on `main` or `release`. +Architecture-specific build manifests may exist without tags while CI is +running or after it fails; they do not receive the deployment-eligibility +predicate and are not promotion inputs. + +Verify a canonical eligible digest with: + +```bash +gh attestation verify \ + oci://ghcr.io/block/buzz@sha256: \ + --repo block/buzz \ + --signer-workflow block/buzz/.github/workflows/docker.yml \ + --predicate-type https://buzz.block.xyz/attestations/deployment-eligibility/v1 \ + --source-ref refs/heads/main +``` + +The predicate's `helm_chart.compatible_version` is image-to-chart metadata. It +does not describe database schema compatibility and does not relax Buzz's rule +that migrations remain backwards compatible. + +The manual pre-merge workflow publishes only to +`ghcr.io/block/buzz-staging-dev`. Those preview images are intentionally +ineligible: they use a different package, may name non-main source, and do not +receive the canonical deployment-eligibility predicate. + +## Runtime inspection + +The relay health listener exposes intrinsic build identity at `/_status`: + +```json +{ + "service": "buzz-relay", + "version": "0.2.1", + "uptime_seconds": 123, + "build": { + "source_sha": "<40-character-source-sha>", + "id": "github-actions::", + "url": "https://github.com/block/buzz/actions/runs//attempts/" + } +} +``` + +Non-CI builds report stable `unknown` or `local` fallback values instead of +claiming provenance they do not have. + +## Helm digest pinning + +Buzz chart `0.1.8` and newer accept an immutable image digest: + +```yaml +image: + repository: ghcr.io/block/buzz + digest: sha256:<64-lowercase-hex-characters> +``` + +When `image.digest` is set, the chart renders `repository@digest` and ignores +`image.tag`. Existing tag-only values remain backwards compatible. diff --git a/scripts/create-deployment-eligibility-predicate.jq b/scripts/create-deployment-eligibility-predicate.jq new file mode 100644 index 0000000000..3d984b4e6e --- /dev/null +++ b/scripts/create-deployment-eligibility-predicate.jq @@ -0,0 +1,26 @@ +{ + predicate_version: 1, + eligible: true, + source: { + repository: $source_repository, + ref: $source_ref, + sha: $source_sha + }, + build: { + workflow: $build_workflow, + run_id: $build_run_id, + run_attempt: $build_run_attempt, + run_url: $build_run_url + }, + qualification: { + workflow: $qualification_workflow, + run_id: $qualification_run_id, + run_attempt: $qualification_run_attempt, + run_url: $qualification_run_url, + conclusion: "success" + }, + helm_chart: { + name: "buzz", + compatible_version: $chart_version + } +} diff --git a/scripts/select-qualified-ci-run.jq b/scripts/select-qualified-ci-run.jq new file mode 100644 index 0000000000..4d99a9e60f --- /dev/null +++ b/scripts/select-qualified-ci-run.jq @@ -0,0 +1,9 @@ +[ + .workflow_runs[] + | select(.head_sha == $source_sha) + | select(.event == "push") + | select(.head_branch == "main" or .head_branch == "release") +] +| sort_by([.id, .run_attempt]) +| last // empty +| select(.conclusion == "success") diff --git a/scripts/test-relay-image-eligibility-workflow.sh b/scripts/test-relay-image-eligibility-workflow.sh new file mode 100755 index 0000000000..168f9d3689 --- /dev/null +++ b/scripts/test-relay-image-eligibility-workflow.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +workflow="$repo_root/.github/workflows/docker.yml" +selector="$repo_root/scripts/select-qualified-ci-run.jq" +predicate_builder="$repo_root/scripts/create-deployment-eligibility-predicate.jq" + +require_literal() { + local needle=$1 + grep -Fq -- "$needle" "$workflow" || { + echo "relay image workflow is missing required delivery contract: $needle" >&2 + exit 1 + } +} + +require_literal " qualify:" +require_literal "actions: read" +require_literal "actions/workflows/ci.yml/runs" +require_literal 'select-qualified-ci-run.jq' +require_literal "needs: [build, qualify]" +require_literal "https://buzz.block.xyz/attestations/deployment-eligibility/v1" +require_literal "actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6" +require_literal "if: matrix.variant == 'release'" +require_literal "BUZZ_SOURCE_SHA" +require_literal "BUZZ_BUILD_ID" +require_literal "BUZZ_BUILD_URL" +require_literal '- "deploy/charts/buzz/Chart.yaml"' +require_literal '- "scripts/create-deployment-eligibility-predicate.jq"' +require_literal '- "scripts/select-qualified-ci-run.jq"' +require_literal '- "scripts/test-relay-image-eligibility-workflow.sh"' + +if grep -Fq "buzz-staging-dev" "$workflow"; then + echo "canonical relay image workflow references the preview-only image package" >&2 + exit 1 +fi + +select_run() { + jq -r --arg source_sha aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa -f "$selector" | jq -r '.id // empty' +} + +selected=$(select_run <<'JSON' +{"workflow_runs":[ + {"id":100,"run_attempt":1,"head_sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","head_branch":"main","event":"push","status":"completed","conclusion":"failure"}, + {"id":101,"run_attempt":1,"head_sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","head_branch":"main","event":"push","status":"completed","conclusion":"success"} +]} +JSON +) +[[ "$selected" == "101" ]] || { + echo "latest successful same-SHA main run was not selected" >&2 + exit 1 +} + +selected=$(select_run <<'JSON' +{"workflow_runs":[ + {"id":100,"run_attempt":1,"head_sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","head_branch":"main","event":"push","status":"completed","conclusion":"success"}, + {"id":101,"run_attempt":1,"head_sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","head_branch":"main","event":"push","status":"completed","conclusion":"failure"} +]} +JSON +) +[[ -z "$selected" ]] || { + echo "stale successful run remained eligible after a newer failure" >&2 + exit 1 +} + +selected=$(select_run <<'JSON' +{"workflow_runs":[ + {"id":102,"run_attempt":1,"head_sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","head_branch":"main","event":"push","status":"completed","conclusion":"success"}, + {"id":103,"run_attempt":1,"head_sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","head_branch":"feature","event":"pull_request","status":"completed","conclusion":"success"} +]} +JSON +) +[[ -z "$selected" ]] || { + echo "wrong-SHA or pull-request CI run was accepted" >&2 + exit 1 +} + +predicate=$(jq -n \ + --arg source_repository "block/buzz" \ + --arg source_ref "refs/heads/main" \ + --arg source_sha "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" \ + --arg build_workflow ".github/workflows/docker.yml" \ + --argjson build_run_id 200 \ + --argjson build_run_attempt 2 \ + --arg build_run_url "https://github.com/block/buzz/actions/runs/200/attempts/2" \ + --arg qualification_workflow ".github/workflows/ci.yml" \ + --argjson qualification_run_id 201 \ + --argjson qualification_run_attempt 1 \ + --arg qualification_run_url "https://github.com/block/buzz/actions/runs/201" \ + --arg chart_version "0.1.8" \ + -f "$predicate_builder") + +jq -e ' + .predicate_version == 1 and + .eligible == true and + .source.sha == "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" and + .build.run_id == 200 and + .qualification.run_id == 201 and + .qualification.conclusion == "success" and + .helm_chart == {"name":"buzz","compatible_version":"0.1.8"} and + (has("schema") | not) and + (.helm_chart | has("schema_compatibility") | not) +' <<<"$predicate" >/dev/null || { + echo "deployment eligibility predicate has an invalid contract" >&2 + exit 1 +} + +echo "relay image eligibility workflow contract passed"