mirror of
https://github.com/block/buzz.git
synced 2026-11-06 09:21:45 +00:00
Merge current main into projects CLI stack
Co-authored-by: Wrench <0eabe6ea5758c1e4c5b68cea4ac42b32c479072883cb28da8110b4a47c32b9a1@buzz.block.builderlab.xyz> Signed-off-by: Wrench <0eabe6ea5758c1e4c5b68cea4ac42b32c479072883cb28da8110b4a47c32b9a1@buzz.block.builderlab.xyz>
This commit is contained in:
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
151
.github/workflows/docker.yml
vendored
151
.github/workflows/docker.yml
vendored
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
16
crates/buzz-relay/src/build_info.rs
Normal file
16
crates/buzz-relay/src/build_info.rs
Normal 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")
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: ""
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: []
|
||||
|
||||
|
||||
@@ -357,7 +357,6 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
}),
|
||||
[activeChannel, currentPubkey, profiles],
|
||||
);
|
||||
|
||||
const handleWelcomeAddAgent = React.useCallback(() => {
|
||||
onAddAgent?.({
|
||||
beforeSend: () =>
|
||||
@@ -374,13 +373,14 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
onWelcomeAddAgent: onAddAgent ? handleWelcomeAddAgent : undefined,
|
||||
});
|
||||
const channelIntro = isHuddleTranscript ? null : standardChannelIntro;
|
||||
const { mainTimelineEntries, visibleMessages } = useChannelPaneMessages({
|
||||
activeChannel,
|
||||
isHuddleTranscript,
|
||||
messages,
|
||||
profiles,
|
||||
threadSummaries,
|
||||
});
|
||||
const { mainTimelineEntries, recentMentions, visibleMessages } =
|
||||
useChannelPaneMessages({
|
||||
activeChannel,
|
||||
isHuddleTranscript,
|
||||
messages,
|
||||
profiles,
|
||||
threadSummaries,
|
||||
});
|
||||
useRenderScopedReactionHydration({
|
||||
activeChannel,
|
||||
mainTimelineEntries,
|
||||
@@ -738,7 +738,7 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
: undefined
|
||||
}
|
||||
onSend={handleSendMessage}
|
||||
profiles={profiles}
|
||||
{...{ profiles, recentMentionPubkeys: recentMentions }}
|
||||
showBackgroundUploadProgress={false}
|
||||
placeholder={
|
||||
timeoutState.active
|
||||
@@ -847,7 +847,7 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
onScrollTargetSettled={resolveScrollTarget}
|
||||
onToggleReaction={onToggleReaction}
|
||||
onUnfollowThread={onUnfollowThread}
|
||||
profiles={profiles}
|
||||
{...{ profiles, recentMentionPubkeys: recentMentions }}
|
||||
replyTargetMessage={threadReplyTargetMessage}
|
||||
scrollTargetHighlights={!layoutScrollTargetId}
|
||||
scrollTargetId={layoutScrollTargetId ?? threadScrollTargetId}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
});
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
@@ -46,6 +46,7 @@ export type MentionCandidate = {
|
||||
secondaryLabel?: string | null;
|
||||
ownerPubkey?: string | null;
|
||||
isAgent: boolean;
|
||||
isActiveAgent?: boolean;
|
||||
isManagedAgent?: boolean;
|
||||
isGlobalSearchResult?: boolean;
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
114
desktop/src/features/messages/lib/recentMentionPubkeys.test.mjs
Normal file
114
desktop/src/features/messages/lib/recentMentionPubkeys.test.mjs
Normal 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],
|
||||
);
|
||||
});
|
||||
41
desktop/src/features/messages/lib/recentMentionPubkeys.ts
Normal file
41
desktop/src/features/messages/lib/recentMentionPubkeys.ts
Normal 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;
|
||||
}
|
||||
24
desktop/src/features/messages/lib/useActiveAgentPubkeys.ts
Normal file
24
desktop/src/features/messages/lib/useActiveAgentPubkeys.ts
Normal 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],
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
useChannelLinks,
|
||||
type ChannelSuggestion,
|
||||
} from "@/features/messages/lib/useChannelLinks";
|
||||
import { handleAgentSnapshotPaste } from "@/features/messages/lib/agentSnapshotClipboard";
|
||||
import { useComposerAutofocus } from "@/features/messages/lib/useComposerAutofocus";
|
||||
import { useDrafts } from "@/features/messages/lib/useDrafts";
|
||||
import { resolveSentDraftKey } from "@/features/messages/ui/draftSubmitKey";
|
||||
@@ -29,7 +28,6 @@ import {
|
||||
} from "@/features/messages/lib/backgroundMediaUploadStore";
|
||||
import { useMentions } from "@/features/messages/lib/useMentions";
|
||||
import {
|
||||
getPersistentAgentAudienceRevision,
|
||||
getPersistentAgentAudienceScope,
|
||||
usePersistentAgentAudience,
|
||||
} from "@/features/messages/lib/persistentAgentAudience";
|
||||
@@ -38,10 +36,6 @@ import {
|
||||
useKeepMentionedAgentsPinned,
|
||||
} from "@/features/messages/lib/autoPinMentionedAgentsPreference";
|
||||
import { useIdentityQuery } from "@/shared/api/hooks";
|
||||
import {
|
||||
hasMentionClipboardHtml,
|
||||
normalizeMentionClipboardHtml,
|
||||
} from "@/features/messages/lib/normalizeMentionClipboard";
|
||||
import { CUSTOM_EMOJI_NODE_NAME } from "@/features/messages/lib/customEmojiNode";
|
||||
import {
|
||||
type AutocompleteEdit,
|
||||
@@ -51,7 +45,6 @@ import {
|
||||
import { useLinkEditor } from "@/features/messages/lib/useLinkEditor";
|
||||
import { useComposerSpoilerParticles } from "@/features/messages/lib/useComposerSpoilerParticles";
|
||||
import { useTypingBroadcast } from "@/features/messages/useTypingBroadcast";
|
||||
import { getBuzzCodeBlockClipboardText } from "@/shared/lib/codeBlockClipboard";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { ChannelAutocomplete } from "./ChannelAutocomplete";
|
||||
import { ComposerReplyEditBanner } from "./ComposerReplyEditBanner";
|
||||
@@ -68,6 +61,7 @@ import { useAlwaysAddressShortcut } from "./useAlwaysAddressShortcut";
|
||||
import { useComposerMentionPicker } from "./useComposerMentionPicker";
|
||||
import { useAutoPinMentionedAgents } from "./useAutoPinMentionedAgents";
|
||||
import { useComposerContentState } from "./useComposerContentState";
|
||||
import { useComposerPasteHandler } from "./useComposerPasteHandler";
|
||||
import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot";
|
||||
import { submitMessageEdit } from "./submitMessageEdit";
|
||||
import { prepareBackgroundLinkPreviews } from "@/features/messages/lib/linkPreviewPreparationStore";
|
||||
@@ -98,6 +92,7 @@ function MessageComposerImpl({
|
||||
onSend,
|
||||
placeholder,
|
||||
profiles,
|
||||
recentMentionPubkeys,
|
||||
replyTarget = null,
|
||||
mediaController,
|
||||
showBackgroundUploadProgress = true,
|
||||
@@ -122,8 +117,6 @@ function MessageComposerImpl({
|
||||
} = useComposerLinkPreviews(previewContent, editTarget == null);
|
||||
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = React.useState(false);
|
||||
const [isFormattingOpen, setIsFormattingOpen] = React.useState(false);
|
||||
const [mentionOptionsOpenRequest, setMentionOptionsOpenRequest] =
|
||||
React.useState(0);
|
||||
const [spoileredAttachmentUrls, setSpoileredAttachmentUrls] = React.useState<
|
||||
Set<string>
|
||||
>(() => new Set());
|
||||
@@ -154,6 +147,7 @@ function MessageComposerImpl({
|
||||
} | null>(null);
|
||||
const mentions = useMentions(channelId, undefined, profiles, {
|
||||
channelType,
|
||||
recentMentionPubkeys,
|
||||
});
|
||||
const channelLinks = useChannelLinks();
|
||||
const customEmoji = useCustomEmoji();
|
||||
@@ -213,12 +207,16 @@ function MessageComposerImpl({
|
||||
const isSendingRef = React.useRef(isSending);
|
||||
const isUploadingRef = React.useRef(media.isUploading);
|
||||
const isSubmitLockedRef = React.useRef(false);
|
||||
const [isSubmitLocked, setIsSubmitLocked] = React.useState(false);
|
||||
const onSendRef = React.useRef(onSend);
|
||||
const onEditSaveRef = React.useRef(onEditSave);
|
||||
const onEditLastOwnMessageRef = React.useRef(onEditLastOwnMessage);
|
||||
const editTargetRef = React.useRef(editTarget);
|
||||
const extractMentionPubkeysRef = React.useRef(mentions.extractMentionPubkeys);
|
||||
const ownerPubkeyRef = React.useRef(ownerPubkey);
|
||||
const syncAddressedAgentsFromTextRef = React.useRef<(text: string) => void>(
|
||||
() => {},
|
||||
);
|
||||
disabledRef.current = disabled;
|
||||
isSendingRef.current = isSending;
|
||||
isUploadingRef.current = media.isUploading;
|
||||
@@ -276,6 +274,9 @@ function MessageComposerImpl({
|
||||
onUpdate: ({ cursor, linkPreviewContent, text }) => {
|
||||
setComposerContentFromText(text);
|
||||
setPreviewContent(linkPreviewContent);
|
||||
if (!isSubmitLockedRef.current && !editTargetRef.current) {
|
||||
syncAddressedAgentsFromTextRef.current(text);
|
||||
}
|
||||
mentions.updateMentionQuery(text, cursor);
|
||||
channelLinks.updateChannelQuery(text, cursor);
|
||||
emojiAutocomplete.updateEmojiQuery(text, cursor);
|
||||
@@ -297,14 +298,38 @@ function MessageComposerImpl({
|
||||
const persistentAudience = usePersistentAgentAudience(audienceScope);
|
||||
const keepMentionedAgentsPinned = useKeepMentionedAgentsPinned();
|
||||
const addressPulse = useAddressMentionPulse();
|
||||
const openMentionOptionsRef = React.useRef<() => void>(() => {});
|
||||
const addInlineAgentMentionsToAudience = useAutoPinMentionedAgents({
|
||||
const {
|
||||
confirmationTitle: autoPinConfirmationTitle,
|
||||
dismissConfirmation: dismissAutoPinConfirmation,
|
||||
promoteExplicitlyAddressedAgents,
|
||||
promoteMentionedAgents,
|
||||
turnOffConfirmation: turnOffAutoPinConfirmation,
|
||||
} = useAutoPinMentionedAgents({
|
||||
audienceScope,
|
||||
enabled: keepMentionedAgentsPinned,
|
||||
getDisplayName: mentions.getMentionDisplayName,
|
||||
onOpenOptions: () => openMentionOptionsRef.current(),
|
||||
onPulse: addressPulse.pulseOne,
|
||||
onTurnOff: () => setKeepMentionedAgentsPinned(false),
|
||||
});
|
||||
const restoreAddressedAgentMentionsRef = React.useRef<
|
||||
(
|
||||
pubkeys?: readonly string[],
|
||||
allowedUnpinnedPubkeys?: readonly string[],
|
||||
) => string
|
||||
>(() => "");
|
||||
const restoreAddressedAgentMentionsFrameRef = React.useRef<number | null>(
|
||||
null,
|
||||
);
|
||||
const channelIdRef = React.useRef(channelId);
|
||||
channelIdRef.current = channelId;
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
if (restoreAddressedAgentMentionsFrameRef.current !== null) {
|
||||
cancelAnimationFrame(restoreAddressedAgentMentionsFrameRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
const mentionSendFlow = useMentionSendFlow({
|
||||
channelId,
|
||||
channelLinks,
|
||||
@@ -315,8 +340,23 @@ function MessageComposerImpl({
|
||||
emojiAutocomplete,
|
||||
mentions,
|
||||
onAddressedAgentsSendStarted: addressPulse.pulseMany,
|
||||
onAddressedAgentsComposerCleared: (pubkeys) =>
|
||||
restoreAddressedAgentMentionsRef.current(pubkeys),
|
||||
onAddressedAgentsSendFailed: addressPulse.shakeMany,
|
||||
onInlineAgentMentionsSent: addInlineAgentMentionsToAudience,
|
||||
onAddressedAgentsSendSucceeded: (pubkeys, newlyPinnedPubkeys) => {
|
||||
if (newlyPinnedPubkeys.length === 0) return;
|
||||
const sentChannelId = channelId;
|
||||
if (restoreAddressedAgentMentionsFrameRef.current !== null) {
|
||||
cancelAnimationFrame(restoreAddressedAgentMentionsFrameRef.current);
|
||||
}
|
||||
restoreAddressedAgentMentionsFrameRef.current = requestAnimationFrame(
|
||||
() => {
|
||||
restoreAddressedAgentMentionsFrameRef.current = null;
|
||||
if (channelIdRef.current !== sentChannelId) return;
|
||||
restoreAddressedAgentMentionsRef.current(pubkeys, newlyPinnedPubkeys);
|
||||
},
|
||||
);
|
||||
},
|
||||
onPrepareSendChannel,
|
||||
onSendRef,
|
||||
richText,
|
||||
@@ -394,6 +434,7 @@ function MessageComposerImpl({
|
||||
edit.replaceToOffset,
|
||||
edit.insertText,
|
||||
edit.customEmojiShortcode,
|
||||
edit.preserveSelection,
|
||||
);
|
||||
},
|
||||
[richText.replacePlainTextRange],
|
||||
@@ -403,17 +444,30 @@ function MessageComposerImpl({
|
||||
lockedAgents,
|
||||
lockedAgentPubkeys,
|
||||
removeAddressedAgent,
|
||||
restoreAddressedAgentMentions,
|
||||
selectMentionSuggestion,
|
||||
syncAddressedAgentsFromText,
|
||||
toggleAlwaysAddressAgent,
|
||||
} = useAgentAddressLockPicker({
|
||||
applyAutocompleteEdit,
|
||||
audience: persistentAudience,
|
||||
audienceScope,
|
||||
mentions,
|
||||
onAddressAgentMention: (suggestion) =>
|
||||
promoteExplicitlyAddressedAgents({
|
||||
pubkeys: suggestion.pubkey ? [suggestion.pubkey] : [],
|
||||
}),
|
||||
onAutoPinAgentMention: (suggestion) => {
|
||||
promoteMentionedAgents({
|
||||
pubkeys: suggestion.pubkey ? [suggestion.pubkey] : [],
|
||||
});
|
||||
},
|
||||
onPulseAddressLock: addressPulse.pulseOne,
|
||||
profiles,
|
||||
richText,
|
||||
});
|
||||
restoreAddressedAgentMentionsRef.current = restoreAddressedAgentMentions;
|
||||
syncAddressedAgentsFromTextRef.current = syncAddressedAgentsFromText;
|
||||
const applyChannelInsert = React.useCallback(
|
||||
(suggestion: ChannelSuggestion) => {
|
||||
const { cursor } = richText.getPlainTextAndCursor();
|
||||
@@ -481,15 +535,12 @@ function MessageComposerImpl({
|
||||
() => openMentionPicker(false),
|
||||
[openMentionPicker],
|
||||
);
|
||||
const openMentionOptions = React.useCallback(() => {
|
||||
openMentionSettings();
|
||||
setMentionOptionsOpenRequest((request) => request + 1);
|
||||
}, [openMentionSettings]);
|
||||
openMentionOptionsRef.current = openMentionOptions;
|
||||
const handleAlwaysAddressShortcut = useAlwaysAddressShortcut({
|
||||
enabled: Boolean(audienceScope && editTarget == null),
|
||||
lockedAgent: lockedAgents[0],
|
||||
mentions,
|
||||
onOpenPicker: openMentionPicker,
|
||||
onSelect: selectMentionSuggestion,
|
||||
onToggle: toggleAlwaysAddressAgent,
|
||||
});
|
||||
const submitMessage = React.useCallback(async () => {
|
||||
@@ -561,6 +612,7 @@ function MessageComposerImpl({
|
||||
return;
|
||||
}
|
||||
isSubmitLockedRef.current = true;
|
||||
setIsSubmitLocked(true);
|
||||
onPreparingMentionSendChange?.(true);
|
||||
try {
|
||||
const preparedLinkPreviews = getReadyLinkPreviewTags().some(
|
||||
@@ -570,9 +622,6 @@ function MessageComposerImpl({
|
||||
: prepareBackgroundLinkPreviews(getLiveLinkPreviewCandidates());
|
||||
await mentionSendFlow.sendMessageWithMentionFlow({
|
||||
addressedAgentPubkeys: persistentAudience.pubkeys,
|
||||
audienceRevision: audienceScope
|
||||
? getPersistentAgentAudienceRevision(audienceScope)
|
||||
: 0,
|
||||
capturedChannelId: channelId,
|
||||
capturedThreadContext,
|
||||
pendingImeta: currentPendingImeta,
|
||||
@@ -589,6 +638,7 @@ function MessageComposerImpl({
|
||||
});
|
||||
} finally {
|
||||
isSubmitLockedRef.current = false;
|
||||
setIsSubmitLocked(false);
|
||||
onPreparingMentionSendChange?.(false);
|
||||
}
|
||||
}, [
|
||||
@@ -615,7 +665,6 @@ function MessageComposerImpl({
|
||||
syncComposerContentFromEditor,
|
||||
onCaptureSendContext,
|
||||
onPreparingMentionSendChange,
|
||||
audienceScope,
|
||||
persistentAudience.pubkeys,
|
||||
isEditSubmissionLocked,
|
||||
effectiveDraftKey,
|
||||
@@ -713,74 +762,12 @@ function MessageComposerImpl({
|
||||
onCancelEdit,
|
||||
],
|
||||
);
|
||||
// ── Media paste + ⌘K link shortcut via Tiptap editorProps ──────────
|
||||
const uploadFileRef = React.useRef(media.uploadFile);
|
||||
uploadFileRef.current = media.uploadFile;
|
||||
React.useEffect(() => {
|
||||
if (!richText.editor) return;
|
||||
richText.editor.setOptions({
|
||||
editorProps: {
|
||||
...richText.editor.options.editorProps,
|
||||
handlePaste: (_view, event) => {
|
||||
// --- File paste ---
|
||||
// Any actual file (image, video, document, …) pastes as an
|
||||
// attachment. String/text items have kind "string", so plain-text
|
||||
// and code-block paste fall through to the handlers below.
|
||||
const items = Array.from(event.clipboardData?.items ?? []);
|
||||
const mediaItem = items.find((item) => item.kind === "file");
|
||||
if (mediaItem) {
|
||||
const file = mediaItem.getAsFile();
|
||||
if (file) {
|
||||
void uploadFileRef.current(file);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// --- Buzz code-block paste ---
|
||||
// The code block copy button writes a small Buzz marker alongside
|
||||
// plain text. Use it to paste back as a literal code block so Markdown
|
||||
// parsing cannot reshape indentation, fence markers, or headings.
|
||||
const codeBlockText = getBuzzCodeBlockClipboardText(
|
||||
event.clipboardData,
|
||||
);
|
||||
if (codeBlockText !== null) {
|
||||
event.preventDefault();
|
||||
richText.editor
|
||||
?.chain()
|
||||
.focus()
|
||||
.insertContent([
|
||||
{
|
||||
type: "codeBlock",
|
||||
content:
|
||||
codeBlockText.length > 0
|
||||
? [{ type: "text", text: codeBlockText }]
|
||||
: [],
|
||||
},
|
||||
{ type: "paragraph" },
|
||||
])
|
||||
.run();
|
||||
scrollComposerToBottom();
|
||||
return true;
|
||||
}
|
||||
// Restore Buzz snapshots before normal styled-HTML normalization.
|
||||
if (handleAgentSnapshotPaste(event, media.setPendingImeta))
|
||||
return true;
|
||||
// Strip mention/channel wrappers that Tiptap would misread as bold.
|
||||
const html = event.clipboardData?.getData("text/html");
|
||||
if (html && hasMentionClipboardHtml(html)) {
|
||||
const cleanHtml = normalizeMentionClipboardHtml(html);
|
||||
event.preventDefault();
|
||||
_view.pasteHTML(cleanHtml);
|
||||
return true;
|
||||
}
|
||||
const plainText = event.clipboardData?.getData("text/plain") ?? "";
|
||||
if (plainText.includes("\n")) {
|
||||
scrollComposerToBottom();
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
});
|
||||
}, [media.setPendingImeta, richText.editor, scrollComposerToBottom]);
|
||||
useComposerPasteHandler({
|
||||
editor: richText.editor,
|
||||
scrollToBottom: scrollComposerToBottom,
|
||||
setPendingImeta: media.setPendingImeta,
|
||||
uploadFile: media.uploadFile,
|
||||
});
|
||||
// ── Send button state ───────────────────────────────────────────────
|
||||
const sendDisabled =
|
||||
composerDisabled ||
|
||||
@@ -858,6 +845,7 @@ function MessageComposerImpl({
|
||||
layoutMode === "standalone" &&
|
||||
"backdrop-blur-md dark:backdrop-blur-xl",
|
||||
)}
|
||||
data-submit-locked={isSubmitLocked ? "true" : "false"}
|
||||
data-testid="message-composer"
|
||||
onDragEnter={ownsDropZone ? media.handleDragEnter : undefined}
|
||||
onDragLeave={ownsDropZone ? media.handleDragLeave : undefined}
|
||||
@@ -904,7 +892,6 @@ function MessageComposerImpl({
|
||||
? setKeepMentionedAgentsPinned
|
||||
: undefined
|
||||
}
|
||||
openOptionsRequest={mentionOptionsOpenRequest}
|
||||
onToggleAlwaysAddressAgent={
|
||||
audienceScope && editTarget == null
|
||||
? toggleAlwaysAddressAgent
|
||||
@@ -973,6 +960,7 @@ function MessageComposerImpl({
|
||||
</div>
|
||||
<ComposerDockToolbar
|
||||
addressedAgents={editTarget == null ? lockedAgents : []}
|
||||
autoPinConfirmationTitle={autoPinConfirmationTitle}
|
||||
layoutMode={layoutMode}
|
||||
composerDisabled={composerDisabled}
|
||||
editor={richText.editor}
|
||||
@@ -983,6 +971,8 @@ function MessageComposerImpl({
|
||||
isSending={isSending || mentionSendFlow.isPreparingMentionSend}
|
||||
isUploading={media.isUploading}
|
||||
onCaptureSelection={handleCaptureSelection}
|
||||
onAutoPinConfirmationDismiss={dismissAutoPinConfirmation}
|
||||
onAutoPinConfirmationTurnOff={turnOffAutoPinConfirmation}
|
||||
onEmojiPickerOpenChange={setIsEmojiPickerOpen}
|
||||
onEmojiSelect={insertEmoji}
|
||||
onFormattingToggle={handleFormattingToggle}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
|
||||
import {
|
||||
depthGuideActionsEqual,
|
||||
numberArrayEqual,
|
||||
@@ -59,6 +58,7 @@ import { SentFromThreadLine } from "./SentFromThreadLine";
|
||||
import { WaveMessageAttachment } from "./WaveMessageAttachment";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
import { getAgentAddressMentionPubkeys } from "@/features/messages/lib/agentAddressMention.mjs";
|
||||
import { getVisibleAgentAddressPubkeys } from "@/features/messages/lib/getVisibleAgentAddressPubkeys";
|
||||
import { MessageAgentAddressPrefix } from "./MessageAgentAddressPrefix";
|
||||
|
||||
const DiffMessage = React.lazy(() => import("./DiffMessage"));
|
||||
@@ -280,10 +280,12 @@ export const MessageRow = React.memo(
|
||||
return Object.keys(values).length > 0 ? values : undefined;
|
||||
}, [isKnownAgentPubkey, mentionPubkeysByName]);
|
||||
const addressedAgentPubkeys = React.useMemo(() => {
|
||||
return getAgentAddressMentionPubkeys(message.tags).filter(
|
||||
isKnownAgentPubkey,
|
||||
return getVisibleAgentAddressPubkeys(
|
||||
message.body,
|
||||
getAgentAddressMentionPubkeys(message.tags).filter(isKnownAgentPubkey),
|
||||
mentionPubkeysByName,
|
||||
);
|
||||
}, [isKnownAgentPubkey, message.tags]);
|
||||
}, [isKnownAgentPubkey, mentionPubkeysByName, message.body, message.tags]);
|
||||
const agentAddressPrefix =
|
||||
addressedAgentPubkeys.length > 0 ? (
|
||||
<MessageAgentAddressPrefix
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
73
desktop/src/features/messages/ui/useComposerPasteHandler.ts
Normal file
73
desktop/src/features/messages/ui/useComposerPasteHandler.ts
Normal 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]);
|
||||
}
|
||||
@@ -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[];
|
||||
|
||||
@@ -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)
|
||||
|
||||
52
desktop/src/features/messages/ui/useMentionSendFlow.types.ts
Normal file
52
desktop/src/features/messages/ui/useMentionSendFlow.types.ts
Normal 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>>
|
||||
>;
|
||||
};
|
||||
@@ -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",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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}
|
||||
|
||||
31
desktop/src/shared/ui/markdown/ImageGalleryStatus.tsx
Normal file
31
desktop/src/shared/ui/markdown/ImageGalleryStatus.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", ``].join("\n"),
|
||||
undefined,
|
||||
threadTimestamp,
|
||||
);
|
||||
await emitMockMessage(
|
||||
page,
|
||||
["thread gallery reply one", ``].join("\n"),
|
||||
rootId,
|
||||
threadTimestamp + 1,
|
||||
);
|
||||
await emitMockMessage(
|
||||
page,
|
||||
["thread gallery reply two", ``].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", ``].join("\n"),
|
||||
undefined,
|
||||
timestamp,
|
||||
);
|
||||
const branchId = await emitMockMessage(
|
||||
page,
|
||||
["rendered branch", ``].join("\n"),
|
||||
rootId,
|
||||
timestamp + 1,
|
||||
);
|
||||
await emitMockMessage(
|
||||
page,
|
||||
["collapsed child", ``].join("\n"),
|
||||
branchId,
|
||||
timestamp + 2,
|
||||
);
|
||||
await emitMockMessage(
|
||||
page,
|
||||
["rendered sibling", ``].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}`,
|
||||
``,
|
||||
].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", ``].join("\n"),
|
||||
);
|
||||
await emitMockMessage(
|
||||
page,
|
||||
["separate gallery two", ``].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,
|
||||
}) => {
|
||||
|
||||
@@ -430,7 +430,7 @@ test("duplicate owned agents preserve provenance and exact pubkey selection", as
|
||||
await expect
|
||||
.poll(() => readOutgoingMentionPubkeys(page, "local"))
|
||||
.toEqual([managedPubkey]);
|
||||
await expect(input).toBeEmpty();
|
||||
await expect(input).toHaveText("@carl ");
|
||||
|
||||
await page.getByTestId(`composer-address-lock-${managedPubkey}`).click();
|
||||
await input.fill("@carl");
|
||||
@@ -720,7 +720,7 @@ test("defers agent mentions until DM members finish loading", async ({
|
||||
expect(commandCount(await readCommandLog(page), "add_channel_members")).toBe(
|
||||
commandCount(baselineCommands, "add_channel_members"),
|
||||
);
|
||||
await expect(input).toBeEmpty();
|
||||
await expect(input).toHaveText("@alice ");
|
||||
await expect(threadPanel).toContainText("before members resolve");
|
||||
});
|
||||
|
||||
@@ -1290,7 +1290,8 @@ test("managed relay-profile agents with member roles use the agent address tray"
|
||||
await expect(dropdown.getByText("agent")).toBeVisible();
|
||||
await input.press("Enter");
|
||||
|
||||
await expect(input).toBeEmpty();
|
||||
await expect(input).toHaveText("@charlie ");
|
||||
await expect(input.locator(".agent-mention-highlight")).toHaveText("charlie");
|
||||
await expect(
|
||||
page.getByTestId(`composer-address-lock-${TEST_IDENTITIES.charlie.pubkey}`),
|
||||
).toBeVisible();
|
||||
@@ -2642,8 +2643,8 @@ test("selecting a managed non-member agent from a DM addresses it", async ({
|
||||
await expect(input.locator(".mention-chip")).toHaveCount(0);
|
||||
await input.press("Enter");
|
||||
|
||||
await expect(input).toBeEmpty();
|
||||
await expect(input.locator(".mention-chip")).toHaveCount(0);
|
||||
await expect(input).toHaveText("@charlie ");
|
||||
await expect(input.locator(".agent-mention-highlight")).toHaveText("charlie");
|
||||
await expect(
|
||||
page.getByTestId(`composer-address-lock-${TEST_IDENTITIES.charlie.pubkey}`),
|
||||
).toBeVisible();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
import { waitForAnimations } from "../helpers/animations";
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
@@ -28,6 +28,9 @@ async function automaticallyMention(
|
||||
.getByTestId("mention-autocomplete")
|
||||
.getByRole("button", { name: `Automatically mention ${displayName}` })
|
||||
.click();
|
||||
await expect(composer.getByTestId("message-input")).toContainText(
|
||||
`@${displayName}`,
|
||||
);
|
||||
await composer.locator("[data-mention-picker-trigger]").click();
|
||||
}
|
||||
|
||||
@@ -54,7 +57,20 @@ function threadComposer(page: Page) {
|
||||
return page.getByTestId("thread-composer-overlay");
|
||||
}
|
||||
|
||||
async function pressPrimaryShift(page: Page, key: "Enter" | "M") {
|
||||
async function readComposerCaret(input: Locator) {
|
||||
return input.evaluate((element) => {
|
||||
const selection = window.getSelection();
|
||||
if (!selection?.anchorNode || !element.contains(selection.anchorNode)) {
|
||||
return null;
|
||||
}
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(element);
|
||||
range.setEnd(selection.anchorNode, selection.anchorOffset);
|
||||
return range.toString().length;
|
||||
});
|
||||
}
|
||||
|
||||
async function pressPrimaryShift(page: Page, key: "M") {
|
||||
const isMac = await page.evaluate(() =>
|
||||
/mac|iphone|ipad|ipod/i.test(navigator.platform),
|
||||
);
|
||||
@@ -106,11 +122,38 @@ async function readOutgoingMentionPubkeys(page: Page, content: string) {
|
||||
}, content);
|
||||
}
|
||||
|
||||
async function emitMockMessage(
|
||||
page: Page,
|
||||
content: string,
|
||||
mentionPubkeys: string[],
|
||||
) {
|
||||
await page.evaluate(
|
||||
({ body, mentions }) => {
|
||||
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "general",
|
||||
content: body,
|
||||
mentionPubkeys: mentions,
|
||||
});
|
||||
},
|
||||
{ body: content, mentions: mentionPubkeys },
|
||||
);
|
||||
}
|
||||
|
||||
async function installAudienceFixtures(
|
||||
page: Page,
|
||||
options: {
|
||||
deferredComposerUploads?: boolean;
|
||||
sendMessageDelayMs?: number;
|
||||
sendMessageErrors?: string[];
|
||||
uploadDelayMs?: number;
|
||||
uploadDescriptors?: Array<{
|
||||
filename: string;
|
||||
sha256: string;
|
||||
size: number;
|
||||
type: string;
|
||||
uploaded: number;
|
||||
url: string;
|
||||
}>;
|
||||
usersBatchDelayMs?: number;
|
||||
} = {},
|
||||
) {
|
||||
@@ -133,6 +176,80 @@ async function installAudienceFixtures(
|
||||
});
|
||||
}
|
||||
|
||||
test("keeps a queued-attachment send locked through upload and send settlement", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installAudienceFixtures(page, {
|
||||
deferredComposerUploads: true,
|
||||
uploadDelayMs: 2_000,
|
||||
sendMessageDelayMs: 2_000,
|
||||
uploadDescriptors: [
|
||||
{
|
||||
filename: "delayed-video.mp4",
|
||||
sha256: "d".repeat(64),
|
||||
size: 16,
|
||||
type: "video/mp4",
|
||||
uploaded: 1,
|
||||
url: `https://mock.relay/media/${"d".repeat(64)}.mp4`,
|
||||
},
|
||||
],
|
||||
});
|
||||
await openGeneral(page);
|
||||
|
||||
const composer = channelComposer(page);
|
||||
const composerForm = composer.getByTestId("message-composer");
|
||||
const input = composer.getByTestId("message-input");
|
||||
await input.fill("delayed upload");
|
||||
|
||||
const [chooser] = await Promise.all([
|
||||
page.waitForEvent("filechooser"),
|
||||
composer.getByRole("button", { name: "Attach file" }).click(),
|
||||
]);
|
||||
await chooser.setFiles({
|
||||
buffer: Buffer.from("delayed video"),
|
||||
mimeType: "video/mp4",
|
||||
name: "delayed-video.mp4",
|
||||
});
|
||||
|
||||
const sendAttempts = () =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
window.__BUZZ_E2E_COMMAND_LOG__?.filter(
|
||||
(entry) => entry.command === "send_channel_message",
|
||||
).length ?? 0,
|
||||
);
|
||||
const sendAttemptsBefore = await sendAttempts();
|
||||
await input.press("Enter");
|
||||
await expect(composer.getByTestId("composer-upload-progress")).toBeVisible();
|
||||
|
||||
// Observe the synchronous lock itself after upload has started. The exact
|
||||
// premature-release mutation (`void uploadSettled; await Promise.resolve()`)
|
||||
// has already cleared this attribute by this boundary, before any retryable
|
||||
// downstream command or restored-audience timing can obscure the defect.
|
||||
await expect(composerForm).toHaveAttribute("data-submit-locked", "true");
|
||||
await input.press("Enter");
|
||||
await input.press("Enter");
|
||||
await expect(composerForm).toHaveAttribute("data-submit-locked", "true");
|
||||
|
||||
await expect.poll(sendAttempts).toBe(sendAttemptsBefore + 1);
|
||||
|
||||
// Command entry marks the independently delayed finishSend() window. Keep
|
||||
// the synchronous lock held and fence repeated submits until it settles.
|
||||
await expect(composer.getByTestId("composer-upload-progress")).toBeVisible();
|
||||
await expect(composerForm).toHaveAttribute("data-submit-locked", "true");
|
||||
await input.press("Enter");
|
||||
await input.press("Enter");
|
||||
await expect(composerForm).toHaveAttribute("data-submit-locked", "true");
|
||||
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("message-row")
|
||||
.filter({ hasText: "delayed upload" })
|
||||
.last(),
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
await expect(composerForm).toHaveAttribute("data-submit-locked", "false");
|
||||
});
|
||||
|
||||
test("automatically mentions multiple agents from the mention picker", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -154,9 +271,7 @@ test("automatically mentions multiple agents from the mention picker", async ({
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("Tab keeps a manually selected agent as an inline mention", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("Tab immediately selects a manually mentioned agent", async ({ page }) => {
|
||||
await installAudienceFixtures(page);
|
||||
await openGeneral(page);
|
||||
|
||||
@@ -169,12 +284,21 @@ test("Tab keeps a manually selected agent as an inline mention", async ({
|
||||
|
||||
await expect(input).toHaveText("@Morgarita ");
|
||||
await expect(composer.getByTestId("mention-autocomplete")).toHaveCount(0);
|
||||
await expect(
|
||||
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toBeVisible();
|
||||
const selectAllShortcut = await page.evaluate(() =>
|
||||
/mac|iphone|ipad|ipod/i.test(navigator.platform) ? "Meta+A" : "Control+A",
|
||||
);
|
||||
await input.press(selectAllShortcut);
|
||||
await input.press("Backspace");
|
||||
await expect(input).toHaveText("");
|
||||
await expect(
|
||||
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("primary+Shift+Enter opens the picker, then pins the highlighted agent", async ({
|
||||
test("primary+Shift+M addresses the default agent, then selects the highlighted agent", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installAudienceFixtures(page);
|
||||
@@ -183,23 +307,65 @@ test("primary+Shift+Enter opens the picker, then pins the highlighted agent", as
|
||||
const composer = channelComposer(page);
|
||||
const input = composer.getByTestId("message-input");
|
||||
await input.fill("draft text");
|
||||
await pressPrimaryShift(page, "Enter");
|
||||
await pressPrimaryShift(page, "M");
|
||||
|
||||
const menu = composer.getByTestId("mention-autocomplete");
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(input).toHaveText("@alice draft text");
|
||||
await expect(input.locator(".agent-mention-highlight")).toHaveText("alice");
|
||||
await expect(
|
||||
page.getByTestId("composer-auto-pin-confirmation"),
|
||||
).toContainText("alice will be mentioned automatically");
|
||||
await pressPrimaryShift(page, "M");
|
||||
await expect(input).toHaveText("draft text");
|
||||
await pressPrimaryShift(page, "M");
|
||||
await expect(input).toHaveText("@alice draft text");
|
||||
await expect(
|
||||
composer
|
||||
.getByTestId("composer-address-locks")
|
||||
.getByRole("button", { name: /^Stop automatically mentioning / }),
|
||||
).toHaveCount(1);
|
||||
|
||||
await input.fill("@Mor");
|
||||
await expect(menu.getByTestId(`mention-suggestion-${AGENT_A}`)).toHaveClass(
|
||||
await input.fill("@Vog");
|
||||
const menu = composer.getByTestId("mention-autocomplete");
|
||||
await expect(menu.getByTestId(`mention-suggestion-${AGENT_B}`)).toHaveClass(
|
||||
/(?:^|\s)bg-accent(?:\s|$)/,
|
||||
);
|
||||
await pressPrimaryShift(page, "Enter");
|
||||
await pressPrimaryShift(page, "M");
|
||||
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(input).toHaveText("");
|
||||
await expect(menu).toHaveCount(0);
|
||||
await expect(input).toHaveText("@Vogue ");
|
||||
await expect(
|
||||
composer.getByTestId(`composer-address-lock-${AGENT_B}`),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toBeVisible();
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
composer
|
||||
.getByTestId("composer-address-locks")
|
||||
.getByRole("button", { name: /^Stop automatically mentioning / }),
|
||||
).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("primary+Shift+M favors the most recently mentioned eligible agent", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installAudienceFixtures(page);
|
||||
await openGeneral(page);
|
||||
await emitMockMessage(page, "Please ask Vogue", [AGENT_B]);
|
||||
|
||||
const input = channelComposer(page).getByTestId("message-input");
|
||||
await input.fill("draft text");
|
||||
await input.press("ArrowLeft");
|
||||
await input.press("ArrowLeft");
|
||||
await expect.poll(() => readComposerCaret(input)).toBe(8);
|
||||
await pressPrimaryShift(page, "M");
|
||||
|
||||
await expect(input).toHaveText("@Vogue draft text");
|
||||
await expect.poll(() => readComposerCaret(input)).toBe(15);
|
||||
await pressPrimaryShift(page, "M");
|
||||
await expect(input).toHaveText("draft text");
|
||||
await pressPrimaryShift(page, "M");
|
||||
await expect(input).toHaveText("@Vogue draft text");
|
||||
});
|
||||
|
||||
test("the mention button opens settings and can undo an address", async ({
|
||||
@@ -215,11 +381,11 @@ test("the mention button opens settings and can undo an address", async ({
|
||||
name: "Manage automatic agent mentions",
|
||||
});
|
||||
|
||||
await input.fill("draft text");
|
||||
await input.type("draft text");
|
||||
await ingress.click();
|
||||
const menu = composer.getByTestId("mention-autocomplete");
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(input).toHaveText("draft text");
|
||||
await expect(input).toHaveText("@Morgarita draft text");
|
||||
await page.getByTestId("mention-options-trigger").click();
|
||||
await expect(
|
||||
page.getByTestId("mention-keep-agents-pinned-toggle"),
|
||||
@@ -235,7 +401,7 @@ test("the mention button opens settings and can undo an address", async ({
|
||||
if (!layerBox || !optionsBox) throw new Error("Mention tray is not laid out");
|
||||
await page.mouse.click(layerBox.x + 4, optionsBox.y + optionsBox.height / 2);
|
||||
await expect(menu).toHaveCount(0);
|
||||
await expect(input).toHaveText("draft text");
|
||||
await expect(input).toHaveText("@Morgarita draft text");
|
||||
await ingress.click();
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(page.getByTestId("mention-options-trigger")).toHaveAttribute(
|
||||
@@ -247,7 +413,7 @@ test("the mention button opens settings and can undo an address", async ({
|
||||
).toHaveCount(0);
|
||||
await ingress.click();
|
||||
await expect(menu).toHaveCount(0);
|
||||
await input.fill("");
|
||||
await expect(input).toHaveText("@Morgarita draft text");
|
||||
await ingress.click();
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(page.getByTestId("user-profile-panel")).toHaveCount(0);
|
||||
@@ -260,10 +426,11 @@ test("the mention button opens settings and can undo an address", async ({
|
||||
await menu
|
||||
.getByRole("button", { name: "Stop automatically mentioning Morgarita" })
|
||||
.click();
|
||||
await expect(input).toHaveText("");
|
||||
await expect(input).toHaveText("draft text");
|
||||
await expect(
|
||||
composer.getByRole("button", { name: "Mention someone" }),
|
||||
).toBeVisible();
|
||||
await input.fill("");
|
||||
|
||||
await menu
|
||||
.getByRole("button", { name: "Mention Morgarita", exact: true })
|
||||
@@ -271,11 +438,11 @@ test("the mention button opens settings and can undo an address", async ({
|
||||
await expect(input).toHaveText("@Morgarita ");
|
||||
await expect(
|
||||
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toHaveCount(0);
|
||||
).toBeVisible();
|
||||
|
||||
await input.type("later");
|
||||
await input.press("Enter");
|
||||
await expect(input).toHaveText("");
|
||||
await expect(input).toHaveText("@Morgarita ");
|
||||
await expect
|
||||
.poll(() => readOutgoingMentionPubkeys(page, "@Morgarita later"))
|
||||
.toContain(AGENT_A);
|
||||
@@ -296,15 +463,41 @@ test("always-mentioned agents remain in the mention button while Enter-send reso
|
||||
const composer = threadComposer(page);
|
||||
await automaticallyMention(composer, "Morgarita");
|
||||
const input = composer.getByTestId("message-input");
|
||||
const send = composer.getByTestId("send-message");
|
||||
await input.evaluate((element) => {
|
||||
const snapshots = [element.textContent ?? ""];
|
||||
new MutationObserver(() =>
|
||||
snapshots.push(element.textContent ?? ""),
|
||||
).observe(element, { childList: true, characterData: true, subtree: true });
|
||||
(
|
||||
window as typeof window & { __BUZZ_COMPOSER_TEXT_SNAPSHOTS__?: string[] }
|
||||
).__BUZZ_COMPOSER_TEXT_SNAPSHOTS__ = snapshots;
|
||||
});
|
||||
const avatar = composer.getByTestId(`composer-address-lock-${AGENT_A}`);
|
||||
const initialPulseVersion = Number(
|
||||
await avatar.getAttribute("data-pulse-version"),
|
||||
);
|
||||
await input.fill("hello");
|
||||
await input.type("hello");
|
||||
await input.evaluate((element) => {
|
||||
const snapshots = (
|
||||
window as typeof window & { __BUZZ_COMPOSER_TEXT_SNAPSHOTS__?: string[] }
|
||||
).__BUZZ_COMPOSER_TEXT_SNAPSHOTS__;
|
||||
snapshots?.splice(0, snapshots.length, element.textContent ?? "");
|
||||
});
|
||||
await input.press("Enter");
|
||||
|
||||
await expect(input).toHaveText("", { timeout: 500 });
|
||||
await expect(input).toHaveText("@Morgarita ", { timeout: 500 });
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
(
|
||||
window as typeof window & {
|
||||
__BUZZ_COMPOSER_TEXT_SNAPSHOTS__?: string[];
|
||||
}
|
||||
).__BUZZ_COMPOSER_TEXT_SNAPSHOTS__ ?? [],
|
||||
),
|
||||
)
|
||||
.not.toContain("");
|
||||
await expect(avatar).toHaveAttribute(
|
||||
"data-pulse-version",
|
||||
String(initialPulseVersion + 1),
|
||||
@@ -316,10 +509,10 @@ test("always-mentioned agents remain in the mention button while Enter-send reso
|
||||
await expect(input).toBeFocused();
|
||||
await expect(composer.getByTestId("mention-autocomplete")).toHaveCount(0);
|
||||
|
||||
await expect(send).toBeDisabled();
|
||||
await expect
|
||||
.poll(() => readOutgoingMentionPubkeys(page, "hello"))
|
||||
.poll(() => readOutgoingMentionPubkeys(page, "@Morgarita hello"))
|
||||
.toContain(AGENT_A);
|
||||
await expect(input).toHaveText("@Morgarita ");
|
||||
|
||||
const sentRow = page
|
||||
.getByTestId("message-row")
|
||||
@@ -331,6 +524,18 @@ test("always-mentioned agents remain in the mention button while Enter-send reso
|
||||
);
|
||||
await expect(addressPrefix).toBeVisible();
|
||||
await expect(addressPrefix).toHaveText("Morgarita");
|
||||
|
||||
await input.type("follow up");
|
||||
await input.press("Enter");
|
||||
const inlineMentionRow = page
|
||||
.getByTestId("message-row")
|
||||
.filter({ hasText: "follow up" })
|
||||
.last();
|
||||
await expect(
|
||||
inlineMentionRow.locator("[data-mention].agent-mention-highlight", {
|
||||
hasText: "Morgarita",
|
||||
}),
|
||||
).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("a failed always-mentioned send shakes the composer avatar", async ({
|
||||
@@ -351,7 +556,7 @@ test("a failed always-mentioned send shakes the composer avatar", async ({
|
||||
);
|
||||
await expect(avatar).toHaveAttribute("data-shake-version", "0");
|
||||
|
||||
await input.fill("please retry");
|
||||
await input.type("please retry");
|
||||
await input.press("Enter");
|
||||
|
||||
await expect(avatar).toHaveAttribute(
|
||||
@@ -359,11 +564,11 @@ test("a failed always-mentioned send shakes the composer avatar", async ({
|
||||
String(initialPulseVersion + 1),
|
||||
{ timeout: 500 },
|
||||
);
|
||||
await expect(input).toHaveText("please retry");
|
||||
await expect(input).toHaveText("@Morgarita please retry");
|
||||
await expect(avatar).toHaveAttribute("data-shake-version", "1");
|
||||
});
|
||||
|
||||
test("a manually mentioned agent becomes selected after the message sends", async ({
|
||||
test("a manually mentioned agent becomes selected immediately", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installAudienceFixtures(page, { sendMessageDelayMs: 1_500 });
|
||||
@@ -377,42 +582,74 @@ test("a manually mentioned agent becomes selected after the message sends", asyn
|
||||
await expect(input).toHaveText("@Morgarita ");
|
||||
await expect(
|
||||
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toBeVisible();
|
||||
|
||||
const autoPinConfirmation = page.getByTestId(
|
||||
"composer-auto-pin-confirmation",
|
||||
);
|
||||
await expect(autoPinConfirmation).toContainText(
|
||||
"Morgarita will be mentioned automatically",
|
||||
);
|
||||
await expect(autoPinConfirmation).not.toContainText(
|
||||
"Future messages in this channel will include this agent.",
|
||||
);
|
||||
await expect(autoPinConfirmation).toHaveAttribute("data-side", "right");
|
||||
await expect(autoPinConfirmation.locator("span")).toHaveCSS(
|
||||
"white-space",
|
||||
"nowrap",
|
||||
);
|
||||
await expect(
|
||||
page
|
||||
.locator("[data-sonner-toast]")
|
||||
.filter({ hasText: "Morgarita will be mentioned automatically" }),
|
||||
).toHaveCount(0);
|
||||
const addressControlBox = await composer
|
||||
.getByTestId("composer-address-locks")
|
||||
.locator("..")
|
||||
.boundingBox();
|
||||
const confirmationBox = await autoPinConfirmation.boundingBox();
|
||||
expect(addressControlBox).not.toBeNull();
|
||||
expect(confirmationBox).not.toBeNull();
|
||||
if (!addressControlBox || !confirmationBox) {
|
||||
throw new Error("Automatic mention confirmation is not laid out");
|
||||
}
|
||||
expect(confirmationBox.x).toBeGreaterThan(
|
||||
addressControlBox.x + addressControlBox.width,
|
||||
);
|
||||
const turnOffAction = autoPinConfirmation.getByRole("button", {
|
||||
name: "Turn off",
|
||||
});
|
||||
await expect(turnOffAction).toHaveRole("button");
|
||||
await expect(turnOffAction).toHaveText("Turn off");
|
||||
|
||||
await input.press("Escape");
|
||||
await expect(autoPinConfirmation).toHaveCount(0);
|
||||
|
||||
await input.type("hello");
|
||||
await input.press("Enter");
|
||||
|
||||
await expect(input).toHaveText("", { timeout: 500 });
|
||||
await expect(input.locator("[data-placeholder]").first()).toHaveAttribute(
|
||||
"data-placeholder",
|
||||
"Message #general",
|
||||
{ timeout: 500 },
|
||||
);
|
||||
await expect(input).toHaveText("@Morgarita ", { timeout: 2_500 });
|
||||
await expect(input.locator("[data-placeholder]")).toHaveCount(0);
|
||||
await expect(input).toBeFocused();
|
||||
await expect(
|
||||
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toBeVisible({ timeout: 2_500 });
|
||||
const autoPinToast = page
|
||||
.locator("[data-sonner-toast][data-removed='false']")
|
||||
.filter({ hasText: "Morgarita will be mentioned automatically" });
|
||||
await expect(autoPinToast).not.toContainText(
|
||||
"Future messages in this channel will include this agent.",
|
||||
);
|
||||
const undoAction = autoPinToast.locator("[data-action]");
|
||||
await expect(undoAction).toHaveRole("button");
|
||||
await expect(undoAction).toHaveText("Undo");
|
||||
await expect(autoPinConfirmation).toHaveCount(0);
|
||||
await expect
|
||||
.poll(() => readOutgoingMentionPubkeys(page, "@Morgarita hello"))
|
||||
.toContain(AGENT_A);
|
||||
|
||||
await input.fill("follow up");
|
||||
await expect(
|
||||
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toHaveCount(0);
|
||||
await input.press("Enter");
|
||||
await expect
|
||||
.poll(() => readOutgoingMentionPubkeys(page, "follow up"))
|
||||
.toContain(AGENT_A);
|
||||
.not.toContain(AGENT_A);
|
||||
});
|
||||
|
||||
test("the auto-pin toast can undo and the picker can restore the agent", async ({
|
||||
test("the auto-pin popover can turn off automatic agent mentions", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installAudienceFixtures(page);
|
||||
@@ -424,41 +661,32 @@ test("the auto-pin toast can undo and the picker can restore the agent", async (
|
||||
await expect(composer.getByTestId("mention-autocomplete")).toBeVisible();
|
||||
await input.press("Tab");
|
||||
await expect(input).toHaveText("@Morgarita ");
|
||||
await input.press("Space");
|
||||
await input.type("undo me");
|
||||
await input.press("Enter");
|
||||
|
||||
await expect(
|
||||
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toBeVisible();
|
||||
const autoPinToast = page
|
||||
.locator("[data-sonner-toast][data-removed='false']")
|
||||
.filter({ hasText: "Morgarita will be mentioned automatically" });
|
||||
await autoPinToast.locator("[data-action]").click();
|
||||
const autoPinConfirmation = page.getByTestId(
|
||||
"composer-auto-pin-confirmation",
|
||||
);
|
||||
await expect(autoPinConfirmation).toContainText(
|
||||
"Morgarita will be mentioned automatically",
|
||||
);
|
||||
await autoPinConfirmation.getByRole("button", { name: "Turn off" }).click();
|
||||
|
||||
await expect(
|
||||
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toHaveCount(0);
|
||||
await expect(autoPinConfirmation).toHaveCount(0);
|
||||
|
||||
await composer.getByTestId("message-insert-mention").click();
|
||||
await expect(composer.getByTestId("mention-autocomplete")).toBeVisible();
|
||||
await expect(composer.getByTestId("mention-options-trigger")).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
);
|
||||
await composer.getByTestId("mention-options-trigger").click();
|
||||
await expect(
|
||||
composer.getByTestId("mention-keep-agents-pinned-toggle"),
|
||||
).toBeVisible();
|
||||
).toHaveAttribute("data-state", "unchecked");
|
||||
|
||||
await input.press("Escape");
|
||||
await expect(composer.getByTestId("mention-autocomplete")).toHaveCount(0);
|
||||
await composer.locator("[data-mention-picker-trigger]").click();
|
||||
await composer
|
||||
.getByTestId("mention-autocomplete")
|
||||
.getByRole("button", { name: "Mention Morgarita", exact: true })
|
||||
.click();
|
||||
await expect(input).toHaveText("");
|
||||
await expect(
|
||||
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("channel automatic mentions carry into threads and stay synchronized", async ({
|
||||
@@ -479,12 +707,36 @@ test("channel automatic mentions carry into threads and stay synchronized", asyn
|
||||
await expect(threadAutomaticMention).toBeVisible();
|
||||
|
||||
await threadComposer(page)
|
||||
.getByRole("button", { name: "Stop automatically mentioning Morgarita" })
|
||||
.getByTestId(`composer-address-lock-remove-${AGENT_A}`)
|
||||
.click();
|
||||
await expect(threadAutomaticMention).toHaveCount(0);
|
||||
await expect(channelAutomaticMention).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("reduced motion removes addressed agents without spatial animation", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await installAudienceFixtures(page);
|
||||
await openGeneral(page);
|
||||
|
||||
const composer = channelComposer(page);
|
||||
const input = composer.getByTestId("message-input");
|
||||
await input.fill("@Mor");
|
||||
await expect(composer.getByTestId("mention-autocomplete")).toBeVisible();
|
||||
await input.press("Tab");
|
||||
const removeButton = composer.getByTestId(
|
||||
`composer-address-lock-remove-${AGENT_A}`,
|
||||
);
|
||||
await expect(removeButton).toBeVisible();
|
||||
await expect(removeButton).toHaveAttribute("style", /opacity: 1/);
|
||||
await expect(removeButton).toHaveCSS("transform", "none");
|
||||
|
||||
await removeButton.click();
|
||||
await expect(input).toHaveText("@Morgarita ");
|
||||
await expect(removeButton).toHaveCount(0);
|
||||
});
|
||||
|
||||
for (const theme of ["buzz", "buzz-dark"]) {
|
||||
test(`captures the mention-button placement in ${theme}`, async ({
|
||||
page,
|
||||
@@ -519,3 +771,45 @@ test("the mention-button placement fits the narrow composer", async ({
|
||||
await waitForAnimations(page);
|
||||
await composer.screenshot({ path: `${SHOTS}/narrow-mention-button.png` });
|
||||
});
|
||||
|
||||
test("captures the lightweight auto-pin popover", async ({ page }) => {
|
||||
await seedTheme(page, "buzz-dark");
|
||||
await installAudienceFixtures(page);
|
||||
await openGeneral(page);
|
||||
|
||||
const composer = channelComposer(page);
|
||||
const input = composer.getByTestId("message-input");
|
||||
await input.fill("draft text");
|
||||
await pressPrimaryShift(page, "M");
|
||||
await expect(input).toHaveText("@alice draft text");
|
||||
|
||||
const addressControl = composer
|
||||
.getByTestId("composer-address-locks")
|
||||
.locator("..");
|
||||
const confirmation = page.getByTestId("composer-auto-pin-confirmation");
|
||||
await expect(confirmation).toContainText(
|
||||
"alice will be mentioned automatically",
|
||||
);
|
||||
await waitForAnimations(page);
|
||||
|
||||
const addressBox = await addressControl.boundingBox();
|
||||
const confirmationBox = await confirmation.boundingBox();
|
||||
expect(addressBox).not.toBeNull();
|
||||
expect(confirmationBox).not.toBeNull();
|
||||
if (!addressBox || !confirmationBox) {
|
||||
throw new Error("Popover is not laid out");
|
||||
}
|
||||
|
||||
const left = addressBox.x - 14;
|
||||
const top = Math.min(addressBox.y, confirmationBox.y) - 14;
|
||||
const right = confirmationBox.x + confirmationBox.width + 14;
|
||||
const bottom =
|
||||
Math.max(
|
||||
addressBox.y + addressBox.height,
|
||||
confirmationBox.y + confirmationBox.height,
|
||||
) + 14;
|
||||
await page.screenshot({
|
||||
path: `${SHOTS}/auto-pin-popover-dark.png`,
|
||||
clip: { x: left, y: top, width: right - left, height: bottom - top },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
68
docs/deployment-identity.md
Normal file
68
docs/deployment-identity.md
Normal 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.
|
||||
26
scripts/create-deployment-eligibility-predicate.jq
Normal file
26
scripts/create-deployment-eligibility-predicate.jq
Normal 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
|
||||
}
|
||||
}
|
||||
9
scripts/select-qualified-ci-run.jq
Normal file
9
scripts/select-qualified-ci-run.jq
Normal 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")
|
||||
108
scripts/test-relay-image-eligibility-workflow.sh
Executable file
108
scripts/test-relay-image-eligibility-workflow.sh
Executable 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"
|
||||
Reference in New Issue
Block a user