Merge remote-tracking branch 'origin/main' into duncan/permission-policy

* origin/main:
  docs(nest): make commit attribution policy-neutral (#6707)
  fix(desktop-messages): preserve inline agent mentions with persistent addressing (#6793)
  Qualify canonical relay images for staged delivery (#6781)
  feat(desktop): persist agent addressing across composer messages (#6714)
  feat: navigate images across message threads (#6705)

Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
This commit is contained in:
Duncan
2026-08-25 16:44:52 -04:00
65 changed files with 3344 additions and 604 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

@@ -48,7 +48,7 @@ const BUZZ_CLI_SKILL_MD: &str = include_str!("nest_skill.md");
/// Template content version for AGENTS.md static content (above managed markers).
/// Bump this when changing `nest_agents.md` to trigger refresh on existing installs.
/// Version 1 is implicitly "before this mechanism existed" (no version file).
const NEST_AGENTS_VERSION: u32 = 4;
const NEST_AGENTS_VERSION: u32 = 5;
/// Template content version for SKILL.md.
/// Bump this when changing `nest_skill.md` to trigger refresh on existing installs.

View File

@@ -41,6 +41,21 @@ fn nest_skill_contains_safe_mention_workflow() {
assert!(BUZZ_CLI_SKILL_MD.contains("never changes membership automatically"));
}
#[test]
fn nest_agents_template_separates_commit_attribution_claims() {
assert_eq!(AGENTS_MD.matches("## Git Commit Attribution").count(), 1);
assert!(AGENTS_MD.contains(
"Git authorship, co-authorship, DCO sign-off, and cryptographic signing are separate claims"
));
assert!(AGENTS_MD
.contains("Request, approval, review, or accountability alone is not co-authorship"));
assert!(AGENTS_MD.contains("A sign-off is not an approval marker"));
assert!(AGENTS_MD.contains("Never use another person's signing key"));
assert!(AGENTS_MD.contains("inspect every outgoing commit against the actual upstream or base"));
assert!(AGENTS_MD.contains("An agent-owned repository may use the agent as author"));
assert!(!AGENTS_MD.contains("every commit MUST include a `Signed-off-by`"));
}
#[test]
fn ensure_nest_creates_all_dirs_and_agents_md() {
let tmp = tempfile::tempdir().unwrap();
@@ -431,6 +446,34 @@ fn refresh_agents_md_writes_version_file() {
assert_eq!(version.trim(), NEST_AGENTS_VERSION.to_string());
}
#[test]
fn refresh_agents_md_upgrades_attribution_and_preserves_owned_content() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join(".buzz");
ensure_nest_at(&root).unwrap();
let agents_md = root.join("AGENTS.md");
fs::write(
&agents_md,
"# Buzz Nest\n\n## Git Commit Identity\n\n\
- **Human sign-off (required):** every commit MUST include a `Signed-off-by`.\n\n\
<!-- BEGIN BUZZ MANAGED — regenerated automatically, do not edit below -->\n\
## Active Agents\n\n| Name | Persona | How to address |\n\
|------|---------|----------------|\n| Kit | Builder | @Kit |\n\
<!-- END BUZZ MANAGED -->\n\n## Local Notes\n\nKeep me.\n",
)
.unwrap();
fs::write(root.join(".nest-agents-version"), "4\n").unwrap();
ensure_nest_at(&root).unwrap();
let content = fs::read_to_string(&agents_md).unwrap();
assert_eq!(content.matches("## Git Commit Attribution").count(), 1);
assert!(!content.contains("**Human sign-off (required):**"));
assert!(content.contains("| Kit | Builder | @Kit |"));
assert!(content.contains("## Local Notes\n\nKeep me."));
}
#[test]
fn refresh_skill_md_writes_version_file() {
let tmp = tempfile::tempdir().unwrap();

View File

@@ -44,15 +44,18 @@ created: 2026-01-15
- **`.scratch/` is disposable** — don't rely on it across sessions
- **Stay on task** — only stage files relevant to your current work
## Git Commit Identity
## Git Commit Attribution
The human operator signs off for accountability.
Git authorship, co-authorship, DCO sign-off, and cryptographic signing are separate claims. Follow repository-local rules and the authorizing human's explicit directions; do not infer attribution from repository ownership or from who requested, approved, or reviewed the work.
- **Human sign-off (required):** every commit MUST include a `Signed-off-by` trailer for the human operator who is responsible for the agent's work. Add via `git commit --trailer "Signed-off-by: Human Name <human@email>"`. One blank line must separate trailers from the commit body.
- **Human credit (`Co-authored-by`):** every commit MUST also include a `Co-authored-by` trailer for the same human operator, with identical name and email to the `Signed-off-by` line. GitHub parses `Co-authored-by` for contribution-graph credit; `Signed-off-by` alone does not grant it. Add via `git commit --trailer "Co-authored-by: Human Name <human@email>"`. Place `Co-authored-by` before `Signed-off-by` in the trailer block.
- **Discovering the human's identity:** read `git config user.name` and `git config user.email` from the working repository. These reflect the human operator's configured identity for that repo (which may differ from their global config). Use these exact values for both trailers. Do NOT hardcode, guess, or prompt for the email — the repo config is the source of truth. If `git config user.email` returns empty, STOP and ask the human operator for their name and email before committing.
- **Signing:** if the agent has a registered signing key, sign commits. If not, commits will land unverified — this is acceptable until agent SSH keys are provisioned. Do NOT use the human's signing key.
- **Verify before pushing:** `git log -1` should show the human's `Signed-off-by` trailer.
- **Author:** use the person or agent required by the applicable policy. If no policy specifies an author, use the identity that actually authored the change.
- **Co-authors:** add `Co-authored-by` only for other people or agents who materially authored the change. Request, approval, review, or accountability alone is not co-authorship.
- **DCO:** add `Signed-off-by` only when repository policy requires that identity's DCO certification. A sign-off is not an approval marker.
- **Identity:** resolve required identities from trusted local configuration or explicit verified direction; never hard-code or guess them. A managed runtime may make effective `git config user.*` values identify the agent. Stop and ask if a required identity cannot be established.
- **Signing:** use only the signing key configured for the committing identity. Never use another person's signing key.
- **Verify before pushing:** inspect every outgoing commit against the actual upstream or base and confirm its attribution matches the applicable policy.
A repository may require an accountable human as author and the implementing agent as co-author. An agent-owned repository may use the agent as author and require no human trailer. In both cases, repository-local policy controls.
<!-- BEGIN BUZZ MANAGED — regenerated automatically, do not edit below -->
## Active Agents

View File

@@ -371,7 +371,6 @@ export const ChannelPane = React.memo(function ChannelPane({
}),
[activeChannel, currentPubkey, profiles],
);
const handleWelcomeAddAgent = React.useCallback(() => {
onAddAgent?.({
beforeSend: () =>
@@ -387,13 +386,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,
@@ -780,7 +780,7 @@ export const ChannelPane = React.memo(function ChannelPane({
: undefined
}
onSend={handleSendMessage}
profiles={profiles}
{...{ profiles, recentMentionPubkeys: recentMentions }}
showBackgroundUploadProgress={false}
placeholder={
timeoutState.active
@@ -889,7 +889,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

@@ -9,25 +9,25 @@ globalThis.localStorage = {
const preference = await import("./autoPinMentionedAgentsPreference.ts");
test("defaults missing and invalid values to keeping mentioned agents pinned", () => {
assert.equal(preference.parseKeepMentionedAgentsPinned(null), true);
assert.equal(preference.parseKeepMentionedAgentsPinned("invalid"), true);
test("defaults missing and invalid values to one-time agent mentions", () => {
assert.equal(preference.parseKeepMentionedAgentsPinned(null), false);
assert.equal(preference.parseKeepMentionedAgentsPinned("invalid"), false);
assert.equal(preference.parseKeepMentionedAgentsPinned("true"), true);
assert.equal(preference.parseKeepMentionedAgentsPinned("false"), false);
});
test("persists changes to the post-mention pinning preference", () => {
preference.setKeepMentionedAgentsPinned(false);
assert.equal(preference.getKeepMentionedAgentsPinned(), false);
assert.equal(
values.get(preference.KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY),
"false",
);
preference.setKeepMentionedAgentsPinned(true);
assert.equal(preference.getKeepMentionedAgentsPinned(), true);
assert.equal(
values.get(preference.KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY),
"true",
);
preference.setKeepMentionedAgentsPinned(false);
assert.equal(preference.getKeepMentionedAgentsPinned(), false);
assert.equal(
values.get(preference.KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY),
"false",
);
});

View File

@@ -2,7 +2,7 @@ import * as React from "react";
export const KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY =
"buzz.messages.keepMentionedAgentsPinned";
export const DEFAULT_KEEP_MENTIONED_AGENTS_PINNED = true;
export const DEFAULT_KEEP_MENTIONED_AGENTS_PINNED = false;
const listeners = new Set<() => void>();
let keepMentionedAgentsPinned = readStoredPreference();

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

@@ -114,6 +114,10 @@ function storageKey(): string {
: legacyStorageKey();
}
export function getDraftStoreScope(): string {
return storageKey();
}
function legacyStorageKey(): string {
return `${LEGACY_DRAFT_STORE_KEY_PREFIX}:${currentPubkey}`;
}

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();
@@ -176,32 +170,33 @@ function MessageComposerImpl({
media.queuedAttachmentsRef.current.length === 0;
const ownsDropZone = mediaController === undefined;
const backgroundUpload = useBackgroundMediaUpload();
useDraftPersistLifecycle({
effectiveDraftKey,
channelId,
loadDraft: drafts.loadDraft,
persistDraft: drafts.persistDraft,
getMentionRefs: mentions.getDraftMentionRefs,
restoreMentionRefs: mentions.restoreDraftMentionRefs,
livePendingImeta: media.pendingImeta,
setPendingImeta: media.setPendingImeta,
getQueuedAttachments: () => media.queuedAttachmentsRef.current,
saveQueuedAttachmentsForDraft,
clearQueuedAttachments: media.clearQueuedAttachments,
restoreQueuedAttachments: media.restoreQueuedAttachments,
takeQueuedAttachmentsForDraft,
setContent: (content) => {
setComposerContent(content);
richText.setContent(content);
},
clearContent: () => {
setComposerContent("");
richText.clearContent();
},
setSpoileredAttachmentUrls,
spoileredAttachmentUrlsRef,
syncComposerContentFromEditor,
});
const { trackAuthoredContent: trackDraftAuthoredContent } =
useDraftPersistLifecycle({
effectiveDraftKey,
channelId,
loadDraft: drafts.loadDraft,
persistDraft: drafts.persistDraft,
getMentionRefs: mentions.getDraftMentionRefs,
restoreMentionRefs: mentions.restoreDraftMentionRefs,
livePendingImeta: media.pendingImeta,
setPendingImeta: media.setPendingImeta,
getQueuedAttachments: () => media.queuedAttachmentsRef.current,
saveQueuedAttachmentsForDraft,
clearQueuedAttachments: media.clearQueuedAttachments,
restoreQueuedAttachments: media.restoreQueuedAttachments,
takeQueuedAttachmentsForDraft,
setContent: (content) => {
setComposerContent(content);
richText.setContent(content);
},
clearContent: () => {
setComposerContent("");
richText.clearContent();
},
setSpoileredAttachmentUrls,
spoileredAttachmentUrlsRef,
syncComposerContentFromEditor,
});
// biome-ignore lint/correctness/useExhaustiveDependencies: effectiveDraftKey is the sole trigger
React.useEffect(() => {
media.setUploadState({ status: "idle" });
@@ -213,12 +208,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;
@@ -274,8 +273,13 @@ function MessageComposerImpl({
onLinkSelectionChange: (info) => onLinkSelectionChangeRef.current?.(info),
onLinkShortcut: () => onLinkShortcutRef.current?.() ?? false,
onUpdate: ({ cursor, linkPreviewContent, text }) => {
trackDraftAuthoredContent(text);
contentRef.current = 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 +301,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 +343,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 +437,7 @@ function MessageComposerImpl({
edit.replaceToOffset,
edit.insertText,
edit.customEmojiShortcode,
edit.preserveSelection,
);
},
[richText.replacePlainTextRange],
@@ -403,17 +447,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 +538,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 +615,7 @@ function MessageComposerImpl({
return;
}
isSubmitLockedRef.current = true;
setIsSubmitLocked(true);
onPreparingMentionSendChange?.(true);
try {
const preparedLinkPreviews = getReadyLinkPreviewTags().some(
@@ -570,9 +625,6 @@ function MessageComposerImpl({
: prepareBackgroundLinkPreviews(getLiveLinkPreviewCandidates());
await mentionSendFlow.sendMessageWithMentionFlow({
addressedAgentPubkeys: persistentAudience.pubkeys,
audienceRevision: audienceScope
? getPersistentAgentAudienceRevision(audienceScope)
: 0,
capturedChannelId: channelId,
capturedThreadContext,
pendingImeta: currentPendingImeta,
@@ -589,6 +641,7 @@ function MessageComposerImpl({
});
} finally {
isSubmitLockedRef.current = false;
setIsSubmitLocked(false);
onPreparingMentionSendChange?.(false);
}
}, [
@@ -615,7 +668,6 @@ function MessageComposerImpl({
syncComposerContentFromEditor,
onCaptureSendContext,
onPreparingMentionSendChange,
audienceScope,
persistentAudience.pubkeys,
isEditSubmissionLocked,
effectiveDraftKey,
@@ -713,74 +765,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 +848,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 +895,6 @@ function MessageComposerImpl({
? setKeepMentionedAgentsPinned
: undefined
}
openOptionsRequest={mentionOptionsOpenRequest}
onToggleAlwaysAddressAgent={
audienceScope && editTarget == null
? toggleAlwaysAddressAgent
@@ -973,6 +963,7 @@ function MessageComposerImpl({
</div>
<ComposerDockToolbar
addressedAgents={editTarget == null ? lockedAgents : []}
autoPinConfirmationTitle={autoPinConfirmationTitle}
layoutMode={layoutMode}
composerDisabled={composerDisabled}
editor={richText.editor}
@@ -983,6 +974,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

@@ -585,6 +585,225 @@ test("draft_lifecycle_empty_target_clears_stale_mention_refs", async () => {
await handle.unmount();
});
test("draft_lifecycle_persists_an_explicit_clear_before_async_rerender", async () => {
const DRAFT_KEY = "chan-clear-race";
setupStore("pubkey-clear-race");
persistDraftEntry(DRAFT_KEY, "draft text", DRAFT_KEY, [], []);
let editorContent = "";
let trackAuthoredContent;
const spoileredRef = { current: new Set() };
function HarnessComposer() {
({ trackAuthoredContent } = useDraftPersistLifecycle({
effectiveDraftKey: DRAFT_KEY,
channelId: DRAFT_KEY,
loadDraft: loadDraftEntry,
persistDraft: persistDraftEntry,
getMentionRefs: () => [],
restoreMentionRefs: () => {},
livePendingImeta: [],
setPendingImeta: () => {},
setContent: (content) => {
editorContent = content;
},
clearContent: () => {
editorContent = "";
},
setSpoileredAttachmentUrls: () => {},
spoileredAttachmentUrlsRef: spoileredRef,
syncComposerContentFromEditor: () => editorContent,
}));
return null;
}
const handle = await mountStrictMode(HarnessComposer);
assert.equal(editorContent, "draft text");
trackAuthoredContent("");
assert.equal(
loadDraftEntry(DRAFT_KEY),
undefined,
"the authoritative update removes the stale body even while editor reads lag",
);
await handle.unmount();
assert.equal(
loadDraftEntry(DRAFT_KEY),
undefined,
"async settlement must not repersist the deleted body",
);
const remounted = await mountStrictMode(HarnessComposer);
assert.equal(editorContent, "", "the deleted body must not be restored");
await remounted.unmount();
});
test("draft_lifecycle_clear_caption_preserves_image_and_spoiler_on_remount", async () => {
const DRAFT_KEY = "chan-clear-caption-image";
setupStore("pubkey-clear-caption-image");
persistDraftEntry(DRAFT_KEY, "caption", DRAFT_KEY, [IMG_A], [IMG_A.url]);
let editorContent = "";
let pendingImeta = [];
let spoileredUrls = new Set();
let trackAuthoredContent;
function HarnessComposer() {
({ trackAuthoredContent } = useDraftPersistLifecycle({
effectiveDraftKey: DRAFT_KEY,
channelId: DRAFT_KEY,
loadDraft: loadDraftEntry,
persistDraft: persistDraftEntry,
getMentionRefs: () => [],
restoreMentionRefs: () => {},
livePendingImeta: pendingImeta,
setPendingImeta: (imeta) => {
pendingImeta = imeta;
},
setContent: (content) => {
editorContent = content;
},
clearContent: () => {
editorContent = "";
},
setSpoileredAttachmentUrls: (urls) => {
spoileredUrls = urls;
},
spoileredAttachmentUrlsRef: {
get current() {
return spoileredUrls;
},
},
syncComposerContentFromEditor: () => editorContent,
}));
return null;
}
const handle = await mountStrictMode(HarnessComposer);
trackAuthoredContent("");
editorContent = "";
await handle.unmount();
const remounted = await mountStrictMode(HarnessComposer);
assert.equal(editorContent, "");
assert.equal(pendingImeta[0]?.url, IMG_A.url);
assert.deepEqual([...spoileredUrls], [IMG_A.url]);
assert.equal(loadDraftEntry(DRAFT_KEY)?.content, "");
await remounted.unmount();
});
test("draft_lifecycle_clear_caption_preserves_queued_file_on_remount", async () => {
const DRAFT_KEY = "chan-clear-caption-file";
setupStore("pubkey-clear-caption-file");
persistDraftEntry(DRAFT_KEY, "caption", DRAFT_KEY, [], []);
const FILE_A = {
file: new File(["report"], "report.pdf", { type: "application/pdf" }),
id: 9,
spoilered: true,
};
let editorContent = "";
let queuedAttachments = [FILE_A];
let trackAuthoredContent;
const spoileredRef = { current: new Set() };
function HarnessComposer() {
({ trackAuthoredContent } = useDraftPersistLifecycle({
effectiveDraftKey: DRAFT_KEY,
channelId: DRAFT_KEY,
loadDraft: loadDraftEntry,
persistDraft: persistDraftEntry,
getMentionRefs: () => [],
restoreMentionRefs: () => {},
livePendingImeta: [],
setPendingImeta: () => {},
getQueuedAttachments: () => queuedAttachments,
saveQueuedAttachmentsForDraft,
clearQueuedAttachments: () => {
queuedAttachments = [];
},
restoreQueuedAttachments: (attachments) => {
queuedAttachments = attachments;
},
takeQueuedAttachmentsForDraft,
setContent: (content) => {
editorContent = content;
},
clearContent: () => {
editorContent = "";
},
setSpoileredAttachmentUrls: () => {},
spoileredAttachmentUrlsRef: spoileredRef,
syncComposerContentFromEditor: () => editorContent,
}));
return null;
}
saveQueuedAttachmentsForDraft(DRAFT_KEY, [FILE_A]);
const handle = await mountStrictMode(HarnessComposer);
trackAuthoredContent("");
editorContent = "";
await handle.unmount();
const remounted = await mountStrictMode(HarnessComposer);
assert.equal(editorContent, "");
assert.equal(queuedAttachments[0]?.file.name, "report.pdf");
assert.equal(queuedAttachments[0]?.spoilered, true);
await remounted.unmount();
});
test("draft_lifecycle_clear_authority_is_scoped_to_relay_and_identity", async () => {
const DRAFT_KEY = "shared-key";
installFreshLocalStorage();
clearAllDrafts();
initDraftStore("pubkey-a", "wss://relay-a.example");
persistDraftEntry(DRAFT_KEY, "workspace A", DRAFT_KEY, [], []);
let editorContent = "";
let trackAuthoredContent;
const spoileredRef = { current: new Set() };
function HarnessComposer() {
({ trackAuthoredContent } = useDraftPersistLifecycle({
effectiveDraftKey: DRAFT_KEY,
channelId: DRAFT_KEY,
loadDraft: loadDraftEntry,
persistDraft: persistDraftEntry,
getMentionRefs: () => [],
restoreMentionRefs: () => {},
livePendingImeta: [],
setPendingImeta: () => {},
setContent: (content) => {
editorContent = content;
},
clearContent: () => {
editorContent = "";
},
setSpoileredAttachmentUrls: () => {},
spoileredAttachmentUrlsRef: spoileredRef,
syncComposerContentFromEditor: () => editorContent,
}));
return null;
}
const workspaceA = await mountStrictMode(HarnessComposer);
trackAuthoredContent("");
editorContent = "";
await workspaceA.unmount();
initDraftStore("pubkey-b", "wss://relay-b.example");
persistDraftEntry(DRAFT_KEY, "workspace B", DRAFT_KEY, [], []);
const workspaceB = await mountStrictMode(HarnessComposer);
assert.equal(editorContent, "workspace B");
await workspaceB.unmount();
assert.equal(loadDraftEntry(DRAFT_KEY)?.content, "workspace B");
initDraftStore("pubkey-a", "wss://relay-a.example");
const workspaceARemount = await mountStrictMode(HarnessComposer);
assert.equal(editorContent, "", "workspace A stale text must stay cleared");
await workspaceARemount.unmount();
});
test("draft_lifecycle_preserves_local_files_across_a_b_a_switch", async () => {
setupStore("pubkey-switch-files");
const FILE_A = {

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,
@@ -61,6 +60,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
@@ -423,8 +425,7 @@ export const MessageRow = React.memo(
);
}
// Suppress prose for permission-request sentinels: the harness
// encodes them as bare JSON, and the card below renders the ask.
// Suppress prose for permission-request sentinels — bare JSON the card below renders.
if (message.isAgent && isPermissionRequestSentinel(message.body)) {
return null;
}

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

@@ -2,9 +2,10 @@ import * as React from "react";
import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown";
import type { QueuedMediaAttachment } from "@/features/messages/lib/backgroundMediaUploadStore";
import type {
DraftMentionRef,
DraftState,
import {
getDraftStoreScope,
type DraftMentionRef,
type DraftState,
} from "@/features/messages/lib/useDrafts";
type UseDraftPersistLifecycleParams = {
@@ -60,6 +61,21 @@ type UseDraftPersistLifecycleParams = {
syncComposerContentFromEditor: () => string;
};
type UseDraftPersistLifecycleResult = {
/**
* Record the latest authored editor content. Empty content is persisted
* immediately and remains authoritative across composer remounts until a
* later non-empty editor update supersedes it.
*/
trackAuthoredContent: (content: string) => void;
};
const authoritativelyClearedDraftKeys = new Set<string>();
function scopedDraftKey(draftKey: string): string {
return `${getDraftStoreScope()}:${draftKey}`;
}
/**
* Owns the draft-persist lifecycle for `MessageComposer`.
*
@@ -104,8 +120,10 @@ export function useDraftPersistLifecycle({
setSpoileredAttachmentUrls,
spoileredAttachmentUrlsRef,
syncComposerContentFromEditor,
}: UseDraftPersistLifecycleParams): void {
}: UseDraftPersistLifecycleParams): UseDraftPersistLifecycleResult {
const pendingImetaForPersistRef = React.useRef<ImetaMedia[]>([]);
const emptyContentIsAuthoritativeRef = React.useRef(false);
const isRestoringContentRef = React.useRef(false);
const restoredQueuedAttachmentsRef = React.useRef<QueuedMediaAttachment[]>(
[],
);
@@ -117,7 +135,7 @@ export function useDraftPersistLifecycle({
pendingImetaForPersistRef.current = livePendingImeta;
// biome-ignore lint/correctness/useExhaustiveDependencies: effectiveDraftKey is the sole trigger
React.useEffect(() => {
React.useLayoutEffect(() => {
// The outgoing draft is persisted by the cleanup below, which runs before
// this body on key changes and has the correct outgoing channelId in its
// closure. Do NOT re-persist prevKey here: channelId in this render
@@ -134,10 +152,21 @@ export function useDraftPersistLifecycle({
: [];
}
restoreQueuedAttachments?.(restoredQueuedAttachmentsRef.current);
const authoritativeDraftKey = effectiveDraftKey
? scopedDraftKey(effectiveDraftKey)
: null;
const wasAuthoritativelyCleared = authoritativeDraftKey
? authoritativelyClearedDraftKeys.has(authoritativeDraftKey)
: false;
const saved = effectiveDraftKey ? loadDraft(effectiveDraftKey) : undefined;
emptyContentIsAuthoritativeRef.current = wasAuthoritativelyCleared;
isRestoringContentRef.current = true;
if (saved) {
setContent(saved.content);
restoreMentionRefs(saved.mentionRefs ?? []);
const restoredContent = wasAuthoritativelyCleared ? "" : saved.content;
setContent(restoredContent);
restoreMentionRefs(
wasAuthoritativelyCleared ? [] : (saved.mentionRefs ?? []),
);
// Set the persist-snapshot ref SYNCHRONOUSLY before calling the async
// state setter, so the cleanup closure (which may fire before the state
// update commits in React StrictMode's simulate-unmount pass) reads the
@@ -153,6 +182,7 @@ export function useDraftPersistLifecycle({
setPendingImeta([]);
setSpoileredAttachmentUrls(new Set());
}
isRestoringContentRef.current = false;
return () => {
if (effectiveDraftKey) {
@@ -160,7 +190,9 @@ export function useDraftPersistLifecycle({
if (queuedAttachments.length > 0) {
saveQueuedAttachmentsForDraft?.(effectiveDraftKey, queuedAttachments);
}
const content = syncComposerContentFromEditor();
const content = emptyContentIsAuthoritativeRef.current
? ""
: syncComposerContentFromEditor();
persistDraft(
effectiveDraftKey,
content,
@@ -172,4 +204,29 @@ export function useDraftPersistLifecycle({
}
};
}, [effectiveDraftKey]);
const trackAuthoredContent = React.useCallback(
(content: string) => {
if (!effectiveDraftKey || isRestoringContentRef.current) return;
const authoritativeDraftKey = scopedDraftKey(effectiveDraftKey);
if (content.length > 0) {
authoritativelyClearedDraftKeys.delete(authoritativeDraftKey);
emptyContentIsAuthoritativeRef.current = false;
return;
}
authoritativelyClearedDraftKeys.add(authoritativeDraftKey);
emptyContentIsAuthoritativeRef.current = true;
persistDraft(
effectiveDraftKey,
content,
channelId ?? effectiveDraftKey,
[...pendingImetaForPersistRef.current],
[...spoileredAttachmentUrlsRef.current],
[],
);
},
[channelId, effectiveDraftKey, persistDraft, spoileredAttachmentUrlsRef],
);
return { trackAuthoredContent };
}

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");
});
@@ -1260,7 +1260,7 @@ test("selecting a persona mention reuses an existing persona agent", async ({
await expect(mentionChip).toHaveText("Fizz");
});
test("managed relay-profile agents with member roles use the agent address tray", async ({
test("managed relay-profile agents with member roles can be addressed explicitly", async ({
page,
}) => {
await installMockBridge(page, {
@@ -1286,11 +1286,20 @@ test("managed relay-profile agents with member roles use the agent address tray"
await input.fill("@char");
const dropdown = autocomplete(page);
await expect(dropdown.getByText("charlie")).toBeVisible();
await expect(dropdown.getByText("agent")).toBeVisible();
await input.press("Enter");
const charlieRow = dropdown.getByTestId(
`mention-suggestion-${TEST_IDENTITIES.charlie.pubkey}`,
);
await expect(charlieRow.getByText("charlie")).toBeVisible();
await expect(charlieRow.getByText("agent")).toBeVisible();
await charlieRow
.getByRole("button", {
name: "Automatically mention charlie",
exact: true,
})
.click();
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();
@@ -2617,7 +2626,7 @@ test("system member-joined rows render the joined person as a plain profile name
await expect(joinedPersonName).not.toHaveAttribute("data-mention");
});
test("selecting a managed non-member agent from a DM addresses it", async ({
test("a managed non-member agent from a DM can be addressed explicitly", async ({
page,
}) => {
await installMockBridge(page, {
@@ -2637,13 +2646,21 @@ test("selecting a managed non-member agent from a DM addresses it", async ({
await input.fill("@char");
const dropdown = autocomplete(page);
await expect(dropdown.getByText("charlie")).toBeVisible();
const charlieRow = dropdown.getByTestId(
`mention-suggestion-${TEST_IDENTITIES.charlie.pubkey}`,
);
await expect(charlieRow.getByText("charlie")).toBeVisible();
await expect(autocomplete(page)).toHaveCount(1);
await expect(input.locator(".mention-chip")).toHaveCount(0);
await input.press("Enter");
await charlieRow
.getByRole("button", {
name: "Automatically mention charlie",
exact: true,
})
.click();
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";
@@ -8,6 +8,20 @@ const CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
const AGENT_A = "a".repeat(64);
const AGENT_B = "b".repeat(64);
const THREAD_ROOT_ID = "mock-general-welcome";
const KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY =
"buzz.messages.keepMentionedAgentsPinned";
test.beforeEach(async ({ page }) => {
await page.addInitScript((storageKey) => {
window.localStorage.removeItem(storageKey);
}, KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY);
});
async function keepMentionedAgentsPinned(page: Page) {
await page.addInitScript((storageKey) => {
window.localStorage.setItem(storageKey, "true");
}, KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY);
}
async function seedTheme(page: Page, theme: string, accent = "#c0a2f1") {
await page.addInitScript(
@@ -28,6 +42,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 +71,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 +136,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 +190,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 +285,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 inserts a one-time agent mention by default", async ({ page }) => {
await installAudienceFixtures(page);
await openGeneral(page);
@@ -172,34 +301,86 @@ test("Tab keeps a manually selected agent as an inline mention", async ({
await expect(
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
).toHaveCount(0);
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 keepMentionedAgentsPinned(page);
await installAudienceFixtures(page);
await openGeneral(page);
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 +396,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 +416,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 +428,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 +441,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,17 +453,17 @@ 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);
await expect(
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
).toHaveCount(1);
).toHaveCount(0);
});
test("always-mentioned agents remain in the mention button while Enter-send resolves", async ({
@@ -296,15 +478,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 +524,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 +539,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 +571,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,13 +579,14 @@ 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 manual mention persists when automatic mentions are enabled", async ({
page,
}) => {
await keepMentionedAgentsPinned(page);
await installAudienceFixtures(page, { sendMessageDelayMs: 1_500 });
await openGeneral(page);
@@ -377,44 +598,77 @@ 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 keepMentionedAgentsPinned(page);
await installAudienceFixtures(page);
await openGeneral(page);
@@ -424,41 +678,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 +724,37 @@ 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 keepMentionedAgentsPinned(page);
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 +789,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"