Wrench
2026-08-25 15:08:27 -04:00
57 changed files with 2919 additions and 541 deletions

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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")
}

View File

@@ -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;

View File

@@ -413,14 +413,22 @@ async fn readiness_handler(State(state): State<Arc<AppState>>) -> impl IntoRespo
}
}
/// Status endpoint — service name, version, uptime.
async fn status_handler(State(state): State<Arc<AppState>>) -> 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<Arc<AppState>>) -> 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();

View File

@@ -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

View File

@@ -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`.

View File

@@ -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

View File

@@ -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

View File

@@ -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: ""

View File

@@ -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",

View File

@@ -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: []

View File

@@ -357,7 +357,6 @@ export const ChannelPane = React.memo(function ChannelPane({
}),
[activeChannel, currentPubkey, profiles],
);
const handleWelcomeAddAgent = React.useCallback(() => {
onAddAgent?.({
beforeSend: () =>
@@ -374,13 +373,14 @@ export const ChannelPane = React.memo(function ChannelPane({
onWelcomeAddAgent: onAddAgent ? handleWelcomeAddAgent : undefined,
});
const channelIntro = isHuddleTranscript ? null : standardChannelIntro;
const { mainTimelineEntries, visibleMessages } = useChannelPaneMessages({
activeChannel,
isHuddleTranscript,
messages,
profiles,
threadSummaries,
});
const { mainTimelineEntries, recentMentions, visibleMessages } =
useChannelPaneMessages({
activeChannel,
isHuddleTranscript,
messages,
profiles,
threadSummaries,
});
useRenderScopedReactionHydration({
activeChannel,
mainTimelineEntries,
@@ -738,7 +738,7 @@ export const ChannelPane = React.memo(function ChannelPane({
: undefined
}
onSend={handleSendMessage}
profiles={profiles}
{...{ profiles, recentMentionPubkeys: recentMentions }}
showBackgroundUploadProgress={false}
placeholder={
timeoutState.active
@@ -847,7 +847,7 @@ export const ChannelPane = React.memo(function ChannelPane({
onScrollTargetSettled={resolveScrollTarget}
onToggleReaction={onToggleReaction}
onUnfollowThread={onUnfollowThread}
profiles={profiles}
{...{ profiles, recentMentionPubkeys: recentMentions }}
replyTargetMessage={threadReplyTargetMessage}
scrollTargetHighlights={!layoutScrollTargetId}
scrollTargetId={layoutScrollTargetId ?? threadScrollTargetId}

View File

@@ -5,6 +5,7 @@ import {
} from "@/features/channels/ui/ChannelPane.helpers";
import type { ChannelPaneProps } from "@/features/channels/ui/ChannelPane.types";
import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel";
import { getRecentMentionPubkeys } from "@/features/messages/lib/recentMentionPubkeys";
import { isWelcomeExperienceChannel } from "@/features/onboarding/welcome";
type ChannelPaneMessagesOptions = Pick<
@@ -46,5 +47,14 @@ export function useChannelPaneMessages({
[isHuddleTranscript, profiles, threadSummaries, visibleMessages],
);
return { mainTimelineEntries, visibleMessages };
const recentMentionPubkeys = React.useMemo(
() => getRecentMentionPubkeys(messages, activeChannel?.channelType),
[activeChannel?.channelType, messages],
);
return {
mainTimelineEntries,
recentMentions: recentMentionPubkeys,
visibleMessages,
};
}

View File

@@ -0,0 +1,39 @@
import assert from "node:assert/strict";
import test from "node:test";
import { getVisibleAgentAddressPubkeys } from "./getVisibleAgentAddressPubkeys.ts";
const DARIA = "a".repeat(64);
const RIZZ = "b".repeat(64);
test("hides an address prefix already represented by an inline mention", () => {
assert.deepEqual(
getVisibleAgentAddressPubkeys("@Daria please review this", [DARIA], {
daria: DARIA,
}),
[],
);
});
test("keeps a tag-backed prefix when its inline mention was deleted", () => {
assert.deepEqual(
getVisibleAgentAddressPubkeys("please review this", [DARIA], {
daria: DARIA,
}),
[DARIA],
);
});
test("filters only addressed agents that are present inline", () => {
assert.deepEqual(
getVisibleAgentAddressPubkeys(
"@Daria please pair with someone",
[DARIA, RIZZ],
{
daria: DARIA,
rizz: RIZZ,
},
),
[RIZZ],
);
});

View File

@@ -0,0 +1,20 @@
import { orderMentionPubkeysByText } from "@/features/messages/lib/orderMentionPubkeys";
import { normalizePubkey } from "@/shared/lib/pubkey";
/**
* Keep tag-backed address recipients visible without repeating ordinary inline
* mentions already present in the message body.
*/
export function getVisibleAgentAddressPubkeys(
body: string,
addressedPubkeys: readonly string[],
mentionPubkeysByName: Readonly<Record<string, string>> | undefined,
): string[] {
const inlineMentionPubkeys = new Set(
orderMentionPubkeysByText(body, mentionPubkeysByName, () => true),
);
return addressedPubkeys.filter(
(pubkey) => !inlineMentionPubkeys.has(normalizePubkey(pubkey)),
);
}

View File

@@ -46,6 +46,7 @@ export type MentionCandidate = {
secondaryLabel?: string | null;
ownerPubkey?: string | null;
isAgent: boolean;
isActiveAgent?: boolean;
isManagedAgent?: boolean;
isGlobalSearchResult?: boolean;
};

View File

@@ -202,6 +202,7 @@ export function settleAutocompleteMentionInsert(
editor: { storage: object },
tr: Transaction,
text: string,
settleCaret = true,
): void {
const storage = mentionHighlightStorage(editor);
const mentionInsert = /(?:^|[\s(])([@#])([^\s]+) $/.exec(text);
@@ -222,7 +223,7 @@ export function settleAutocompleteMentionInsert(
}
}
}
tr.setMeta(mentionHighlightKey, true);
if (settleCaret) tr.setMeta(mentionHighlightKey, true);
}
export function syncMentionHighlightFromProps(

View File

@@ -1,7 +1,10 @@
import assert from "node:assert/strict";
import test from "node:test";
import { rankMentionCandidates } from "./mentionRanking.ts";
import {
pickDefaultAgentCandidate,
rankMentionCandidates,
} from "./mentionRanking.ts";
const CHANNEL_BRAIN_PUBKEY = "1".repeat(64);
const OTHER_BRAIN_PUBKEY = "2".repeat(64);
@@ -139,3 +142,108 @@ test("rankMentionCandidates: owned teams rank with runnable personas", () => {
["team", "identity"],
);
});
test("pickDefaultAgentCandidate: active agents outrank stopped channel members", () => {
const stoppedMember = candidate({
displayName: "Ada",
isActiveAgent: false,
isAgent: true,
isMember: true,
pubkey: CHANNEL_BRAIN_PUBKEY,
});
const runningNonMember = candidate({
displayName: "Bea",
isActiveAgent: true,
isAgent: true,
pubkey: OTHER_BRAIN_PUBKEY,
});
assert.equal(
pickDefaultAgentCandidate([stoppedMember, runningNonMember]),
runningNonMember,
);
});
test("pickDefaultAgentCandidate: stable labels break ties instead of roster order", () => {
const vogue = candidate({
displayName: "Vogue",
isActiveAgent: true,
isAgent: true,
isMember: true,
pubkey: OTHER_BRAIN_PUBKEY,
});
const morgarita = candidate({
displayName: "Morgarita",
isActiveAgent: true,
isAgent: true,
isMember: true,
pubkey: CHANNEL_BRAIN_PUBKEY,
});
assert.equal(pickDefaultAgentCandidate([vogue, morgarita]), morgarita);
assert.equal(pickDefaultAgentCandidate([morgarita, vogue]), morgarita);
});
test("pickDefaultAgentCandidate: runnable personas break otherwise equal ties", () => {
const plain = candidate({
displayName: "Zulu",
isActiveAgent: true,
isAgent: true,
pubkey: OTHER_BRAIN_PUBKEY,
});
const runnable = candidate({
displayName: "Zulu 2",
isActiveAgent: true,
isAgent: true,
personaId: "active-persona",
pubkey: CHANNEL_BRAIN_PUBKEY,
});
assert.equal(
pickDefaultAgentCandidate([plain, runnable], new Set(["active-persona"])),
runnable,
);
});
test("pickDefaultAgentCandidate: recent eligible mentions outrank the fallback ranking", () => {
const stoppedRecentMember = candidate({
displayName: "Ada",
isActiveAgent: false,
isAgent: true,
isMember: true,
pubkey: CHANNEL_BRAIN_PUBKEY,
});
const runningNonMember = candidate({
displayName: "Bea",
isActiveAgent: true,
isAgent: true,
pubkey: OTHER_BRAIN_PUBKEY,
});
assert.equal(
pickDefaultAgentCandidate(
[runningNonMember, stoppedRecentMember],
new Set(),
[CHANNEL_BRAIN_PUBKEY],
),
stoppedRecentMember,
);
});
test("pickDefaultAgentCandidate: skips recent pubkeys that are not eligible candidates", () => {
const runningAgent = candidate({
isActiveAgent: true,
isAgent: true,
pubkey: OTHER_BRAIN_PUBKEY,
});
assert.equal(
pickDefaultAgentCandidate([runningAgent], new Set(), ["f".repeat(64)]),
runningAgent,
);
});
test("pickDefaultAgentCandidate: returns null without an addressable agent", () => {
assert.equal(pickDefaultAgentCandidate([]), null);
assert.equal(pickDefaultAgentCandidate([candidate()]), null);
});

View File

@@ -3,6 +3,7 @@ import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
export type MentionCandidateForRanking = {
displayName: string | null;
isAgent: boolean;
isActiveAgent?: boolean;
isMember: boolean;
kind: "identity" | "persona" | "team";
personaId?: string | null;
@@ -51,6 +52,58 @@ function scoreMentionCandidateLabel(
return null;
}
export function pickDefaultAgentCandidate<T extends MentionCandidateForRanking>(
candidates: readonly T[],
activePersonaIds: ReadonlySet<string> = new Set(),
recentMentionPubkeys: readonly string[] = [],
): T | null {
const recentMentionRankByPubkey = new Map(
recentMentionPubkeys.map((pubkey, index) => [
normalizePubkey(pubkey),
index,
]),
);
return (
candidates
.filter((candidate) => candidate.isAgent && Boolean(candidate.pubkey))
.sort((left, right) => {
const leftRecentRank = left.pubkey
? recentMentionRankByPubkey.get(normalizePubkey(left.pubkey))
: undefined;
const rightRecentRank = right.pubkey
? recentMentionRankByPubkey.get(normalizePubkey(right.pubkey))
: undefined;
const recentDiff =
(leftRecentRank ?? recentMentionPubkeys.length) -
(rightRecentRank ?? recentMentionPubkeys.length);
if (recentDiff !== 0) return recentDiff;
const activeDiff =
Number(right.isActiveAgent === true) -
Number(left.isActiveAgent === true);
if (activeDiff !== 0) return activeDiff;
const memberDiff = Number(right.isMember) - Number(left.isMember);
if (memberDiff !== 0) return memberDiff;
const runnableDiff =
Number(
Boolean(right.personaId) &&
activePersonaIds.has(right.personaId ?? ""),
) -
Number(
Boolean(left.personaId) &&
activePersonaIds.has(left.personaId ?? ""),
);
if (runnableDiff !== 0) return runnableDiff;
const labelDiff = (left.displayName ?? "").localeCompare(
right.displayName ?? "",
undefined,
{ sensitivity: "base" },
);
if (labelDiff !== 0) return labelDiff;
return (left.pubkey ?? "").localeCompare(right.pubkey ?? "");
})[0] ?? null
);
}
export function rankMentionCandidates<T extends MentionCandidateForRanking>(
candidates: readonly T[],
query: string,

View File

@@ -3,7 +3,9 @@ import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { formatOwnerLabel } from "@/features/profile/lib/identity";
import type { ChannelRole, ChannelType } from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
import type { TeamMentionMember } from "./mentionCandidates";
import type { MentionCandidate, TeamMentionMember } from "./mentionCandidates";
import { mentionCandidateLabel } from "./mentionCandidates";
import { pickDefaultAgentCandidate } from "./mentionRanking";
export type MentionSuggestionCandidate = {
kind: "identity" | "persona" | "team";
@@ -74,3 +76,26 @@ export function mapMentionCandidateToSuggestion(opts: {
role: !candidate.isAgent && candidate.role === "admin" ? "admin" : null,
};
}
export function pickDefaultAgentSuggestion(opts: {
activePersonaIds: ReadonlySet<string>;
agentProvenanceReady: boolean;
candidates: readonly MentionCandidate[];
channelType?: ChannelType | null;
currentPubkey?: string | null;
ownerProfiles?: UserProfileLookup;
profiles?: UserProfileLookup;
recentMentionPubkeys?: readonly string[];
}): MentionSuggestion | null {
const candidate = pickDefaultAgentCandidate(
opts.candidates,
opts.activePersonaIds,
opts.recentMentionPubkeys,
);
if (!candidate) return null;
return mapMentionCandidateToSuggestion({
...opts,
candidate,
label: mentionCandidateLabel(candidate),
});
}

View File

@@ -0,0 +1,114 @@
import assert from "node:assert/strict";
import test from "node:test";
import { getRecentMentionPubkeys } from "./recentMentionPubkeys.ts";
const AUTHOR = "a".repeat(64);
const OLDER = "1".repeat(64);
const LATEST_FIRST = "2".repeat(64);
const LATEST_LAST = "3".repeat(64);
const REPLY_AUTHOR = "4".repeat(64);
function message(createdAt, tags) {
return {
id: String(createdAt),
createdAt,
pubkey: AUTHOR,
author: "Author",
time: "",
body: "",
depth: 0,
tags,
};
}
test("returns loaded channel mentions newest-first and excludes structural author tags", () => {
assert.deepEqual(
getRecentMentionPubkeys([
message(1, [
["p", AUTHOR],
["p", OLDER],
]),
message(2, [
["p", AUTHOR],
["p", LATEST_FIRST],
["p", LATEST_LAST],
]),
]),
[LATEST_LAST, LATEST_FIRST, OLDER],
);
});
test("keeps a top-level mention when the event omits its structural author tag", () => {
assert.deepEqual(
getRecentMentionPubkeys([message(1, [["p", LATEST_FIRST]])]),
[LATEST_FIRST],
);
});
test("filters desktop structural self-tags by identity", () => {
const parent = message(1, []);
assert.deepEqual(
getRecentMentionPubkeys([
parent,
{
...message(2, [
["p", REPLY_AUTHOR],
["p", LATEST_FIRST],
]),
id: "desktop-reply",
parentId: parent.id,
pubkey: REPLY_AUTHOR,
},
]),
[LATEST_FIRST],
);
});
test("keeps an sdk-shaped reply mention that matches the parent author", () => {
const parent = message(1, []);
assert.deepEqual(
getRecentMentionPubkeys([
parent,
{
...message(2, [["p", AUTHOR]]),
id: "sdk-reply",
parentId: parent.id,
pubkey: REPLY_AUTHOR,
},
]),
[AUTHOR],
);
});
test("ignores DM participant fan-out tags", () => {
assert.deepEqual(
getRecentMentionPubkeys(
[
message(1, [
["p", AUTHOR],
["p", LATEST_FIRST],
["p", LATEST_LAST],
]),
],
"dm",
),
[],
);
});
test("deduplicates repeated mentions at their newest position", () => {
assert.deepEqual(
getRecentMentionPubkeys([
message(1, [
["p", AUTHOR],
["p", LATEST_FIRST],
]),
message(2, [
["p", AUTHOR],
["p", LATEST_FIRST],
]),
]),
[LATEST_FIRST],
);
});

View File

@@ -0,0 +1,41 @@
import type { TimelineMessage } from "@/features/messages/types";
import type { ChannelType } from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
/**
* Return explicitly addressed pubkeys from the loaded channel window, newest
* first. DM `p` tags fan out to every participant and cannot distinguish inline
* mentions, so DMs deliberately fall back to the non-recency ranking ladder.
* Desktop-authored stream events may include the message author as a structural
* `p` tag, while SDK-authored events omit it. Filter by author identity rather
* than tag position so an SDK mention of a reply target remains eligible.
*/
export function getRecentMentionPubkeys(
messages: readonly TimelineMessage[],
channelType?: ChannelType | null,
): string[] {
if (channelType === "dm") return [];
const seen = new Set<string>();
const recent: string[] = [];
for (
let messageIndex = messages.length - 1;
messageIndex >= 0;
messageIndex -= 1
) {
const message = messages[messageIndex];
const authorPubkey = normalizePubkey(message.pubkey ?? "");
const tags = message.tags ?? [];
for (let tagIndex = tags.length - 1; tagIndex >= 0; tagIndex -= 1) {
const tag = tags[tagIndex];
if (tag[0] !== "p" || !tag[1]) continue;
const pubkey = normalizePubkey(tag[1]);
if (!pubkey || pubkey === authorPubkey || seen.has(pubkey)) continue;
seen.add(pubkey);
recent.push(pubkey);
}
}
return recent;
}

View File

@@ -0,0 +1,24 @@
import * as React from "react";
import type { ManagedAgent, RelayAgent } from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
export function useActiveAgentPubkeys(
managedAgents?: readonly ManagedAgent[],
relayAgents?: readonly RelayAgent[],
): ReadonlySet<string> {
return React.useMemo(
() =>
new Set([
...(managedAgents ?? [])
.filter(
(agent) =>
agent.status === "running" || agent.status === "deployed",
)
.map((agent) => normalizePubkey(agent.pubkey)),
...(relayAgents ?? [])
.filter((agent) => agent.status !== "offline")
.map((agent) => normalizePubkey(agent.pubkey)),
]),
[managedAgents, relayAgents],
);
}

View File

@@ -0,0 +1,50 @@
import * as React from "react";
import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomplete";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { ChannelType } from "@/shared/api/types";
import type { MentionCandidate } from "./mentionCandidates";
import { pickDefaultAgentSuggestion } from "./mentionSuggestionMapping";
export function useDefaultAgentSuggestion({
activePersonaIds,
agentProvenanceReady,
candidates,
channelType,
currentPubkey,
ownerProfiles,
profiles,
recentMentionPubkeys,
}: {
activePersonaIds: ReadonlySet<string>;
agentProvenanceReady: boolean;
candidates: readonly MentionCandidate[];
channelType?: ChannelType | null;
currentPubkey?: string | null;
ownerProfiles?: UserProfileLookup;
profiles?: UserProfileLookup;
recentMentionPubkeys?: readonly string[];
}): () => MentionSuggestion | null {
return React.useCallback(
() =>
pickDefaultAgentSuggestion({
activePersonaIds,
agentProvenanceReady,
candidates,
channelType,
currentPubkey,
ownerProfiles,
profiles,
recentMentionPubkeys,
}),
[
activePersonaIds,
agentProvenanceReady,
candidates,
channelType,
currentPubkey,
ownerProfiles,
profiles,
recentMentionPubkeys,
],
);
}

View File

@@ -37,6 +37,8 @@ import { detectPrefixQuery } from "@/shared/lib/detectPrefixQuery";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { channelMemberPubkeySet } from "@/shared/lib/rosterDerivations";
import { trimMapToSize } from "@/shared/lib/trimMapToSize";
import { useActiveAgentPubkeys } from "./useActiveAgentPubkeys";
import { useDefaultAgentSuggestion } from "./useDefaultAgentSuggestion";
import { flushMentionDebounce } from "./flushMentionDebounce";
import { useAgentMentionRevalidation } from "./agentMentionRevalidation";
import { extractMentionPubkeys } from "./extractMentionPubkeys";
@@ -61,9 +63,12 @@ import {
type MentionCandidate,
mentionCandidateLabel,
} from "./mentionCandidates";
const MENTION_DEBOUNCE_MS = 120;
const MENTION_SUGGESTION_LIMIT = 50;
type UseMentionsOptions = { channelType?: ChannelType | null };
const MENTION_DEBOUNCE_MS = 120,
MENTION_SUGGESTION_LIMIT = 50;
type UseMentionsOptions = {
channelType?: ChannelType | null;
recentMentionPubkeys?: readonly string[];
};
export function useMentions(
channelId: string | null,
externalMembers?: ChannelMember[],
@@ -170,6 +175,10 @@ export function useMentions(
),
[relayAgentsQuery.data],
);
const activeAgentPubkeys = useActiveAgentPubkeys(
managedAgentsQuery.data,
relayAgentsQuery.data,
);
const sharedChannelIds = React.useMemo(
() => getSharedChannelIds(channelsQuery.data),
[channelsQuery.data],
@@ -276,6 +285,7 @@ export function useMentions(
? (candidate.displayName ?? current.displayName)
: (current.displayName ?? candidate.displayName),
isAgent: current.isAgent || candidate.isAgent,
isActiveAgent: current.isActiveAgent || candidate.isActiveAgent,
isMember: current.isMember || candidate.isMember,
personaId: current.personaId ?? candidate.personaId,
personaName: current.personaName ?? candidate.personaName ?? null,
@@ -321,6 +331,7 @@ export function useMentions(
member.role === "bot" ||
managedAgentNamesByPubkey.has(pubkey) ||
relayAgentNamesByPubkey.has(pubkey),
isActiveAgent: activeAgentPubkeys.has(pubkey),
ownerPubkey: profile?.ownerPubkey ?? null,
personaName: personaNameByPubkey.get(pubkey) ?? null,
role: member.role,
@@ -342,6 +353,7 @@ export function useMentions(
(activePersonaById.has(pubkey) ? pubkey : undefined),
ownerPubkey: agent.ownerPubkey,
isAgent: true,
isActiveAgent: agent.status !== "offline",
});
}
for (const agent of managedAgentsQuery.data ?? []) {
@@ -351,6 +363,8 @@ export function useMentions(
displayName: agent.name,
isMember: false,
isAgent: true,
isActiveAgent:
agent.status === "running" || agent.status === "deployed",
isManagedAgent: true,
personaId: agent.personaId ?? undefined,
personaName:
@@ -406,6 +420,7 @@ export function useMentions(
);
}, [
activePersonaById,
activeAgentPubkeys,
activePersonas,
userSearchResults,
canSearchGlobalUsers,
@@ -534,6 +549,16 @@ export function useMentions(
ownerProfilesQuery.data?.profiles,
profiles,
]);
const getDefaultAgentSuggestion = useDefaultAgentSuggestion({
activePersonaIds,
agentProvenanceReady: agentDirectoriesReady,
candidates: mentionCandidates,
channelType: options?.channelType,
currentPubkey,
ownerProfiles: ownerProfilesQuery.data?.profiles,
profiles,
recentMentionPubkeys: options?.recentMentionPubkeys,
});
const fetchMoreSuggestions = React.useCallback(() => {
if (userSearchQuery.hasNextPage && !userSearchQuery.isFetchingNextPage) {
void userSearchQuery.fetchNextPage();
@@ -938,6 +963,7 @@ export function useMentions(
return {
cancelMentionAutocomplete,
clearMentions,
getDefaultAgentSuggestion,
extractMentionPersonas,
extractMentionPubkeys: extractMentionPubkeysForCurrentMentions,
revalidateMentionPubkeys,

View File

@@ -71,6 +71,8 @@ export type AutocompleteEdit = {
replaceFromOffset: number;
replaceToOffset: number;
insertText: string;
/** Keep the current selection mapped through this edit instead of moving it to the insertion. */
preserveSelection?: boolean;
/**
* When set, the replaced range becomes a CustomEmojiNode for this
* shortcode (followed by `insertText`, which carries the trailing space)
@@ -824,6 +826,7 @@ export function useRichTextEditor({
toOffset: number,
text: string,
customEmojiShortcode?: string,
preserveSelection = false,
) => {
if (!editor) return;
const projection = buildPlainTextProjection(editor.state.doc);
@@ -856,19 +859,23 @@ export function useRichTextEditor({
}
const tr = editor.state.tr.insertText(text, fromPM, toPM);
// Place cursor at the end of the inserted text. We map `toPM` (the
// right end of the replaced range) through the transaction's
// mapping — that's the post-transaction position right after the
// inserted text, valid even if mark normalisation shifted things.
// (Mapping `fromPM + text.length` directly would be a pre-image
// position that may not exist in the original doc, which throws
// "Position N out of range".)
const cursorPM = tr.mapping.map(toPM);
tr.setSelection(TextSelection.create(tr.doc, cursorPM));
settleAutocompleteMentionInsert(editor, tr, text);
if (preserveSelection) {
tr.setSelection(editor.state.selection.map(tr.doc, tr.mapping));
} else {
// Place cursor at the end of the inserted text. We map `toPM` (the
// right end of the replaced range) through the transaction's
// mapping — that's the post-transaction position right after the
// inserted text, valid even if mark normalisation shifted things.
// (Mapping `fromPM + text.length` directly would be a pre-image
// position that may not exist in the original doc, which throws
// "Position N out of range".)
const cursorPM = tr.mapping.map(toPM);
tr.setSelection(TextSelection.create(tr.doc, cursorPM));
}
settleAutocompleteMentionInsert(editor, tr, text, !preserveSelection);
editor.view.dispatch(tr);
editor.view.focus();
reassertMentionCaretAfterFocus(editor.view);
if (!preserveSelection) reassertMentionCaretAfterFocus(editor.view);
},
[editor, customEmojiWiring.resolveUrl],
);

View File

@@ -78,9 +78,8 @@ test("mention control expands with automatically mentioned agents", async () =>
assert.match(manage.className, /(?:^|\s)pl-2(?:\s|$)/);
assert.match(manage.parentElement?.className ?? "", /(?:^|\s)pl-2(?:\s|$)/);
assert.match(
view.getByRole("button", { name: "Manage automatic agent mentions" })
.parentElement?.className ?? "",
/(?:^|\s)pr-1(?:\s|$)/,
manage.parentElement?.className ?? "",
/(?:^|\s)pr-1\.5(?:\s|$)/,
);
assert.match(
view.getByRole("button", { name: "Manage automatic agent mentions" })
@@ -111,17 +110,21 @@ test("mention control expands with automatically mentioned agents", async () =>
/scale\(0.8\)/,
);
}
const remove = view.getByRole("button", {
name: "Stop automatically mentioning Agent Ada",
});
const remove = view.getByTestId("composer-address-lock-remove-agent-pubkey");
assert.match(
remove.querySelector("span.absolute")?.className ?? "",
/group-hover\/address:opacity-100/,
);
fireEvent.click(remove);
assert.deepEqual(removed, ["agent-pubkey"]);
fireEvent.click(
view.getByRole("button", { name: "Manage automatic agent mentions" }),
view.rerender(renderButton([]));
const exitingLocks = view.getByTestId("composer-address-locks");
assert.match(exitingLocks.className, /(?:^|\s)overflow-hidden(?:\s|$)/);
assert.match(
view.getByRole("button", { name: "Mention someone" }).parentElement
?.className ?? "",
/(?:^|\s)pr-1\.5(?:\s|$)/,
);
fireEvent.click(view.getByRole("button", { name: "Mention someone" }));
assert.equal(opened, 1);
});

View File

@@ -9,6 +9,7 @@ import * as React from "react";
import { cn } from "@/shared/lib/cn";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
export type ComposerAddressAgent = {
@@ -58,7 +59,7 @@ function AddressedAgentAvatar({
return (
<motion.span
animate={controls}
className="relative block h-5 w-5 shrink-0"
className="relative block h-4.5 w-4.5 shrink-0"
data-pulse-version={pulseVersion}
data-shake-version={shakeVersion}
data-testid={`composer-address-lock-${agent.pubkey}`}
@@ -66,7 +67,7 @@ function AddressedAgentAvatar({
>
<UserAvatar
avatarUrl={agent.avatarUrl}
className="h-5 w-5"
className="h-4.5 w-4.5"
displayName={agent.displayName}
size="xs"
testId="composer-address-lock-avatar"
@@ -125,7 +126,10 @@ type AddressAgentsProps = {
export function ComposerMentionButton({
agents,
confirmationTitle,
disabled,
onConfirmationDismiss,
onConfirmationTurnOff,
onCaptureSelection,
onOpen,
onRemove,
@@ -133,7 +137,10 @@ export function ComposerMentionButton({
shakeVersionByPubkey = {},
showAgents,
}: AddressAgentsProps & {
confirmationTitle?: string | null;
disabled: boolean;
onConfirmationDismiss?: () => void;
onConfirmationTurnOff?: () => void;
onCaptureSelection: () => void;
onOpen: () => void;
onRemove: (pubkey: string) => void;
@@ -142,89 +149,161 @@ export function ComposerMentionButton({
const visibleAgents = showAgents ? agents.slice(0, VISIBLE_AGENT_LIMIT) : [];
const hiddenCount = showAgents ? agents.length - visibleAgents.length : 0;
const hasAgents = visibleAgents.length > 0;
const shouldReduceMotion = useReducedMotion();
const [showActiveChrome, setShowActiveChrome] = React.useState(hasAgents);
const newlyAddedAgentPubkeys = useNewlyAddedAgentPubkeys(visibleAgents);
React.useEffect(() => {
if (hasAgents) setShowActiveChrome(true);
}, [hasAgents]);
return (
<div
className={cn(
"flex h-8 min-w-8 items-center justify-center rounded-lg transition-colors",
hasAgents
? "gap-1.5 bg-primary/15 pl-2 pr-1 text-primary hover:bg-primary/25 hover:text-primary/90"
: "text-foreground",
)}
<Popover
modal={false}
onOpenChange={(open) => {
if (!open) onConfirmationDismiss?.();
}}
open={Boolean(confirmationTitle)}
>
<Tooltip disableHoverableContent>
<TooltipTrigger asChild>
<PopoverAnchor asChild>
<div
className={cn(
"flex h-8 min-w-8 items-center justify-center rounded-lg transition-colors",
showActiveChrome
? "gap-1.5 bg-primary/15 pl-2 pr-1.5 text-primary hover:bg-primary/25 hover:text-primary/90"
: "text-foreground",
)}
>
<Tooltip disableHoverableContent>
<TooltipTrigger asChild>
<button
aria-label={
hasAgents
? "Manage automatic agent mentions"
: "Mention someone"
}
className={cn(
"flex h-8 items-center justify-center rounded-lg focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
showActiveChrome
? "-ml-2 w-6 rounded-l-lg rounded-r-sm pl-2"
: "w-8 hover:bg-accent hover:text-accent-foreground",
)}
data-mention-picker-trigger=""
data-testid="message-insert-mention"
disabled={disabled}
onClick={onOpen}
onMouseDown={onCaptureSelection}
type="button"
>
<AtSign aria-hidden="true" className="h-4 w-4 shrink-0" />
</button>
</TooltipTrigger>
<TooltipContent>
{hasAgents
? "Manage automatic agent mentions"
: "Mention someone"}
</TooltipContent>
</Tooltip>
<AnimatePresence
initial={false}
onExitComplete={() => {
if (!hasAgents) setShowActiveChrome(false);
}}
>
{hasAgents ? (
<motion.span
animate={{ opacity: 1, width: "auto" }}
className="flex items-center gap-1 overflow-hidden"
data-testid="composer-address-locks"
exit={{ opacity: 0, width: 0 }}
initial={false}
transition={
shouldReduceMotion
? { duration: 0 }
: { duration: 0.18, ease: "easeOut" }
}
>
<AnimatePresence mode="popLayout">
{visibleAgents.map((agent) => (
<Tooltip disableHoverableContent key={agent.pubkey}>
<TooltipTrigger asChild>
<motion.button
aria-label={`Stop automatically mentioning ${agent.displayName}`}
animate={{ opacity: 1, scale: 1 }}
className="group/address relative rounded-full focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
data-testid={`composer-address-lock-remove-${agent.pubkey}`}
disabled={disabled}
exit={
shouldReduceMotion
? { opacity: 0 }
: { opacity: 0, scale: 0.8 }
}
initial={
newlyAddedAgentPubkeys.has(agent.pubkey)
? shouldReduceMotion
? { opacity: 0 }
: { opacity: 0, scale: 0.8 }
: false
}
layout={!shouldReduceMotion}
onClick={() => onRemove(agent.pubkey)}
transition={
shouldReduceMotion
? { duration: 0 }
: addressEntryTransition
}
type="button"
>
<AddressedAgentAvatar
agent={agent}
pulseVersion={
pulseVersionByPubkey[agent.pubkey] ?? 0
}
shakeVersion={
shakeVersionByPubkey[agent.pubkey] ?? 0
}
/>
<span className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-full bg-foreground/80 text-background opacity-0 transition-opacity group-hover/address:opacity-100 group-focus-visible/address:opacity-100">
<X aria-hidden="true" className="h-3 w-3" />
</span>
</motion.button>
</TooltipTrigger>
<TooltipContent>
Stop automatically mentioning {agent.displayName}
</TooltipContent>
</Tooltip>
))}
</AnimatePresence>
<RemainingAgentCount count={hiddenCount} />
</motion.span>
) : null}
</AnimatePresence>
</div>
</PopoverAnchor>
{confirmationTitle ? (
<PopoverContent
align="center"
aria-live="polite"
className="flex max-w-[calc(100vw-2rem)] items-center gap-2 rounded-lg px-2.5 py-1.5 text-xs"
collisionPadding={8}
data-testid="composer-auto-pin-confirmation"
onCloseAutoFocus={(event) => event.preventDefault()}
onOpenAutoFocus={(event) => event.preventDefault()}
side="right"
sideOffset={8}
style={{ width: "max-content" }}
>
<span className="whitespace-nowrap">{confirmationTitle}</span>
<button
aria-label={
hasAgents ? "Manage automatic agent mentions" : "Mention someone"
}
className={cn(
"flex h-8 items-center justify-center rounded-lg focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
hasAgents
? "-ml-2 w-6 rounded-l-lg rounded-r-sm pl-2"
: "w-8 hover:bg-accent hover:text-accent-foreground",
)}
data-mention-picker-trigger=""
data-testid="message-insert-mention"
disabled={disabled}
onClick={onOpen}
onMouseDown={onCaptureSelection}
className="shrink-0 rounded-md px-1.5 py-1 font-medium text-primary outline-hidden hover:bg-primary/10 focus-visible:ring-1 focus-visible:ring-ring"
onClick={onConfirmationTurnOff}
type="button"
>
<AtSign aria-hidden="true" className="h-4 w-4 shrink-0" />
Turn off
</button>
</TooltipTrigger>
<TooltipContent>
{hasAgents ? "Manage automatic agent mentions" : "Mention someone"}
</TooltipContent>
</Tooltip>
{hasAgents ? (
<span
className="flex items-center gap-1"
data-testid="composer-address-locks"
>
<AnimatePresence mode="popLayout">
{visibleAgents.map((agent) => (
<Tooltip disableHoverableContent key={agent.pubkey}>
<TooltipTrigger asChild>
<motion.button
aria-label={`Stop automatically mentioning ${agent.displayName}`}
animate={{ opacity: 1, scale: 1 }}
className="group/address relative rounded-full focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
data-testid={`composer-address-lock-remove-${agent.pubkey}`}
disabled={disabled}
exit={{ opacity: 0, scale: 0.8 }}
initial={
newlyAddedAgentPubkeys.has(agent.pubkey)
? { opacity: 0, scale: 0.8 }
: false
}
layout
onClick={() => onRemove(agent.pubkey)}
transition={addressEntryTransition}
type="button"
>
<AddressedAgentAvatar
agent={agent}
pulseVersion={pulseVersionByPubkey[agent.pubkey] ?? 0}
shakeVersion={shakeVersionByPubkey[agent.pubkey] ?? 0}
/>
<span className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-full bg-foreground/80 text-background opacity-0 transition-opacity group-hover/address:opacity-100 group-focus-visible/address:opacity-100">
<X aria-hidden="true" className="h-3 w-3" />
</span>
</motion.button>
</TooltipTrigger>
<TooltipContent>
Stop automatically mentioning {agent.displayName}
</TooltipContent>
</Tooltip>
))}
</AnimatePresence>
<RemainingAgentCount count={hiddenCount} />
</span>
</PopoverContent>
) : null}
</div>
</Popover>
);
}

View File

@@ -4,7 +4,6 @@ import {
useChannelLinks,
type ChannelSuggestion,
} from "@/features/messages/lib/useChannelLinks";
import { handleAgentSnapshotPaste } from "@/features/messages/lib/agentSnapshotClipboard";
import { useComposerAutofocus } from "@/features/messages/lib/useComposerAutofocus";
import { useDrafts } from "@/features/messages/lib/useDrafts";
import { resolveSentDraftKey } from "@/features/messages/ui/draftSubmitKey";
@@ -29,7 +28,6 @@ import {
} from "@/features/messages/lib/backgroundMediaUploadStore";
import { useMentions } from "@/features/messages/lib/useMentions";
import {
getPersistentAgentAudienceRevision,
getPersistentAgentAudienceScope,
usePersistentAgentAudience,
} from "@/features/messages/lib/persistentAgentAudience";
@@ -38,10 +36,6 @@ import {
useKeepMentionedAgentsPinned,
} from "@/features/messages/lib/autoPinMentionedAgentsPreference";
import { useIdentityQuery } from "@/shared/api/hooks";
import {
hasMentionClipboardHtml,
normalizeMentionClipboardHtml,
} from "@/features/messages/lib/normalizeMentionClipboard";
import { CUSTOM_EMOJI_NODE_NAME } from "@/features/messages/lib/customEmojiNode";
import {
type AutocompleteEdit,
@@ -51,7 +45,6 @@ import {
import { useLinkEditor } from "@/features/messages/lib/useLinkEditor";
import { useComposerSpoilerParticles } from "@/features/messages/lib/useComposerSpoilerParticles";
import { useTypingBroadcast } from "@/features/messages/useTypingBroadcast";
import { getBuzzCodeBlockClipboardText } from "@/shared/lib/codeBlockClipboard";
import { cn } from "@/shared/lib/cn";
import { ChannelAutocomplete } from "./ChannelAutocomplete";
import { ComposerReplyEditBanner } from "./ComposerReplyEditBanner";
@@ -68,6 +61,7 @@ import { useAlwaysAddressShortcut } from "./useAlwaysAddressShortcut";
import { useComposerMentionPicker } from "./useComposerMentionPicker";
import { useAutoPinMentionedAgents } from "./useAutoPinMentionedAgents";
import { useComposerContentState } from "./useComposerContentState";
import { useComposerPasteHandler } from "./useComposerPasteHandler";
import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot";
import { submitMessageEdit } from "./submitMessageEdit";
import { prepareBackgroundLinkPreviews } from "@/features/messages/lib/linkPreviewPreparationStore";
@@ -98,6 +92,7 @@ function MessageComposerImpl({
onSend,
placeholder,
profiles,
recentMentionPubkeys,
replyTarget = null,
mediaController,
showBackgroundUploadProgress = true,
@@ -122,8 +117,6 @@ function MessageComposerImpl({
} = useComposerLinkPreviews(previewContent, editTarget == null);
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = React.useState(false);
const [isFormattingOpen, setIsFormattingOpen] = React.useState(false);
const [mentionOptionsOpenRequest, setMentionOptionsOpenRequest] =
React.useState(0);
const [spoileredAttachmentUrls, setSpoileredAttachmentUrls] = React.useState<
Set<string>
>(() => new Set());
@@ -154,6 +147,7 @@ function MessageComposerImpl({
} | null>(null);
const mentions = useMentions(channelId, undefined, profiles, {
channelType,
recentMentionPubkeys,
});
const channelLinks = useChannelLinks();
const customEmoji = useCustomEmoji();
@@ -213,12 +207,16 @@ function MessageComposerImpl({
const isSendingRef = React.useRef(isSending);
const isUploadingRef = React.useRef(media.isUploading);
const isSubmitLockedRef = React.useRef(false);
const [isSubmitLocked, setIsSubmitLocked] = React.useState(false);
const onSendRef = React.useRef(onSend);
const onEditSaveRef = React.useRef(onEditSave);
const onEditLastOwnMessageRef = React.useRef(onEditLastOwnMessage);
const editTargetRef = React.useRef(editTarget);
const extractMentionPubkeysRef = React.useRef(mentions.extractMentionPubkeys);
const ownerPubkeyRef = React.useRef(ownerPubkey);
const syncAddressedAgentsFromTextRef = React.useRef<(text: string) => void>(
() => {},
);
disabledRef.current = disabled;
isSendingRef.current = isSending;
isUploadingRef.current = media.isUploading;
@@ -276,6 +274,9 @@ function MessageComposerImpl({
onUpdate: ({ cursor, linkPreviewContent, text }) => {
setComposerContentFromText(text);
setPreviewContent(linkPreviewContent);
if (!isSubmitLockedRef.current && !editTargetRef.current) {
syncAddressedAgentsFromTextRef.current(text);
}
mentions.updateMentionQuery(text, cursor);
channelLinks.updateChannelQuery(text, cursor);
emojiAutocomplete.updateEmojiQuery(text, cursor);
@@ -297,14 +298,38 @@ function MessageComposerImpl({
const persistentAudience = usePersistentAgentAudience(audienceScope);
const keepMentionedAgentsPinned = useKeepMentionedAgentsPinned();
const addressPulse = useAddressMentionPulse();
const openMentionOptionsRef = React.useRef<() => void>(() => {});
const addInlineAgentMentionsToAudience = useAutoPinMentionedAgents({
const {
confirmationTitle: autoPinConfirmationTitle,
dismissConfirmation: dismissAutoPinConfirmation,
promoteExplicitlyAddressedAgents,
promoteMentionedAgents,
turnOffConfirmation: turnOffAutoPinConfirmation,
} = useAutoPinMentionedAgents({
audienceScope,
enabled: keepMentionedAgentsPinned,
getDisplayName: mentions.getMentionDisplayName,
onOpenOptions: () => openMentionOptionsRef.current(),
onPulse: addressPulse.pulseOne,
onTurnOff: () => setKeepMentionedAgentsPinned(false),
});
const restoreAddressedAgentMentionsRef = React.useRef<
(
pubkeys?: readonly string[],
allowedUnpinnedPubkeys?: readonly string[],
) => string
>(() => "");
const restoreAddressedAgentMentionsFrameRef = React.useRef<number | null>(
null,
);
const channelIdRef = React.useRef(channelId);
channelIdRef.current = channelId;
React.useEffect(
() => () => {
if (restoreAddressedAgentMentionsFrameRef.current !== null) {
cancelAnimationFrame(restoreAddressedAgentMentionsFrameRef.current);
}
},
[],
);
const mentionSendFlow = useMentionSendFlow({
channelId,
channelLinks,
@@ -315,8 +340,23 @@ function MessageComposerImpl({
emojiAutocomplete,
mentions,
onAddressedAgentsSendStarted: addressPulse.pulseMany,
onAddressedAgentsComposerCleared: (pubkeys) =>
restoreAddressedAgentMentionsRef.current(pubkeys),
onAddressedAgentsSendFailed: addressPulse.shakeMany,
onInlineAgentMentionsSent: addInlineAgentMentionsToAudience,
onAddressedAgentsSendSucceeded: (pubkeys, newlyPinnedPubkeys) => {
if (newlyPinnedPubkeys.length === 0) return;
const sentChannelId = channelId;
if (restoreAddressedAgentMentionsFrameRef.current !== null) {
cancelAnimationFrame(restoreAddressedAgentMentionsFrameRef.current);
}
restoreAddressedAgentMentionsFrameRef.current = requestAnimationFrame(
() => {
restoreAddressedAgentMentionsFrameRef.current = null;
if (channelIdRef.current !== sentChannelId) return;
restoreAddressedAgentMentionsRef.current(pubkeys, newlyPinnedPubkeys);
},
);
},
onPrepareSendChannel,
onSendRef,
richText,
@@ -394,6 +434,7 @@ function MessageComposerImpl({
edit.replaceToOffset,
edit.insertText,
edit.customEmojiShortcode,
edit.preserveSelection,
);
},
[richText.replacePlainTextRange],
@@ -403,17 +444,30 @@ function MessageComposerImpl({
lockedAgents,
lockedAgentPubkeys,
removeAddressedAgent,
restoreAddressedAgentMentions,
selectMentionSuggestion,
syncAddressedAgentsFromText,
toggleAlwaysAddressAgent,
} = useAgentAddressLockPicker({
applyAutocompleteEdit,
audience: persistentAudience,
audienceScope,
mentions,
onAddressAgentMention: (suggestion) =>
promoteExplicitlyAddressedAgents({
pubkeys: suggestion.pubkey ? [suggestion.pubkey] : [],
}),
onAutoPinAgentMention: (suggestion) => {
promoteMentionedAgents({
pubkeys: suggestion.pubkey ? [suggestion.pubkey] : [],
});
},
onPulseAddressLock: addressPulse.pulseOne,
profiles,
richText,
});
restoreAddressedAgentMentionsRef.current = restoreAddressedAgentMentions;
syncAddressedAgentsFromTextRef.current = syncAddressedAgentsFromText;
const applyChannelInsert = React.useCallback(
(suggestion: ChannelSuggestion) => {
const { cursor } = richText.getPlainTextAndCursor();
@@ -481,15 +535,12 @@ function MessageComposerImpl({
() => openMentionPicker(false),
[openMentionPicker],
);
const openMentionOptions = React.useCallback(() => {
openMentionSettings();
setMentionOptionsOpenRequest((request) => request + 1);
}, [openMentionSettings]);
openMentionOptionsRef.current = openMentionOptions;
const handleAlwaysAddressShortcut = useAlwaysAddressShortcut({
enabled: Boolean(audienceScope && editTarget == null),
lockedAgent: lockedAgents[0],
mentions,
onOpenPicker: openMentionPicker,
onSelect: selectMentionSuggestion,
onToggle: toggleAlwaysAddressAgent,
});
const submitMessage = React.useCallback(async () => {
@@ -561,6 +612,7 @@ function MessageComposerImpl({
return;
}
isSubmitLockedRef.current = true;
setIsSubmitLocked(true);
onPreparingMentionSendChange?.(true);
try {
const preparedLinkPreviews = getReadyLinkPreviewTags().some(
@@ -570,9 +622,6 @@ function MessageComposerImpl({
: prepareBackgroundLinkPreviews(getLiveLinkPreviewCandidates());
await mentionSendFlow.sendMessageWithMentionFlow({
addressedAgentPubkeys: persistentAudience.pubkeys,
audienceRevision: audienceScope
? getPersistentAgentAudienceRevision(audienceScope)
: 0,
capturedChannelId: channelId,
capturedThreadContext,
pendingImeta: currentPendingImeta,
@@ -589,6 +638,7 @@ function MessageComposerImpl({
});
} finally {
isSubmitLockedRef.current = false;
setIsSubmitLocked(false);
onPreparingMentionSendChange?.(false);
}
}, [
@@ -615,7 +665,6 @@ function MessageComposerImpl({
syncComposerContentFromEditor,
onCaptureSendContext,
onPreparingMentionSendChange,
audienceScope,
persistentAudience.pubkeys,
isEditSubmissionLocked,
effectiveDraftKey,
@@ -713,74 +762,12 @@ function MessageComposerImpl({
onCancelEdit,
],
);
// ── Media paste + ⌘K link shortcut via Tiptap editorProps ──────────
const uploadFileRef = React.useRef(media.uploadFile);
uploadFileRef.current = media.uploadFile;
React.useEffect(() => {
if (!richText.editor) return;
richText.editor.setOptions({
editorProps: {
...richText.editor.options.editorProps,
handlePaste: (_view, event) => {
// --- File paste ---
// Any actual file (image, video, document, …) pastes as an
// attachment. String/text items have kind "string", so plain-text
// and code-block paste fall through to the handlers below.
const items = Array.from(event.clipboardData?.items ?? []);
const mediaItem = items.find((item) => item.kind === "file");
if (mediaItem) {
const file = mediaItem.getAsFile();
if (file) {
void uploadFileRef.current(file);
}
return true;
}
// --- Buzz code-block paste ---
// The code block copy button writes a small Buzz marker alongside
// plain text. Use it to paste back as a literal code block so Markdown
// parsing cannot reshape indentation, fence markers, or headings.
const codeBlockText = getBuzzCodeBlockClipboardText(
event.clipboardData,
);
if (codeBlockText !== null) {
event.preventDefault();
richText.editor
?.chain()
.focus()
.insertContent([
{
type: "codeBlock",
content:
codeBlockText.length > 0
? [{ type: "text", text: codeBlockText }]
: [],
},
{ type: "paragraph" },
])
.run();
scrollComposerToBottom();
return true;
}
// Restore Buzz snapshots before normal styled-HTML normalization.
if (handleAgentSnapshotPaste(event, media.setPendingImeta))
return true;
// Strip mention/channel wrappers that Tiptap would misread as bold.
const html = event.clipboardData?.getData("text/html");
if (html && hasMentionClipboardHtml(html)) {
const cleanHtml = normalizeMentionClipboardHtml(html);
event.preventDefault();
_view.pasteHTML(cleanHtml);
return true;
}
const plainText = event.clipboardData?.getData("text/plain") ?? "";
if (plainText.includes("\n")) {
scrollComposerToBottom();
}
return false;
},
},
});
}, [media.setPendingImeta, richText.editor, scrollComposerToBottom]);
useComposerPasteHandler({
editor: richText.editor,
scrollToBottom: scrollComposerToBottom,
setPendingImeta: media.setPendingImeta,
uploadFile: media.uploadFile,
});
// ── Send button state ───────────────────────────────────────────────
const sendDisabled =
composerDisabled ||
@@ -858,6 +845,7 @@ function MessageComposerImpl({
layoutMode === "standalone" &&
"backdrop-blur-md dark:backdrop-blur-xl",
)}
data-submit-locked={isSubmitLocked ? "true" : "false"}
data-testid="message-composer"
onDragEnter={ownsDropZone ? media.handleDragEnter : undefined}
onDragLeave={ownsDropZone ? media.handleDragLeave : undefined}
@@ -904,7 +892,6 @@ function MessageComposerImpl({
? setKeepMentionedAgentsPinned
: undefined
}
openOptionsRequest={mentionOptionsOpenRequest}
onToggleAlwaysAddressAgent={
audienceScope && editTarget == null
? toggleAlwaysAddressAgent
@@ -973,6 +960,7 @@ function MessageComposerImpl({
</div>
<ComposerDockToolbar
addressedAgents={editTarget == null ? lockedAgents : []}
autoPinConfirmationTitle={autoPinConfirmationTitle}
layoutMode={layoutMode}
composerDisabled={composerDisabled}
editor={richText.editor}
@@ -983,6 +971,8 @@ function MessageComposerImpl({
isSending={isSending || mentionSendFlow.isPreparingMentionSend}
isUploading={media.isUploading}
onCaptureSelection={handleCaptureSelection}
onAutoPinConfirmationDismiss={dismissAutoPinConfirmation}
onAutoPinConfirmationTurnOff={turnOffAutoPinConfirmation}
onEmojiPickerOpenChange={setIsEmojiPickerOpen}
onEmojiSelect={insertEmoji}
onFormattingToggle={handleFormattingToggle}

View File

@@ -95,6 +95,8 @@ export type MessageComposerProps = {
) => Promise<void>;
placeholder?: string;
profiles?: UserProfileLookup;
/** Explicit mention pubkeys from the loaded channel window, newest first. */
recentMentionPubkeys?: readonly string[];
replyTarget?: {
author: string;
body: string;

View File

@@ -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(
>
<ComposerMentionButton
agents={addressedAgents}
confirmationTitle={autoPinConfirmationTitle}
disabled={composerDisabled}
onConfirmationDismiss={onAutoPinConfirmationDismiss}
onConfirmationTurnOff={onAutoPinConfirmationTurnOff}
onCaptureSelection={onCaptureSelection}
onOpen={onOpenMentionPicker}
onRemove={onRemoveAddressedAgent}

View File

@@ -1,6 +1,5 @@
import * as React from "react";
import { AlertTriangle } from "lucide-react";
import {
depthGuideActionsEqual,
numberArrayEqual,
@@ -59,6 +58,7 @@ import { SentFromThreadLine } from "./SentFromThreadLine";
import { WaveMessageAttachment } from "./WaveMessageAttachment";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { getAgentAddressMentionPubkeys } from "@/features/messages/lib/agentAddressMention.mjs";
import { getVisibleAgentAddressPubkeys } from "@/features/messages/lib/getVisibleAgentAddressPubkeys";
import { MessageAgentAddressPrefix } from "./MessageAgentAddressPrefix";
const DiffMessage = React.lazy(() => 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 ? (
<MessageAgentAddressPrefix

View File

@@ -106,6 +106,7 @@ type MessageThreadPanelProps = ThreadPanelLayoutProps & {
remove: boolean,
) => Promise<void>;
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,
@@ -510,8 +512,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. */}
<div
className={cn(hasConstrainedColumn && THREAD_PANEL_COLUMN_CLASS)}
data-image-gallery-scope="thread"
ref={threadContentRef}
style={
hasConstrainedColumn ? { maxWidth: columnMaxWidthPx } : undefined
@@ -851,6 +857,7 @@ export function MessageThreadPanel({
: `Reply in thread to ${threadHead.author}`
}
profiles={profiles}
recentMentionPubkeys={recentMentionPubkeys}
replyTarget={composerReplyTarget}
typingParentEventId={threadHead.id}
typingRootEventId={threadHead.rootId}

View File

@@ -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);
});

View File

@@ -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({

View File

@@ -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<typeof usePersistentAgentAudience>;
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<string, string>());
const visibleAgentMentionPubkeysRef = React.useRef(new Set<string>());
const mentionSyncScopeRef = React.useRef(audienceScope);
if (mentionSyncScopeRef.current !== audienceScope) {
mentionSyncScopeRef.current = audienceScope;
visibleAgentMentionPubkeysRef.current.clear();
}
const [announcement, setAnnouncement] = React.useState("");
const lockedAgents = React.useMemo<ComposerAddressAgent[]>(
() =>
@@ -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,
};
}

View File

@@ -6,21 +6,30 @@ import type { MentionSuggestion } from "./MentionAutocomplete";
export function useAlwaysAddressShortcut({
enabled,
lockedAgent,
mentions,
onOpenPicker,
onSelect,
onToggle,
}: {
enabled: boolean;
lockedAgent?: Pick<MentionSuggestion, "avatarUrl" | "displayName" | "pubkey">;
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,
],

View File

@@ -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<Confirmation | null>(
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,
};
}

View File

@@ -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<unknown>;
}) {
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]);
}

View File

@@ -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[];

View File

@@ -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<UseChannelLinksResult, "clearChannels">;
channelType: ChannelType | null;
contentRef: React.MutableRefObject<string>;
customEmoji: CustomEmoji[];
drafts: Pick<UseDraftsResult, "loadDraft" | "markDraftSent" | "persistDraft">;
emojiAutocomplete: Pick<UseEmojiAutocompleteResult, "clearEmojis">;
mentions: UseMentionsResult;
onPrepareSendChannel?: (pubkeys?: string[]) => Promise<string | null>;
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<void>
>;
richText: Pick<UseRichTextEditorResult, "clearContent" | "setContent">;
setContent: (content: string) => void;
setIsEmojiPickerOpen: React.Dispatch<React.SetStateAction<boolean>>;
setPendingImeta: (pendingImeta: ImetaMedia[]) => void;
hasUnsavedMedia: () => boolean;
clearQueuedAttachments: () => void;
restoreQueuedAttachments: (attachments: QueuedMediaAttachment[]) => void;
setSpoileredAttachmentUrls?: React.Dispatch<
React.SetStateAction<Set<string>>
>;
};
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<void>((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)

View File

@@ -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<UseChannelLinksResult, "clearChannels">;
channelType: ChannelType | null;
contentRef: React.MutableRefObject<string>;
customEmoji: CustomEmoji[];
drafts: Pick<UseDraftsResult, "loadDraft" | "markDraftSent" | "persistDraft">;
emojiAutocomplete: Pick<UseEmojiAutocompleteResult, "clearEmojis">;
mentions: UseMentionsResult;
onPrepareSendChannel?: (pubkeys?: string[]) => Promise<string | null>;
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<void>
>;
richText: Pick<UseRichTextEditorResult, "clearContent" | "setContent">;
setContent: (content: string) => void;
setIsEmojiPickerOpen: React.Dispatch<React.SetStateAction<boolean>>;
setPendingImeta: (pendingImeta: ImetaMedia[]) => void;
hasUnsavedMedia: () => boolean;
clearQueuedAttachments: () => void;
restoreQueuedAttachments: (attachments: QueuedMediaAttachment[]) => void;
setSpoileredAttachmentUrls?: React.Dispatch<
React.SetStateAction<Set<string>>
>;
};

View File

@@ -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",
},
{

View File

@@ -43,6 +43,7 @@ import {
markdownPropsAreEqual,
} from "./markdownUtils";
import { ImageMosaic } from "./markdown/ImageMosaic";
import { ImageGalleryStatus } from "./markdown/ImageGalleryStatus";
import { ImageLightboxZoomControls } from "./markdown/ImageLightboxZoomControls";
import {
CODE_BLOCK_CLASS,
@@ -145,12 +146,30 @@ type WebKitGestureLikeEvent = Event & {
scale?: number;
};
function copyImageToClipboard(src: string | undefined) {
if (!src) return;
invokeTauri("copy_image_to_clipboard", { url: src })
.then(() => {
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(
<div
@@ -859,7 +876,7 @@ function ImageZoomOverlay({
custom={galleryDirection}
exit="exit"
initial="enter"
key={currentItem.resolvedSrc}
key={`${currentIndex}:${currentItem.resolvedSrc}`}
src={currentItem.resolvedSrc}
transition={{
duration: prefersReducedMotion
@@ -936,7 +953,7 @@ function ImageZoomOverlay({
type="button"
onClick={(event) => {
event.stopPropagation();
onDownload(currentItem.src);
downloadImage(currentItem.src);
}}
>
<Download className="h-4 w-4" />
@@ -952,6 +969,7 @@ function ImageZoomOverlay({
updateZoom={updateZoom}
zoom={zoom}
/>
<ImageGalleryStatus {...{ currentIndex, itemCount: items.length }} />
</div>
</div>
{menu && canActOnCurrentImage ? (
@@ -1000,7 +1018,6 @@ function ImageBlock({ alt, dim, resolvedSrc, src, thumbSrc }: ImageBlockProps) {
const triggerRef = React.useRef<HTMLButtonElement | null>(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}

View File

@@ -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 (
<>
<div
aria-hidden="true"
className="h-5 w-px shrink-0 bg-muted-foreground/15"
/>
<span
aria-label={`Image ${position} of ${itemCount}`}
aria-live="polite"
className="min-w-9 text-center text-xs font-medium tabular-nums text-muted-foreground"
role="status"
>
{position} / {itemCount}
</span>
</>
);
}

View File

@@ -10,6 +10,7 @@ import {
type ImageLightboxCornerRadii,
imageLightboxBoxFromRect,
imageLightboxCornerRadiiFromElement,
imageLightboxSourceScopeForTrigger,
visibleImageGalleryForTrigger,
} from "./imageLightbox";
@@ -18,8 +19,6 @@ type ImageZoomOverlayProps = {
galleryIndex?: number;
galleryItems?: ImageGalleryItem[];
onClose: () => void;
onCopy: (src: string | undefined) => void;
onDownload: (src: string | undefined) => void;
resolvedSrc: string;
sourceBox: ImageLightboxBox;
sourceCornerRadii: ImageLightboxCornerRadii;
@@ -27,8 +26,6 @@ type ImageZoomOverlayProps = {
src: string | undefined;
};
const ignoreUnavailableImageAction = () => undefined;
export function createLinkPreviewImageLightbox(
ImageZoomOverlay: ComponentType<ImageZoomOverlayProps>,
): ComponentType<LinkPreviewImageLightboxProps> {
@@ -52,7 +49,7 @@ export function createLinkPreviewImageLightbox(
const sourceBox = imageLightboxBoxFromRect(rect);
const sourceCornerRadii = imageLightboxCornerRadiiFromElement(image);
const sourceScope = trigger.closest("[data-link-preview-list]");
const sourceScope = imageLightboxSourceScopeForTrigger(trigger);
const dim =
image.naturalWidth > 0 && image.naturalHeight > 0
? `${image.naturalWidth}x${image.naturalHeight}`
@@ -62,6 +59,7 @@ export function createLinkPreviewImageLightbox(
{
alt,
dim,
trigger,
resolvedSrc: src,
src: undefined,
thumbnailBox: sourceBox,
@@ -103,8 +101,6 @@ export function createLinkPreviewImageLightbox(
galleryIndex={lightboxState.galleryIndex}
galleryItems={lightboxState.galleryItems}
onClose={() => setLightboxState(null)}
onCopy={ignoreUnavailableImageAction}
onDownload={ignoreUnavailableImageAction}
resolvedSrc={src}
sourceBox={lightboxState.sourceBox}
sourceCornerRadii={lightboxState.sourceCornerRadii}

View File

@@ -32,6 +32,7 @@ export type ImageGalleryDirection = "forward" | "backward";
export type ImageGalleryItem = {
alt: string | undefined;
dim?: string;
trigger?: HTMLElement;
resolvedSrc: string;
src: string | undefined;
thumbnailBox?: ImageLightboxBox;
@@ -339,12 +340,15 @@ function imageLightboxThumbnailTargetForItem(
sourceScope: Element | null | undefined,
): ImageLightboxThumbnailTarget | null {
const root = sourceScope?.isConnected ? sourceScope : document.body;
const triggers = Array.from(
root.querySelectorAll<HTMLElement>("[data-image-lightbox-trigger]"),
);
const triggers = item.trigger
? [item.trigger]
: Array.from(
root.querySelectorAll<HTMLElement>("[data-image-lightbox-trigger]"),
);
for (const trigger of triggers) {
const isCurrentItem =
item.trigger != null ||
trigger.dataset.imageLightboxResolvedSrc === item.resolvedSrc ||
(item.src != null && trigger.dataset.imageLightboxSrc === item.src);
if (!isCurrentItem) {
@@ -386,6 +390,7 @@ export function imageLightboxSourceScopeForTrigger(
trigger: HTMLElement,
): Element | null {
return (
trigger.closest("[data-image-gallery-scope]") ??
trigger.closest(IMAGE_LIGHTBOX_MARKDOWN_SCOPE_SELECTOR) ??
trigger.closest("[data-testid='message-row']")
);
@@ -409,6 +414,7 @@ function imageGalleryItemFromTrigger(
return {
alt: trigger.dataset.imageLightboxAlt || undefined,
dim: trigger.dataset.imageLightboxDim || inferredDim,
trigger,
resolvedSrc,
src: trigger.dataset.imageLightboxSrc || undefined,
thumbnailBox: thumbnail?.box,

View File

@@ -35,6 +35,41 @@ async function waitForMockLiveSubscription(page: Page, channelName: string) {
.toBe(true);
}
async function emitMockMessage(
page: Page,
content: string,
parentEventId?: string,
createdAt?: number,
extraTags?: string[][],
): Promise<string> {
const eventId = await page.evaluate(
({ body, createdAt: timestamp, parent, tags }) => {
const event = (
window as Window & {
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
channelName: string;
content: string;
createdAt?: number;
parentEventId?: string;
extraTags?: string[][];
}) => { id?: string } | undefined;
}
).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: body,
createdAt: timestamp,
parentEventId: parent,
extraTags: tags,
});
return event?.id ?? null;
},
{ body: content, createdAt, parent: parentEventId, tags: extraTags },
);
if (!eventId) throw new Error("Expected mock message event id");
return eventId;
}
function imageImetaTag({
dim,
filename,
@@ -318,6 +353,310 @@ test("image bundle lightbox navigates as a gallery", async ({ page }) => {
).toBeLessThan(2);
});
test("thread lightbox navigates images across messages", async ({ page }) => {
await installNoDimImageRoutes(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");
const threadTimestamp = Math.floor(Date.now() / 1000) - 10;
const rootId = await emitMockMessage(
page,
["thread gallery root", `![root](${NO_DIM_WIDE_URL})`].join("\n"),
undefined,
threadTimestamp,
);
await emitMockMessage(
page,
["thread gallery reply one", `![reply one](${NO_DIM_WIDE_URL})`].join("\n"),
rootId,
threadTimestamp + 1,
);
await emitMockMessage(
page,
["thread gallery reply two", `![reply two](${NO_DIM_SECOND_URL})`].join(
"\n",
),
rootId,
threadTimestamp + 2,
);
const threadSummary = page.locator(
`[data-testid="message-thread-summary"][data-thread-head-id="${rootId}"]`,
);
await expect(threadSummary).toBeVisible();
await threadSummary.click();
const panel = page.getByTestId("message-thread-panel");
await expect(panel).toBeVisible();
const replies = panel.getByTestId("message-thread-replies");
const finalImage = replies
.getByTestId("message-row")
.filter({ hasText: "thread gallery reply two" })
.getByTestId("message-image-lightbox-trigger");
await finalImage.click();
const dialog = page.getByRole("dialog");
const position = dialog.getByRole("status");
await expect(dialog.locator(`img[src="${NO_DIM_SECOND_URL}"]`)).toBeVisible();
await expect(position).toHaveText("3 / 3");
await expect(position).toHaveAttribute("aria-label", "Image 3 of 3");
await expect(dialog.getByRole("button", { name: "Next image" })).toHaveCount(
0,
);
await page.keyboard.press("ArrowLeft");
await expect(dialog.getByRole("img", { name: "reply one" })).toBeVisible();
await expect(position).toHaveText("2 / 3");
const replyOneImage = replies
.getByTestId("message-row")
.filter({ hasText: "thread gallery reply one" })
.getByTestId("message-image-lightbox-trigger");
const replyOneThumbnailBox = await replyOneImage.locator("img").boundingBox();
if (!replyOneThumbnailBox) {
throw new Error("Expected duplicate reply thumbnail to have a layout box");
}
const lightboxFrame = page.locator("[data-image-lightbox-frame]");
await page.waitForTimeout(500);
await page.mouse.click(20, 20);
const closingFrameBox = await lightboxFrame.evaluate((element) => {
if (!(element instanceof HTMLElement)) {
throw new Error("Expected HTML lightbox frame");
}
return {
height: Number.parseFloat(element.style.height),
left: Number.parseFloat(element.style.left),
top: Number.parseFloat(element.style.top),
width: Number.parseFloat(element.style.width),
};
});
expect(Math.abs(closingFrameBox.left - replyOneThumbnailBox.x)).toBeLessThan(
2,
);
expect(Math.abs(closingFrameBox.top - replyOneThumbnailBox.y)).toBeLessThan(
2,
);
expect(
Math.abs(closingFrameBox.width - replyOneThumbnailBox.width),
).toBeLessThan(2);
expect(
Math.abs(closingFrameBox.height - replyOneThumbnailBox.height),
).toBeLessThan(2);
await expect(dialog).toHaveCount(0);
await finalImage.click();
await expect(position).toHaveText("3 / 3");
await page.keyboard.press("ArrowLeft");
await expect(position).toHaveText("2 / 3");
await dialog.getByRole("button", { name: "Previous image" }).click();
await expect(dialog.getByRole("img", { name: "root" })).toBeVisible();
await expect(position).toHaveText("1 / 3");
await expect(
dialog.getByRole("button", { name: "Previous image" }),
).toHaveCount(0);
});
test("thread gallery includes only currently rendered media", async ({
page,
}) => {
await installNoDimImageRoutes(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");
const timestamp = Math.floor(Date.now() / 1000) - 10;
const rootId = await emitMockMessage(
page,
["rendered gallery root", `![root](${NO_DIM_WIDE_URL})`].join("\n"),
undefined,
timestamp,
);
const branchId = await emitMockMessage(
page,
["rendered branch", `![branch](${NO_DIM_PORTRAIT_URL})`].join("\n"),
rootId,
timestamp + 1,
);
await emitMockMessage(
page,
["collapsed child", `![child](${NO_DIM_SECOND_URL})`].join("\n"),
branchId,
timestamp + 2,
);
await emitMockMessage(
page,
["rendered sibling", `![sibling](${NO_DIM_WIDE_URL})`].join("\n"),
rootId,
timestamp + 3,
);
await page
.locator(
`[data-testid="message-thread-summary"][data-thread-head-id="${rootId}"]`,
)
.click();
const replies = page
.getByTestId("message-thread-panel")
.getByTestId("message-thread-replies");
await expect(
replies.getByTestId("message-row").filter({ hasText: "collapsed child" }),
).toHaveCount(0);
await replies
.getByTestId("message-row")
.filter({ hasText: "rendered sibling" })
.getByTestId("message-image-lightbox-trigger")
.click();
const dialog = page.getByRole("dialog");
const position = dialog.getByRole("status");
await expect(position).toHaveText("3 / 3");
await dialog.getByRole("button", { name: "Previous image" }).click();
await expect(dialog.getByRole("img", { name: "branch" })).toBeVisible();
await expect(position).toHaveText("2 / 3");
await expect(dialog.getByRole("img", { name: "child" })).toHaveCount(0);
});
test("preview-first galleries retain Markdown image actions", async ({
page,
}) => {
const previewUrl = "https://github.com/block/buzz/pull/6705";
await installMockBridge(page, {
uploadDescriptors: [
{
url: `http://localhost:3000/media/${IMAGE_SHAS[2]}.png`,
sha256: IMAGE_SHAS[2],
size: 3456,
type: "image/png",
uploaded: Math.floor(Date.now() / 1000),
dim: "140x140",
filename: "preview.png",
},
],
});
await installNoDimImageRoutes(page);
await page.route("http://127.0.0.1:54321/media/**", (route) =>
route.fulfill({
body: '<svg xmlns="http://www.w3.org/2000/svg" width="140" height="140"><rect width="100%" height="100%" fill="#60a5fa"/></svg>',
contentType: "image/svg+xml",
}),
);
await page.addInitScript(() => {
localStorage.setItem("buzz.appearance.linkPreviewStyle", "rich");
});
await page.goto("/");
await page.getByTestId("channel-general").click();
await waitForMockLiveSubscription(page, "general");
const timestamp = Math.floor(Date.now() / 1000) - 10;
const rootId = await emitMockMessage(
page,
"preview action root",
undefined,
timestamp,
);
await emitMockMessage(
page,
[
`preview action reply ${previewUrl}`,
`![body image](${NO_DIM_WIDE_URL})`,
].join("\n"),
rootId,
timestamp + 1,
[
[
"link-preview",
"snapshot",
"1",
previewUrl,
"Preview title",
"Example",
"Preview description",
`http://localhost:3000/media/${IMAGE_SHAS[2]}.png`,
IMAGE_SHAS[2],
"",
"",
],
],
);
await page
.locator(
`[data-testid="message-thread-summary"][data-thread-head-id="${rootId}"]`,
)
.click();
const reply = page
.getByTestId("message-thread-panel")
.getByTestId("message-row")
.filter({ hasText: "preview action reply" });
await reply.getByRole("button", { name: /Zoom image: Preview from/ }).click();
const dialog = page.getByRole("dialog");
await expect(dialog.getByRole("status")).toHaveText("2 / 2");
await dialog.getByRole("button", { name: "Previous image" }).click();
const bodyImage = dialog.getByRole("img", { name: "body image" });
await expect(bodyImage).toBeVisible();
await dialog.getByRole("button", { name: "Download image" }).click();
await expect
.poll(() =>
page.evaluate(
() =>
(window as Window & { __BUZZ_E2E_COMMANDS__?: string[] })
.__BUZZ_E2E_COMMANDS__ ?? [],
),
)
.toContain("download_image");
await bodyImage.click({ button: "right" });
await dialog.getByRole("button", { name: "Copy image" }).click();
await expect
.poll(() =>
page.evaluate(
() =>
(window as Window & { __BUZZ_E2E_COMMANDS__?: string[] })
.__BUZZ_E2E_COMMANDS__ ?? [],
),
)
.toContain("copy_image_to_clipboard");
});
test("adjacent channel messages remain separate image galleries", async ({
page,
}) => {
await installNoDimImageRoutes(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");
await emitMockMessage(
page,
["separate gallery one", `![one](${NO_DIM_WIDE_URL})`].join("\n"),
);
await emitMockMessage(
page,
["separate gallery two", `![two](${NO_DIM_PORTRAIT_URL})`].join("\n"),
);
const firstRow = page
.getByTestId("message-row")
.filter({ hasText: "separate gallery one" })
.last();
await firstRow.getByTestId("message-image-lightbox-trigger").click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await expect(dialog.getByRole("status")).toHaveCount(0);
await expect(dialog.getByRole("button", { name: "Next image" })).toHaveCount(
0,
);
});
test("hidden spoiler images are excluded from gallery navigation until revealed", async ({
page,
}) => {

View File

@@ -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();

View File

@@ -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 },
});
});

View File

@@ -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();

View File

@@ -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:<digest> \
--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:<run-id>:<attempt>",
"url": "https://github.com/block/buzz/actions/runs/<run-id>/attempts/<attempt>"
}
}
```
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.

View File

@@ -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
}
}

View File

@@ -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")

View File

@@ -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"