chore(desktop): merge origin/main into workspace-scoped-agent-store

Adopt spawn_hash → spawn_snapshot rename from main; migrate spawn_config_hash: 0
test fixtures to prospective_spawn_config_snapshot() calls in global_agent_config
tests, epoch tests, and runtime_commands tests.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
2026-08-04 19:13:15 -04:00
428 changed files with 29919 additions and 7327 deletions

View File

@@ -0,0 +1,93 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
AuthOkTracker,
MAX_CONSECUTIVE_AUTH_REJECTIONS,
} from "./relayAuthPolicy.ts";
test("success resolves authenticated and resets the streak", () => {
const tracker = new AuthOkTracker();
tracker.record(false, "auth-required: verification failed");
tracker.record(false, "auth-required: verification failed");
assert.equal(tracker.record(true, ""), "authenticated");
// Streak reset: the next rejection starts a fresh count.
assert.equal(
tracker.record(false, "auth-required: verification failed"),
"retry",
);
});
test("already-authenticated rejection is treated as authenticated (duplicate-AUTH race)", () => {
const tracker = new AuthOkTracker();
assert.equal(
tracker.record(false, "auth-required: already authenticated"),
"authenticated",
);
});
test("restricted rejections latch terminal immediately", () => {
for (const message of [
"restricted: not a relay member",
"restricted: you are banned",
]) {
assert.equal(new AuthOkTracker().record(false, message), "terminal");
}
});
test("the relay's actual ban string latches terminal immediately", () => {
// Exact string emitted by crates/buzz-relay/src/handlers/auth.rs at the
// ban seam. A known-permanent ban must never enter the retry loop.
assert.equal(
new AuthOkTracker().record(
false,
"blocked: you are banned from this community",
),
"terminal",
);
});
test("verification failures retry with backoff (clock skew, DB fail-closed)", () => {
const tracker = new AuthOkTracker();
assert.equal(
tracker.record(false, "auth-required: verification failed"),
"retry",
);
});
test("unknown rejection reasons retry rather than latch", () => {
assert.equal(new AuthOkTracker().record(false, ""), "retry");
assert.equal(new AuthOkTracker().record(false, "error: internal"), "retry");
});
test("consecutive rejections latch terminal at the cap", () => {
const tracker = new AuthOkTracker();
let decision = "retry";
for (let i = 0; i < MAX_CONSECUTIVE_AUTH_REJECTIONS; i++) {
decision = tracker.record(false, "auth-required: verification failed");
}
assert.equal(decision, "terminal");
});
test("reset grants a fresh retry streak after explicit re-engagement", () => {
const tracker = new AuthOkTracker();
for (let i = 0; i < MAX_CONSECUTIVE_AUTH_REJECTIONS; i++) {
tracker.record(false, "auth-required: verification failed");
}
tracker.reset();
assert.equal(
tracker.record(false, "auth-required: verification failed"),
"retry",
);
});
test("already-authenticated wins even past the cap (session is usable)", () => {
const tracker = new AuthOkTracker();
for (let i = 0; i < MAX_CONSECUTIVE_AUTH_REJECTIONS + 1; i++) {
tracker.record(false, "auth-required: verification failed");
}
assert.equal(
tracker.record(false, "auth-required: already authenticated"),
"authenticated",
);
});

View File

@@ -0,0 +1,65 @@
/**
* Policy for NIP-42 AUTH `OK` responses (G2 of the CMD+R gap audit).
*
* Historically ANY auth `OK false` latched the session terminal — no
* reconnect until explicit user re-engagement. But the relay sends
* `OK false` for conditions that are transient from the client's side:
*
* - `auth-required: already authenticated` — a duplicate/late AUTH event on
* a connection that is in fact authenticated. The session is usable;
* treat it as authenticated.
* - `auth-required: verification failed` — covers ±60s clock-skew rejects
* and the relay's fail-closed allowlist DB lookup errors, both of which
* can clear on retry.
*
* Only `restricted:` and `blocked:` rejections (not a relay member / banned)
* are known permanent. Everything else retries with normal backoff, but
* latches terminal after `MAX_CONSECUTIVE_AUTH_REJECTIONS` consecutive
* rejections so a genuinely broken identity (e.g. persistently wrong system
* clock) still surfaces the terminal error card instead of flapping forever.
*
* The rejection streak is preserved across environment-driven resume
* attempts (focus/online/visibility); only explicit user re-engagement —
* the reconnect card or a community switch — may reset it.
*/
export type AuthOkDecision = "authenticated" | "retry" | "terminal";
export const MAX_CONSECUTIVE_AUTH_REJECTIONS = 3;
/** Tracks consecutive AUTH rejections across reconnect attempts. */
export class AuthOkTracker {
private consecutiveRejections = 0;
/**
* Record an AUTH `OK` and decide the session's next move.
* A success — real or "already authenticated" — resets the streak.
*/
record(success: boolean, message: string): AuthOkDecision {
const normalized = message.trim().toLowerCase();
if (
success ||
normalized.startsWith("auth-required: already authenticated")
) {
this.consecutiveRejections = 0;
return "authenticated";
}
this.consecutiveRejections++;
if (
normalized.startsWith("restricted:") ||
normalized.startsWith("blocked:")
) {
return "terminal";
}
if (this.consecutiveRejections >= MAX_CONSECUTIVE_AUTH_REJECTIONS) {
return "terminal";
}
return "retry";
}
/** Called on explicit re-engagement (disconnect / manual preconnect). */
reset(): void {
this.consecutiveRejections = 0;
}
}

View File

@@ -47,6 +47,7 @@ import { RelayConnectionStateEmitter } from "@/shared/api/relayConnectionStateEm
import {
isServiceRestartClose,
isWebSocketClose,
isWebSocketError,
shouldRefuseConnect,
shouldScheduleReconnect,
shouldWaitForScheduledReconnect,
@@ -65,6 +66,7 @@ import {
STALL_IDLE_TIMEOUT_MS,
} from "@/shared/api/relayClientTimings";
import { closeWebSocket } from "@/shared/api/relayWebSocketClose";
import { AuthOkTracker } from "@/shared/api/relayAuthPolicy";
import { buildThreadReferenceTags } from "@/features/messages/lib/threading";
export class RelayClient {
@@ -92,6 +94,7 @@ export class RelayClient {
private connectionGeneration = 0;
private stabilityTimer: number | null = null;
private visibleChannelId: string | null = null;
private authOkTracker = new AuthOkTracker();
private terminal = false;
@@ -128,6 +131,7 @@ export class RelayClient {
this.notifyReconnectListeners = false;
this.terminal = false;
this.visibleChannelId = null;
this.authOkTracker.reset();
this.connectionStateEmitter.set("idle");
if (this.wsId !== null) {
@@ -295,7 +299,7 @@ export class RelayClient {
parentEventId?: string | null,
rootEventId?: string | null,
) {
// Bail when disconnected — not worth triggering a reconnect for ephemeral typing events.
// Disconnected: not worth triggering a reconnect for ephemeral typing.
if (this.wsId === null) {
return;
}
@@ -325,11 +329,11 @@ export class RelayClient {
channelId: string,
onEvent: (event: RelayEvent) => void,
) {
// 39005 rides only this window-store subscription — CHANNEL_EVENT_KINDS'
// other consumers (unread tracking, cache merges) must never see
// summary overlays.
return this.subscribe(
{
// 39005 rides only this window-store subscription — not
// CHANNEL_EVENT_KINDS, whose other consumers (unread tracking,
// timeline-cache merges) must never see summary overlays.
kinds: [...CHANNEL_EVENT_KINDS, KIND_CHANNEL_THREAD_SUMMARY],
"#h": [channelId],
limit: 1000,
@@ -340,10 +344,9 @@ export class RelayClient {
}
/**
* Subscribe to huddle lifecycle events (kinds 48100–48103) for a channel.
* Used by HuddleIndicator to detect active huddles without being drowned
* out by regular channel messages in the generic subscription window.
* Includes both historical (last 10) and live events.
* Subscribe to huddle lifecycle events (kinds 48100–48103) for a channel,
* so HuddleIndicator detects active huddles without being drowned out by
* regular channel messages. Includes the last 10 historical events.
*/
async subscribeToHuddleEvents(
channelId: string,
@@ -425,12 +428,26 @@ export class RelayClient {
}
async preconnect() {
// Explicit re-engagement. If the session went terminal (auth rejection)
// the caller is asking us to try again, so clear the latch. A manual
// reconnect also bypasses the current delay once; ordinary operations do
// not, so background traffic cannot continuously defeat backoff.
// Explicit re-engagement (reconnect card / community switch): clears the
// terminal latch and AUTH rejection streak, and bypasses backoff once.
this.terminal = false;
this.authOkTracker.reset();
this.keepAliveRequested = true;
await this.connectBypassingBackoff();
}
/**
* Environment-driven resume (online/focus/visibility): bypasses a pending
* backoff timer but preserves the terminal latch and AUTH rejection streak
* — only `preconnect()` clears those, so resume events during repeated
* AUTH rejection cannot defeat the consecutive-rejection cap.
*/
async resumeReconnect() {
if (this.terminal) return;
await this.connectBypassingBackoff();
}
private async connectBypassingBackoff() {
if (this.reconnectTimeout !== null) {
window.clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
@@ -448,7 +465,6 @@ export class RelayClient {
subscribeToReconnects(listener: () => void) {
this.reconnectListeners.add(listener);
return () => {
this.reconnectListeners.delete(listener);
};
@@ -460,8 +476,8 @@ export class RelayClient {
}
/**
* Subscribe to connection-state transitions. The listener is invoked
* immediately with the current state so callers don't need a separate
* Subscribe to connection-state transitions. The listener fires
* immediately with the current state, so callers need no separate
* `getConnectionState()` call to seed their UI.
*/
subscribeToConnectionState(listener: (state: ConnectionState) => void) {
@@ -470,11 +486,10 @@ export class RelayClient {
private async ensureConnected() {
if (shouldRefuseConnect({ terminal: this.terminal })) {
// Session is terminal (e.g. relay rejected auth). Refuse to connect
// until an explicit re-engagement (disconnect()/preconnect()) clears
// the flag. Without this, the reconnect timer's catch handler — and
// the retry wrappers in publishEvent / sendRawWithReconnectRetry —
// would race the terminal "disconnected" state back to "reconnecting".
// Terminal (e.g. relay rejected auth): refuse until disconnect() or
// preconnect() clears the latch, else the reconnect-timer catch and
// the publish/subscribe retry wrappers would race the terminal
// "disconnected" state back to "reconnecting".
throw new Error("Relay session is terminal; cannot reconnect.");
}
@@ -494,7 +509,7 @@ export class RelayClient {
// The reconnect coordinator owns outage pacing. Query, publish, and
// subscription callers must wait for its scheduled attempt instead of
// clearing the timer and creating an immediate reconnect storm.
return this.waitForScheduledReconnect();
return this.reconnectWaiters.wait();
}
const connectPromise = this.connect();
@@ -670,7 +685,6 @@ export class RelayClient {
error,
fallbackMessage,
);
try {
await this.ensureConnected();
await this.sendRaw(payload);
@@ -749,12 +763,7 @@ export class RelayClient {
this.resetConnection(new Error("Relay connection closed."));
return;
}
if (
typeof message === "object" &&
message !== null &&
"type" in message &&
message.type === "Error"
) {
if (isWebSocketError(message)) {
this.resetConnection(new Error("Relay connection errored."));
return;
}
@@ -819,8 +828,7 @@ export class RelayClient {
if (type === "NOTICE" && typeof rest[0] === "string") {
const notice: string = rest[0];
// Relay back-pressure signal — activate the gate so pending operations
// back off until the window expires.
// Relay back-pressure — arm the gate until the window expires.
if (notice.startsWith("rate-limited:")) {
activateRateLimit(parseRateLimitHint(notice));
}
@@ -892,12 +900,14 @@ export class RelayClient {
const authRequest = this.authRequest;
this.authRequest = null;
if (success) {
// Decision table lives in relayAuthPolicy.ts.
const decision = this.authOkTracker.record(success, message);
if (decision === "authenticated") {
authRequest.resolve();
} else {
const error = new Error(message || "Relay authentication rejected.");
authRequest.reject(error);
this.resetConnection(error, { reconnect: false });
this.resetConnection(error, { reconnect: decision === "retry" });
}
return;
@@ -919,13 +929,7 @@ export class RelayClient {
}
private hasLiveSubscriptions() {
for (const subscription of this.subscriptions.values()) {
if (subscription.mode === "live") {
return true;
}
}
return false;
return [...this.subscriptions.values()].some((s) => s.mode === "live");
}
private async replayLiveSubscriptions() {
@@ -948,13 +952,6 @@ export class RelayClient {
}
}
private waitForScheduledReconnect(): Promise<void> {
if (this.reconnectTimeout === null) {
return this.ensureConnected();
}
return this.reconnectWaiters.wait();
}
private scheduleReconnect() {
if (
!shouldScheduleReconnect({
@@ -968,9 +965,8 @@ export class RelayClient {
return;
}
// Apply ±25% jitter so a fleet of clients reconnecting simultaneously
// spreads their AUTH storms across a 50% window instead of all hitting
// the relay at the same instant.
// ±25% jitter spreads a fleet's AUTH storms across a 50% window instead
// of hitting the relay at the same instant.
const jitter = this.reconnectDelayMs * (0.75 + Math.random() * 0.5);
const delay = Math.min(jitter, RECONNECT_MAX_DELAY_MS);
this.reconnectDelayMs = Math.min(
@@ -1031,9 +1027,13 @@ export class RelayClient {
if (options?.reconnect === false) {
this.terminal = true;
this.connectionStateEmitter.set("disconnected");
} else if (this.connectionStateEmitter.get() !== "stalled") {
// Stall is a stronger signal than a generic drop; keep it until the
// reconnect timer transitions us back to "reconnecting" in connect().
} else if (
// A late retry failure racing a terminal latch must not paint
// "reconnecting" over the terminal "disconnected" state; stall is a
// stronger signal than a generic drop and is kept until reconnect.
!this.terminal &&
this.connectionStateEmitter.get() !== "stalled"
) {
this.connectionStateEmitter.set("reconnecting");
}

View File

@@ -19,7 +19,6 @@ test("classifyRelayClosed: terminal messages return terminal", () => {
for (const message of [
"restricted: not a channel member",
"restricted: channel access revoked",
"auth-required: not authenticated",
"blocked: banned",
"invalid: malformed filter",
"pow: difficulty too low",
@@ -38,6 +37,21 @@ test("classifyRelayClosed: transient errors return retryable", () => {
}
});
test("classifyRelayClosed: auth-required is retryable (REQ/AUTH reconnect race)", () => {
// A REQ that lands before the AUTH handshake completes gets CLOSED with
// auth-required. Deleting the subscription would silently freeze the
// channel while the connection state still reads "connected" — the sub
// must survive and be retried after backoff.
assert.equal(
classifyRelayClosed("auth-required: not authenticated"),
"retryable",
);
assert.equal(
classifyRelayClosed("auth-required: authenticate before subscribing"),
"retryable",
);
});
// ── Subscription-survival semantics ──────────────────────────────────────────
// These replace the removed isRetryableRelayClosed wrapper tests.
// rate-limited must not delete the subscription; terminal must.
@@ -59,7 +73,6 @@ test("classifyRelayClosed: retryable class survives (subscription must not be de
test("classifyRelayClosed: terminal class triggers deletion (no retry)", () => {
for (const message of [
"restricted: not a channel member",
"auth-required: not authenticated",
"blocked: banned",
"invalid: malformed filter",
"pow: difficulty too low",

View File

@@ -19,9 +19,13 @@ export function classifyRelayClosed(message: string): RelayClosedClass {
if (normalized.startsWith("rate-limited:")) {
return "rate-limited";
}
// `auth-required:` is deliberately retryable, NOT terminal: it occurs
// transiently when a REQ races the AUTH handshake after a reconnect. The
// backoff retry re-sends the REQ once the session is authenticated. A
// session that is genuinely unauthenticated latches `terminal` at the
// connection level (AUTH OK=false), so this cannot loop forever.
if (
normalized.startsWith("restricted:") ||
normalized.startsWith("auth-required:") ||
normalized.startsWith("blocked:") ||
normalized.startsWith("invalid:") ||
normalized.startsWith("pow:") ||

View File

@@ -70,3 +70,13 @@ export function isServiceRestartClose(message: unknown): boolean {
data.code === 1012
);
}
/** Whether a WS-layer message is the plugin's `Error` frame. */
export function isWebSocketError(message: unknown): boolean {
return (
typeof message === "object" &&
message !== null &&
"type" in message &&
message.type === "Error"
);
}

View File

@@ -0,0 +1,70 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
RESUME_TRIGGER_MIN_INTERVAL_MS,
shouldTriggerResumeReconnect,
} from "./relayResumeTriggerPolicy.ts";
const base = Object.freeze({
connectionState: "reconnecting",
lastAttemptAt: 0,
now: RESUME_TRIGGER_MIN_INTERVAL_MS + 1,
});
test("reconnecting + interval elapsed triggers", () => {
assert.equal(shouldTriggerResumeReconnect({ ...base }), true);
});
test("stalled + interval elapsed triggers", () => {
assert.equal(
shouldTriggerResumeReconnect({ ...base, connectionState: "stalled" }),
true,
);
});
test("healthy and terminal states never trigger", () => {
for (const connectionState of [
"idle",
"connecting",
"connected",
"disconnected",
]) {
assert.equal(
shouldTriggerResumeReconnect({ ...base, connectionState }),
false,
connectionState,
);
}
});
test("burst of triggers within the rate window fires once", () => {
assert.equal(
shouldTriggerResumeReconnect({
...base,
lastAttemptAt: 1_000,
now: 1_000 + RESUME_TRIGGER_MIN_INTERVAL_MS - 1,
}),
false,
);
assert.equal(
shouldTriggerResumeReconnect({
...base,
lastAttemptAt: 1_000,
now: 1_000 + RESUME_TRIGGER_MIN_INTERVAL_MS,
}),
true,
);
});
test("custom interval override is honoured", () => {
assert.equal(
shouldTriggerResumeReconnect({
...base,
lastAttemptAt: 0,
now: 10,
minIntervalMs: 5,
}),
true,
);
});

View File

@@ -0,0 +1,39 @@
/**
* Policy for event-driven reconnect triggers (G1 of the CMD+R gap audit).
*
* The exponential-backoff timer is the only thing driving recovery after an
* outage — and WKWebView throttles JS timers in occluded/background windows,
* so at max backoff (30s) a scheduled attempt may not fire until the user
* focuses the window. These triggers short-circuit the wait the moment the
* environment signals recovery: network `online`, window focus, and
* visibility becoming visible. `preconnect()` already clears the pending
* backoff timer, so a trigger converts "wait up to 30s (or forever, if
* throttled)" into "reconnect now".
*/
import type { ConnectionState } from "@/shared/api/relayClientShared";
/** Min ms between trigger-driven preconnect attempts. */
export const RESUME_TRIGGER_MIN_INTERVAL_MS = 5_000;
export function shouldTriggerResumeReconnect(inputs: {
connectionState: ConnectionState;
lastAttemptAt: number;
now: number;
minIntervalMs?: number;
}): boolean {
const minInterval = inputs.minIntervalMs ?? RESUME_TRIGGER_MIN_INTERVAL_MS;
// Only degraded-but-recoverable states. `disconnected` is the terminal
// latch — explicit user re-engagement owns that path, and `idle` /
// `connecting` / `connected` need no help.
if (
inputs.connectionState !== "reconnecting" &&
inputs.connectionState !== "stalled"
) {
return false;
}
// Rate-limit: focus/online events arrive in bursts (e.g. wake fires all
// three); one attempt per window is enough.
return inputs.now - inputs.lastAttemptAt >= minInterval;
}

View File

@@ -0,0 +1,34 @@
/**
* Wire types for the restart-required config diff.
*
* These are camelCase / TS-idiomatic mirrors of the Rust `RestartDiffEntry`
* serialized shape. The `field` is a dotted path derived from serde field
* names (e.g. `"model"`, `"env.FOO"`); display labels are produced by
* humanising the path — there is no per-field label map.
*/
/** A JSON-compatible value. Arrays are atomic leaves in the diff. */
export type JsonValue =
| null
| boolean
| number
| string
| JsonValue[]
| { [key: string]: JsonValue };
/**
* One change in a restart diff. The `kind` discriminant is a closed set of
* five values; unknown `field` paths must render gracefully in the UI.
*/
export type RestartChange =
| { kind: "value"; before: JsonValue; after: JsonValue }
| { kind: "text"; before_chars: number | null; after_chars: number | null }
| { kind: "masked"; before: string | null; after: string | null }
| { kind: "added" }
| { kind: "removed" };
export type RestartDiffEntry = {
/** Dotted path derived from serde field names, e.g. "model", "env.FOO". */
field: string;
change: RestartChange;
};

View File

@@ -118,6 +118,8 @@ type RawRelayAgent = {
respond_to?: RelayAgent["respondTo"];
respond_to_allowlist?: string[];
};
import type { RestartDiffEntry as RawRestartDiffEntry } from "./restartDiff";
export type RawManagedAgent = {
pubkey: string;
name: string;
@@ -143,6 +145,7 @@ export type RawManagedAgent = {
persona_out_of_date: boolean;
persona_orphaned: boolean;
needs_restart: boolean;
restart_diff?: RawRestartDiffEntry[];
env_vars?: Record<string, string>;
status: ManagedAgent["status"];
pid: number | null;
@@ -158,8 +161,7 @@ export type RawManagedAgent = {
auto_restart_on_config_change?: boolean;
backend: ManagedAgentBackend;
backend_agent_id: string | null;
// Optional: pre-feature mock fixtures may omit these. Mapped to
// `"owner-only"` / `[]` in `fromRawManagedAgent`.
// Pre-feature fixtures may omit these; mapped to "owner-only"/[] in fromRawManagedAgent.
respond_to?: ManagedAgent["respondTo"];
respond_to_allowlist?: string[];
};
@@ -596,11 +598,11 @@ export async function uploadMedia(
isTemp,
});
}
export async function pickAndUploadMedia(): Promise<BlobDescriptor[]> {
return invokeTauri<BlobDescriptor[]>("pick_and_upload_media", {});
export async function pickAndUploadMedia(
progressId?: string,
): Promise<BlobDescriptor[]> {
return invokeTauri<BlobDescriptor[]>("pick_and_upload_media", { progressId });
}
export async function uploadMediaBytes(
data: number[],
filename?: string,
@@ -707,11 +709,11 @@ export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent {
avatarUrl: agent.avatar_url ?? null,
model: agent.model,
modelSource: agent.model_source ?? null,
// Fallbacks for pre-feature mocks/fixtures. Real records always carry them.
provider: agent.provider ?? null,
personaOutOfDate: agent.persona_out_of_date ?? false,
personaOrphaned: agent.persona_orphaned ?? false,
needsRestart: agent.needs_restart ?? false,
restartDiff: agent.restart_diff ?? [],
envVars: agent.env_vars ?? {},
status: agent.status,
pid: agent.pid,
@@ -727,8 +729,6 @@ export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent {
autoRestartOnConfigChange: agent.auto_restart_on_config_change ?? true,
backend: agent.backend,
backendAgentId: agent.backend_agent_id,
// Fallbacks for pre-feature mocks/fixtures that don't carry these fields.
// Real agent records always include them (defaulted server-side).
respondTo: agent.respond_to ?? "owner-only",
respondToAllowlist: agent.respond_to_allowlist ?? [],
};

View File

@@ -88,23 +88,22 @@ function decodeRawSubscription(raw: RawSaveSubscription): SaveSubscription {
// ── API wrappers ─────────────────────────────────────────────────────────────
/**
* Returns `true` when observer-feed archive policy is enforced.
* Returns `true` when observer-feed archive is enabled by default.
*
* Internal builds set `BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT` at build time;
* OSS builds never set it, so this returns `false`. The frontend calls this
* every startup to decide whether to reconcile the `owner_p` subscription.
* Always returns `true` — archive defaults to enabled for all builds.
* The frontend calls this every startup to decide whether to reconcile
* the `owner_p` subscription for kind 24200 (observer frames).
*/
export async function observerArchiveDefaultEnabled(): Promise<boolean> {
return invokeTauri<boolean>("observer_archive_default_enabled");
}
/**
* Returns `true` when the build has agent-turn-metric archive default-on.
* Returns `true` when agent-turn-metric archive is enabled by default.
*
* Internal builds set `BUZZ_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT` at build time;
* OSS builds never set it, so this returns `false`. The frontend calls this
* once at startup to decide whether to auto-seed an `owner_p` [44200]
* subscription.
* Always returns `true` — archive defaults to enabled for all builds.
* The frontend calls this once at startup to decide whether to auto-seed
* an `owner_p` [44200] subscription for new identities.
*/
export async function agentMetricArchiveDefaultEnabled(): Promise<boolean> {
return invokeTauri<boolean>("agent_metric_archive_default_enabled");

View File

@@ -1,5 +1,44 @@
import { invoke as invokeTauriRaw } from "@tauri-apps/api/core";
import { type BlobDescriptor, invokeTauri } from "./tauri";
function encodeRawIpcHeader(value: string): string {
const bytes = new TextEncoder().encode(value);
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return window
.btoa(binary)
.replaceAll("+", "-")
.replaceAll("/", "_")
.replace(/=+$/, "");
}
/** Transfer a browser File to Rust as a raw IPC body, avoiding JSON expansion. */
export async function uploadMediaFile(
file: File,
progressId?: string,
signal?: AbortSignal,
): Promise<BlobDescriptor> {
const headers: Record<string, string> = {
"x-buzz-filename": encodeRawIpcHeader(file.name),
};
if (progressId) {
headers["x-buzz-progress-id"] = encodeRawIpcHeader(progressId);
}
if (signal?.aborted) throw new Error("upload cancelled");
const bytes = new Uint8Array(await file.arrayBuffer());
if (signal?.aborted) throw new Error("upload cancelled");
return invokeTauriRaw<BlobDescriptor>("upload_media_bytes_raw", bytes, {
headers,
});
}
/** Stop the native HTTP request associated with a background media upload. */
export async function cancelMediaUpload(progressId: string): Promise<void> {
await invokeTauri("cancel_media_upload", { progressId });
}
/**
* Open a native single-file picker constrained to images and upload the
* chosen file. Non-image files are rejected in Rust (via MIME sniffing)

View File

@@ -304,6 +304,8 @@ export type ManagedAgentBackend =
| { type: "local" }
| { type: "provider"; id: string; config: Record<string, unknown> };
import type { RestartDiffEntry } from "./restartDiff";
export type { JsonValue, RestartChange, RestartDiffEntry } from "./restartDiff";
export type ManagedAgent = {
pubkey: string;
name: string;
@@ -357,6 +359,8 @@ export type ManagedAgent = {
* Always `false` for stopped agents.
*/
needsRestart: boolean;
/** Non-empty iff `needsRestart` is true. Empty when Rust omits the field. */
restartDiff: RestartDiffEntry[];
/** Per-agent env vars. Layered on top of persona envVars. */
envVars: Record<string, string>;
status: "running" | "stopped" | "deployed" | "not_deployed";
@@ -382,11 +386,7 @@ export type ManagedAgent = {
respondToAllowlist: string[];
};
/**
* Inbound author gate mode. Mirrors `buzz-acp`'s `--respond-to` CLI flag.
* `"nobody"` is supported by the harness but not surfaced through this API —
* it's a heartbeat-only mode without a meaningful GUI use case.
*/
/** Inbound author gate mode. Mirrors buzz-acp's --respond-to CLI flag. */
export type RespondToMode = "owner-only" | "allowlist" | "anyone";
export type BackendProviderCandidate = {

View File

@@ -2,12 +2,12 @@ import * as React from "react";
import { useQueryClient } from "@tanstack/react-query";
import type { ConnectionState } from "@/shared/api/relayClientShared";
import { isRelayDependentQuery } from "@/shared/api/relayQueryInvalidation";
import { relayClient } from "@/shared/api/relayClient";
import {
isRelayConnectionDegraded,
useRelayConnection,
} from "@/shared/api/useRelayConnection";
type ConnectionState,
} from "@/shared/api/relayClientShared";
import { isRelayDependentQuery } from "@/shared/api/relayQueryInvalidation";
import {
isRateLimited,
waitForRateLimit,
@@ -100,8 +100,6 @@ export class RelayAutoHealScheduler {
*/
export function useRelayAutoHeal(): void {
const queryClient = useQueryClient();
const connectionState = useRelayConnection();
const prevConnectionStateRef = React.useRef(connectionState);
const schedulerRef = React.useRef<RelayAutoHealScheduler | null>(null);
if (schedulerRef.current === null) {
@@ -129,14 +127,22 @@ export function useRelayAutoHeal(): void {
}
React.useEffect(() => {
// Observe the RAW connection-state emitter, not the 2s-debounced
// useRelayConnection() hook. A sub-2s flap never surfaces through the
// debounced hook (its degraded report is cancelled by the recovery), yet
// resetConnection() already rejected every in-flight query — the heal
// must still fire or errored panes persist until a manual reconnect.
let prev: ConnectionState | null = null;
const unsubscribe = relayClient.subscribeToConnectionState((next) => {
if (prev !== null) {
schedulerRef.current?.onTransition(prev, next);
}
prev = next;
});
return () => {
unsubscribe();
schedulerRef.current?.dispose();
};
}, []);
React.useEffect(() => {
const prev = prevConnectionStateRef.current;
prevConnectionStateRef.current = connectionState;
schedulerRef.current?.onTransition(prev, connectionState);
}, [connectionState]);
}

View File

@@ -0,0 +1,60 @@
import * as React from "react";
import { relayClient } from "@/shared/api/relayClient";
import { shouldTriggerResumeReconnect } from "@/shared/api/relayResumeTriggerPolicy";
/**
* Event-driven reconnect triggers: network `online`, window `focus`, and
* visibility→visible each attempt an immediate `resumeReconnect()` when the
* relay session is degraded (reconnecting/stalled), rate-limited by
* `RESUME_TRIGGER_MIN_INTERVAL_MS`.
*
* Rationale (CMD+R gap audit G1): without these, recovery rides solely on
* the backoff timer, which WKWebView throttles while the window is occluded
* or the system was asleep. The moment a user focuses the window to hit
* CMD+R *is* a focus event — this fires the reconnect first.
*
* Uses `resumeReconnect()`, NOT `preconnect()`: resume events bypass the
* pending backoff timer but must preserve the terminal latch and the AUTH
* rejection streak. Otherwise a focus/online event arriving during repeated
* AUTH rejection would reset the consecutive-rejection cap and the session
* could retry indefinitely instead of surfacing `disconnected`. The
* terminal state stays user-owned via the reconnect card.
*/
export function useRelayResumeTriggers(): void {
React.useEffect(() => {
let lastAttemptAt = -Infinity;
const attempt = () => {
const now = Date.now();
if (
!shouldTriggerResumeReconnect({
connectionState: relayClient.getConnectionState(),
lastAttemptAt,
now,
})
) {
return;
}
lastAttemptAt = now;
// resumeReconnect() clears any pending backoff timer and connects now,
// preserving terminal/AUTH-streak state. Failures re-arm the normal
// backoff loop; nothing to handle here.
void relayClient.resumeReconnect().catch(() => {});
};
const onVisibilityChange = () => {
if (document.visibilityState === "visible") attempt();
};
window.addEventListener("online", attempt);
window.addEventListener("focus", attempt);
document.addEventListener("visibilitychange", onVisibilityChange);
return () => {
window.removeEventListener("online", attempt);
window.removeEventListener("focus", attempt);
document.removeEventListener("visibilitychange", onVisibilityChange);
};
}, []);
}

View File

@@ -58,6 +58,8 @@ export const KIND_AGENT_TURN_METRIC = 44200;
export const KIND_EVENT_REMINDER = 30300;
export const KIND_REPO_ANNOUNCEMENT = 30617;
export const KIND_REPO_STATE = 30618;
// NIP-MP: project grouping above NIP-34 repositories.
export const KIND_PROJECT_ANNOUNCEMENT = 30621;
export const KIND_GIT_PATCH = 1617;
export const KIND_GIT_PULL_REQUEST = 1618;
export const KIND_GIT_PR_UPDATE = 1619;

View File

@@ -53,6 +53,9 @@ export const channelChrome = {
contentPadding: "pt-(--buzz-channel-content-top-padding,5.75rem)",
/** Absolute/fixed top offset below the measured channel header chrome. */
top: "top-(--buzz-channel-content-top-padding,5.75rem)",
/** Sticky timeline controls sit slightly below the channel navigation. */
stickyTimelineTop:
"top-[calc(var(--buzz-channel-content-top-padding,5.75rem)+0.5rem)]",
/** Height matching the measured channel header chrome. */
headerHeight: "h-(--buzz-channel-content-top-padding,5.75rem)",
/** Negative margin for overlaid channel chrome that should not affect flow. */

View File

@@ -0,0 +1,135 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildIssueLink,
buildPullRequestLink,
buildRepoLink,
entityLinkProjectRouteId,
isEntityLink,
parseEntityLink,
} from "./entityLink.ts";
const OWNER =
"71d67180ba17e749ee825fc8819c9c6ee7003617e1c126504f9b658070ab9224";
const EVENT_ID =
"c3b589fa5713ba25bad6dc095e2de00a4ac8f50050fdea00fc6444e603be1dd1";
// Golden format strings — must match the Rust builder in
// crates/buzz-cli/src/links.rs (`golden_format_matches_desktop` test).
test("builders emit the canonical cross-language link format", () => {
assert.equal(
buildPullRequestLink({ id: EVENT_ID, owner: OWNER, dtag: "buzz-world" }),
`buzz://pr?id=${EVENT_ID}&owner=${OWNER}&d=buzz-world`,
);
assert.equal(
buildIssueLink({ id: EVENT_ID, owner: OWNER, dtag: "buzz-world" }),
`buzz://issue?id=${EVENT_ID}&owner=${OWNER}&d=buzz-world`,
);
assert.equal(
buildRepoLink({ owner: OWNER, dtag: "buzz-world" }),
`buzz://repo?owner=${OWNER}&d=buzz-world`,
);
});
test("builders reject invalid identifiers", () => {
assert.throws(() =>
buildRepoLink({ owner: "not-a-pubkey", dtag: "buzz-world" }),
);
assert.throws(() => buildRepoLink({ owner: OWNER, dtag: ".hidden" }));
assert.throws(() => buildRepoLink({ owner: OWNER, dtag: "a..b" }));
assert.throws(() =>
buildPullRequestLink({ id: "short", owner: OWNER, dtag: "buzz-world" }),
);
});
test("parseEntityLink round-trips built links", () => {
const link = buildPullRequestLink({
id: EVENT_ID,
owner: OWNER,
dtag: "buzz-world",
});
assert.deepEqual(parseEntityLink(link), {
ok: true,
value: { type: "pr", id: EVENT_ID, owner: OWNER, dtag: "buzz-world" },
});
const repoLink = buildRepoLink({ owner: OWNER, dtag: "buzz-world" });
assert.deepEqual(parseEntityLink(repoLink), {
ok: true,
value: { type: "repo", owner: OWNER, dtag: "buzz-world" },
});
});
test("parseEntityLink lowercase-normalizes hex identifiers", () => {
const parsed = parseEntityLink(
`buzz://issue?id=${EVENT_ID.toUpperCase()}&owner=${OWNER.toUpperCase()}&d=buzz-world`,
);
assert.deepEqual(parsed, {
ok: true,
value: { type: "issue", id: EVENT_ID, owner: OWNER, dtag: "buzz-world" },
});
});
test("parseEntityLink rejects malformed links", () => {
const cases = [
["not a url at all", "invalid-url"],
[`https://pr?id=${EVENT_ID}&owner=${OWNER}&d=repo`, "wrong-scheme"],
[`buzz://message?channel=x&id=${EVENT_ID}`, "wrong-host"],
[`buzz://pr?id=${EVENT_ID}&owner=nope&d=repo`, "invalid-owner"],
[`buzz://pr?id=${EVENT_ID}&owner=${OWNER}&d=.hidden`, "invalid-dtag"],
[`buzz://pr?id=${EVENT_ID}&owner=${OWNER}`, "invalid-dtag"],
[`buzz://pr?owner=${OWNER}&d=repo`, "invalid-id"],
[`buzz://issue?id=short&owner=${OWNER}&d=repo`, "invalid-id"],
];
for (const [href, reason] of cases) {
assert.deepEqual(parseEntityLink(href), { ok: false, reason }, href);
}
});
test("isEntityLink matches entity hosts and excludes message links", () => {
assert.equal(isEntityLink(`buzz://pr?id=${EVENT_ID}`), true);
assert.equal(isEntityLink(`buzz://issue?id=${EVENT_ID}`), true);
assert.equal(isEntityLink(`buzz://repo?owner=${OWNER}`), true);
assert.equal(isEntityLink("buzz://message?channel=x&id=y"), false);
assert.equal(isEntityLink("https://github.com/block/buzz"), false);
assert.equal(isEntityLink(null), false);
});
test("entityLinkProjectRouteId emits the canonical 30617 coordinate route id", () => {
const parsed = parseEntityLink(
buildRepoLink({ owner: OWNER, dtag: "buzz-world" }),
);
assert.ok(parsed.ok);
assert.equal(
entityLinkProjectRouteId(parsed.value),
`30617:${OWNER}:buzz-world`,
);
});
test("parseEntityLink rejects noncanonical extras", () => {
// Unexpected path segments — reserved for future versioning.
assert.deepEqual(
parseEntityLink(
`buzz://pr/ignored?id=${EVENT_ID}&owner=${OWNER}&d=buzz-world`,
),
{ ok: false, reason: "unexpected-path" },
);
// Fragment — not part of the canonical format.
assert.deepEqual(
parseEntityLink(`buzz://repo?owner=${OWNER}&d=buzz-world#section`),
{ ok: false, reason: "unexpected-fragment" },
);
// Unknown query parameter — reject to preserve forward-compat posture.
assert.deepEqual(
parseEntityLink(
`buzz://repo?owner=${OWNER}&d=buzz-world&relay=wss%3A%2F%2Frelay.example`,
),
{ ok: false, reason: "unknown-param" },
);
// Duplicate required parameter — reject.
assert.deepEqual(
parseEntityLink(`buzz://repo?owner=${OWNER}&d=buzz-world&owner=${OWNER}`),
{ ok: false, reason: "duplicate-param" },
);
});

View File

@@ -0,0 +1,194 @@
/**
* `buzz://` deep links for Buzz-hosted git entities, mirroring
* `features/messages/lib/messageLink.ts` for `buzz://message`.
*
* Formats:
* buzz://repo?owner=<owner-pubkey>&d=<repo-dtag>
* buzz://pr?id=<event-id>&owner=<owner-pubkey>&d=<repo-dtag>
* buzz://issue?id=<event-id>&owner=<owner-pubkey>&d=<repo-dtag>
*
* `owner` + `d` identify the NIP-34 repository coordinate
* (`30617:<owner>:<d>`); `id` is the kind 1618 / 1621 event id. The CLI
* builder in `crates/buzz-cli/src/links.rs` emits the same format — the two
* must stay compatible (see the golden-format tests on both sides).
*/
const ENTITY_LINK_SCHEME = "buzz:";
export type ParsedEntityLink =
| { type: "pr"; id: string; owner: string; dtag: string }
| { type: "issue"; id: string; owner: string; dtag: string }
| { type: "repo"; owner: string; dtag: string };
export type EntityLinkParseResult =
| { ok: true; value: ParsedEntityLink }
| { ok: false; reason: string };
const HEX64_RE = /^[a-fA-F0-9]{64}$/;
const DTAG_RE = /^[a-zA-Z0-9._-]{1,64}$/;
function isValidDtag(dtag: string): boolean {
return DTAG_RE.test(dtag) && !dtag.startsWith(".") && !dtag.includes("..");
}
function checkCoordinate(owner: string, dtag: string): void {
if (!HEX64_RE.test(owner)) {
throw new Error("entityLink: owner must be a 64-char hex pubkey");
}
if (!isValidDtag(dtag)) {
throw new Error("entityLink: invalid repository d-tag");
}
}
function checkEventId(id: string): void {
if (!HEX64_RE.test(id)) {
throw new Error("entityLink: id must be a 64-char hex event id");
}
}
/** Build a `buzz://repo` link for a repository announcement (kind 30617). */
export function buildRepoLink(input: { owner: string; dtag: string }): string {
checkCoordinate(input.owner, input.dtag);
return `buzz://repo?owner=${input.owner.toLowerCase()}&d=${input.dtag}`;
}
/** Build a `buzz://pr` link for a pull request event (kind 1618). */
export function buildPullRequestLink(input: {
id: string;
owner: string;
dtag: string;
}): string {
checkEventId(input.id);
checkCoordinate(input.owner, input.dtag);
return `buzz://pr?id=${input.id.toLowerCase()}&owner=${input.owner.toLowerCase()}&d=${input.dtag}`;
}
/** Build a `buzz://issue` link for an issue event (kind 1621). */
export function buildIssueLink(input: {
id: string;
owner: string;
dtag: string;
}): string {
checkEventId(input.id);
checkCoordinate(input.owner, input.dtag);
return `buzz://issue?id=${input.id.toLowerCase()}&owner=${input.owner.toLowerCase()}&d=${input.dtag}`;
}
/**
* Cheap pre-check used by the markdown renderer and preview extraction
* before parsing. `buzz://message` is intentionally excluded — it has its
* own pill rendering path.
*/
export function isEntityLink(href: string | undefined | null): boolean {
if (!href) return false;
return (
href.startsWith("buzz://pr?") ||
href.startsWith("buzz://issue?") ||
href.startsWith("buzz://repo?")
);
}
/**
* Parse a `buzz://pr|issue|repo?…` URL. Returns a discriminated result so
* callers can fall back to plain-link rendering without throwing. All
* identifiers are validated; hex values are lowercase-normalized.
*
* Strict canonical form to preserve forward-compatibility:
* - Empty or root path only (no `/extra/segments`)
* - No fragment
* - Each required parameter must appear exactly once
* - Unknown query parameters are rejected (callers that need to add
* parameters must version the format or add them to the known-params set)
*
* This ensures old clients decline rather than silently misinterpret future
* extensions (e.g. the reserved `relay=` cross-community field).
*/
export function parseEntityLink(url: string): EntityLinkParseResult {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return { ok: false, reason: "invalid-url" };
}
if (parsed.protocol !== ENTITY_LINK_SCHEME) {
return { ok: false, reason: "wrong-scheme" };
}
const host = parsed.hostname;
if (host !== "pr" && host !== "issue" && host !== "repo") {
return { ok: false, reason: "wrong-host" };
}
// Require empty/root path — path segments are reserved for future versioning.
if (parsed.pathname !== "" && parsed.pathname !== "/") {
return { ok: false, reason: "unexpected-path" };
}
// Reject fragments — not part of the canonical format.
if (parsed.hash) {
return { ok: false, reason: "unexpected-fragment" };
}
// Validate known params and reject unknown ones, and enforce single-instance.
const KNOWN_REPO_PARAMS = new Set(["owner", "d"]);
const KNOWN_EVENT_PARAMS = new Set(["id", "owner", "d"]);
const knownParams = host === "repo" ? KNOWN_REPO_PARAMS : KNOWN_EVENT_PARAMS;
for (const key of parsed.searchParams.keys()) {
if (!knownParams.has(key)) {
return { ok: false, reason: "unknown-param" };
}
}
for (const key of knownParams) {
const values = parsed.searchParams.getAll(key);
if (values.length > 1) {
return { ok: false, reason: "duplicate-param" };
}
}
const owner = parsed.searchParams.get("owner");
const dtag = parsed.searchParams.get("d");
if (!owner || !HEX64_RE.test(owner)) {
return { ok: false, reason: "invalid-owner" };
}
if (!dtag || !isValidDtag(dtag)) {
return { ok: false, reason: "invalid-dtag" };
}
if (host === "repo") {
return {
ok: true,
value: { type: "repo", owner: owner.toLowerCase(), dtag },
};
}
const id = parsed.searchParams.get("id");
if (!id || !HEX64_RE.test(id)) {
return { ok: false, reason: "invalid-id" };
}
return {
ok: true,
value: {
type: host,
id: id.toLowerCase(),
owner: owner.toLowerCase(),
dtag,
},
};
}
/**
* Canonical NIP-34 repository coordinate (`30617:<owner>:<dtag>`) used as the
* route id for `/projects/$projectId`. Duncan's #4671 branch resolves 30617
* coordinates regardless of which explicit project contains the repository, so
* entity links remain stable when a repo's container project changes.
*
* Do NOT use the legacy `<owner>:<dtag>` form — it only matched implicit
* project cards and breaks for repos claimed by an explicit project with a
* different d-tag.
*/
export function entityLinkProjectRouteId(link: ParsedEntityLink): string {
return `30617:${link.owner}:${link.dtag}`;
}

View File

@@ -53,6 +53,160 @@ test("parseSupportedLinkPreview ignores unsupported GitHub URLs", () => {
);
});
const BUZZ_OWNER =
"71d67180ba17e749ee825fc8819c9c6ee7003617e1c126504f9b658070ab9224";
test("parseSupportedLinkPreview parses Buzz relay git clone URLs", () => {
// Must pass the active relay origin for host validation.
assert.deepEqual(
parseSupportedLinkPreview(
`https://buzz.block.builderlab.xyz/git/${BUZZ_OWNER}/buzz-world-galaxy`,
"https://buzz.block.builderlab.xyz",
),
{
kind: "buzz-repository",
href: `buzz://repo?owner=${BUZZ_OWNER}&d=buzz-world-galaxy`,
provider: "Buzz",
title: "buzz-world-galaxy",
typeLabel: "repo",
},
);
// Same URL without a matching origin stays external.
assert.equal(
parseSupportedLinkPreview(
`https://buzz.block.builderlab.xyz/git/${BUZZ_OWNER}/buzz-world-galaxy`,
),
null,
);
});
test("parseSupportedLinkPreview strips .git suffix from clone URLs", () => {
assert.deepEqual(
parseSupportedLinkPreview(
`http://localhost:3000/git/${BUZZ_OWNER}/buzz-world.git`,
"http://localhost:3000",
),
{
kind: "buzz-repository",
href: `buzz://repo?owner=${BUZZ_OWNER}&d=buzz-world`,
provider: "Buzz",
title: "buzz-world",
typeLabel: "repo",
},
);
});
test("parseSupportedLinkPreview rejects malformed Buzz git URLs", () => {
for (const href of [
// Owner segment must be a 64-char lowercase hex pubkey.
"https://relay.example/git/not-a-pubkey/repo",
`https://relay.example/git/${BUZZ_OWNER.toUpperCase()}/repo`,
`https://relay.example/git/${BUZZ_OWNER.slice(0, 32)}/repo`,
// Missing or invalid repo segment.
`https://relay.example/git/${BUZZ_OWNER}`,
`https://relay.example/git/${BUZZ_OWNER}/.hidden`,
// Deeper transport paths are not repo links.
`https://relay.example/git/${BUZZ_OWNER}/repo/info/refs`,
]) {
// Even with a matching origin, structural issues return null.
assert.equal(
parseSupportedLinkPreview(href, "https://relay.example"),
null,
href,
);
}
});
test("parseSupportedLinkPreview rejects clone URLs from non-relay hosts", () => {
// Correct path shape but origin does not match the active relay.
assert.equal(
parseSupportedLinkPreview(
`https://evil.example/git/${BUZZ_OWNER}/my-repo`,
"https://buzz.block.builderlab.xyz",
),
null,
);
// github.com sharing the path shape must never become a Buzz repo card.
assert.equal(
parseSupportedLinkPreview(
`https://github.com/git/${BUZZ_OWNER}/my-repo`,
"https://buzz.block.builderlab.xyz",
),
null,
);
// No relay origin provided — stays external.
assert.equal(
parseSupportedLinkPreview(
`https://buzz.block.builderlab.xyz/git/${BUZZ_OWNER}/buzz-world`,
null,
),
null,
);
});
const BUZZ_EVENT_ID =
"c3b589fa5713ba25bad6dc095e2de00a4ac8f50050fdea00fc6444e603be1dd1";
test("parseSupportedLinkPreview parses buzz:// PR and issue deep links", () => {
assert.deepEqual(
parseSupportedLinkPreview(
`buzz://pr?id=${BUZZ_EVENT_ID}&owner=${BUZZ_OWNER}&d=buzz-world`,
),
{
kind: "buzz-pull-request",
href: `buzz://pr?id=${BUZZ_EVENT_ID}&owner=${BUZZ_OWNER}&d=buzz-world`,
provider: "Buzz",
title: "buzz-world #c3b589fa",
typeLabel: "PR",
},
);
assert.deepEqual(
parseSupportedLinkPreview(
`buzz://issue?id=${BUZZ_EVENT_ID}&owner=${BUZZ_OWNER}&d=buzz-world`,
)?.typeLabel,
"issue",
);
assert.deepEqual(
parseSupportedLinkPreview(`buzz://repo?owner=${BUZZ_OWNER}&d=buzz-world`),
{
kind: "buzz-repository",
href: `buzz://repo?owner=${BUZZ_OWNER}&d=buzz-world`,
provider: "Buzz",
title: "buzz-world",
typeLabel: "repo",
},
);
});
test("parseSupportedLinkPreview rejects malformed buzz:// entity links", () => {
for (const href of [
`buzz://pr?owner=${BUZZ_OWNER}&d=buzz-world`,
`buzz://pr?id=short&owner=${BUZZ_OWNER}&d=buzz-world`,
`buzz://issue?id=${BUZZ_EVENT_ID}&owner=nope&d=buzz-world`,
`buzz://repo?owner=${BUZZ_OWNER}&d=.hidden`,
]) {
assert.equal(parseSupportedLinkPreview(href), null, href);
}
});
test("extractSupportedLinkPreviews picks up buzz:// links in prose", () => {
assert.deepEqual(
extractSupportedLinkPreviews(
`PR is up: buzz://pr?id=${BUZZ_EVENT_ID}&owner=${BUZZ_OWNER}&d=buzz-world — review please.`,
).map((preview) => [preview.kind, preview.title]),
[["buzz-pull-request", "buzz-world #c3b589fa"]],
);
});
test("extractSupportedLinkPreviews uses markdown labels for buzz:// links", () => {
assert.deepEqual(
extractSupportedLinkPreviews(
`[Add header links](buzz://pr?id=${BUZZ_EVENT_ID}&owner=${BUZZ_OWNER}&d=buzz-world)`,
).map((preview) => preview.title),
["Add header links"],
);
});
test("parseSupportedLinkPreview parses Linear issue URLs", () => {
assert.deepEqual(
parseSupportedLinkPreview(
@@ -114,6 +268,66 @@ test("extractSupportedLinkPreviews returns unique supported links in order", ()
);
});
test("extractSupportedLinkPreviews picks up bare Buzz clone URLs in prose", () => {
assert.deepEqual(
extractSupportedLinkPreviews(
`master pushed; clone: https://buzz.block.builderlab.xyz/git/${BUZZ_OWNER}/buzz-world-galaxy and review please.`,
"https://buzz.block.builderlab.xyz",
),
[
{
kind: "buzz-repository",
href: `buzz://repo?owner=${BUZZ_OWNER}&d=buzz-world-galaxy`,
provider: "Buzz",
title: "buzz-world-galaxy",
typeLabel: "repo",
},
],
);
// Without a relay origin the URL is treated as an ordinary external link.
assert.deepEqual(
extractSupportedLinkPreviews(
`clone: https://buzz.block.builderlab.xyz/git/${BUZZ_OWNER}/buzz-world-galaxy`,
),
[],
);
});
test("extractSupportedLinkPreviews uses markdown labels for Buzz repo links", () => {
assert.deepEqual(
extractSupportedLinkPreviews(
`[Buzz World](https://relay.example/git/${BUZZ_OWNER}/buzz-world-galaxy)`,
"https://relay.example",
).map((preview) => preview.title),
["Buzz World"],
);
});
test("extractSupportedLinkPreviews dedupes clone URL variants of one repo", () => {
assert.deepEqual(
extractSupportedLinkPreviews(
[
`https://relay.example/git/${BUZZ_OWNER}/buzz-world-galaxy`,
`https://relay.example/git/${BUZZ_OWNER}/buzz-world-galaxy.git`,
].join(" "),
"https://relay.example",
).map((preview) => preview.href),
[`buzz://repo?owner=${BUZZ_OWNER}&d=buzz-world-galaxy`],
);
});
test("clone URLs and buzz://repo links for the same repo dedupe to one card", () => {
assert.deepEqual(
extractSupportedLinkPreviews(
[
`https://relay.example/git/${BUZZ_OWNER}/buzz-world-galaxy`,
`buzz://repo?owner=${BUZZ_OWNER}&d=buzz-world-galaxy`,
].join(" "),
).map((preview) => preview.href),
[`buzz://repo?owner=${BUZZ_OWNER}&d=buzz-world-galaxy`],
);
});
test("extractSupportedLinkPreviews handles markdown link serialization", () => {
assert.deepEqual(
extractSupportedLinkPreviews(
@@ -252,3 +466,106 @@ test("isSupportedLinkAutolinkLabel matches normalized bare URL labels", () => {
);
assert.equal(isSupportedLinkAutolinkLabel("review this", preview), false);
});
// ── useResolvedLinkPreviews: behavioral regression pins ──────────────────────
//
// These tests pin the three behaviors that were implemented without tests in
// the initial fix round. They use the exported pure helpers directly so no
// React hook environment is required.
import {
getLinkPreviewCacheGeneration,
resetLinkPreviewTitleCache,
shouldResolveTitle,
} from "./useResolvedLinkPreviews.ts";
import { buzzEntityFallbackTitle } from "./linkPreview.ts";
const OWNER_HEX =
"71d67180ba17e749ee825fc8819c9c6ee7003617e1c126504f9b658070ab9224";
const EVENT_HEX =
"c3b589fa5713ba25bad6dc095e2de00a4ac8f50050fdea00fc6444e603be1dd1";
function makePrPreview(title) {
return {
kind: "buzz-pull-request",
href: `buzz://pr?id=${EVENT_HEX}&owner=${OWNER_HEX}&d=buzz-world`,
title,
provider: "Buzz",
typeLabel: "pr",
};
}
// 1. Cache epoch: stale promise cannot seed the new generation.
// `resetLinkPreviewTitleCache` must increment the generation counter so that
// a promise captured before the reset sees a different generation and skips
// writing back.
test("resetLinkPreviewTitleCache_incrementsGenerationCounter", () => {
const before = getLinkPreviewCacheGeneration();
resetLinkPreviewTitleCache();
const after = getLinkPreviewCacheGeneration();
assert.equal(after, before + 1, "each reset must bump the generation by 1");
resetLinkPreviewTitleCache();
assert.equal(
getLinkPreviewCacheGeneration(),
before + 2,
"second reset must increment again",
);
});
// 2. Mismatched a-tag: shouldResolveTitle uses buzzEntityFallbackTitle to
// decide whether to attempt a relay lookup. When the link's href parses to a
// PR/issue with the expected fallback title, resolution should proceed. When
// the title has already been set to something else (explicit label or earlier
// relay result), shouldResolveTitle must return false so the label wins.
test("shouldResolveTitle_fallbackTitle_returnsTrue", () => {
const parsed = {
ok: true,
value: { type: "pr", id: EVENT_HEX, owner: OWNER_HEX, dtag: "buzz-world" },
};
// Construct the expected fallback title and verify shouldResolveTitle allows lookup.
const fallback = buzzEntityFallbackTitle(parsed.value);
const preview = makePrPreview(fallback);
assert.equal(
shouldResolveTitle(preview),
true,
"fallback title should trigger relay lookup",
);
});
test("shouldResolveTitle_customLabel_returnsFalse_labelMustWin", () => {
// User has written `[My custom PR title](buzz://pr?...)` — the label must
// win; shouldResolveTitle must return false to skip writing the relay title.
const preview = makePrPreview("My custom PR title");
assert.equal(
shouldResolveTitle(preview),
false,
"custom label must suppress relay title lookup (label-must-win invariant)",
);
});
// 3. Label-rerender: converting a bare link to `[label](link)` changes the
// preview title away from the fallback — shouldResolveTitle transitions
// from true to false, so a cached relay title is not applied.
test("shouldResolveTitle_transitionsFromTrueToFalseWhenLabelApplied", () => {
const parsed = {
ok: true,
value: { type: "pr", id: EVENT_HEX, owner: OWNER_HEX, dtag: "buzz-world" },
};
const fallback = buzzEntityFallbackTitle(parsed.value);
// Before the label: bare link with fallback title — should resolve.
const barePreview = makePrPreview(fallback);
assert.equal(
shouldResolveTitle(barePreview),
true,
"bare link should resolve",
);
// After the label: same href but title is now the user's label — must NOT resolve.
const labeledPreview = makePrPreview("My labeled PR");
assert.equal(
shouldResolveTitle(labeledPreview),
false,
"labeled link must not overwrite label with cached relay title",
);
});

View File

@@ -1,4 +1,16 @@
import {
buildIssueLink,
buildPullRequestLink,
buildRepoLink,
isEntityLink,
parseEntityLink,
type ParsedEntityLink,
} from "./entityLink";
export type SupportedLinkPreviewKind =
| "buzz-pull-request"
| "buzz-issue"
| "buzz-repository"
| "github-pull-request"
| "github-issue"
| "github-repository"
@@ -13,6 +25,7 @@ export type SupportedLinkPreview = {
kind: SupportedLinkPreviewKind;
href: string;
provider:
| "Buzz"
| "GitHub"
| "Linear"
| "Google Drive"
@@ -31,10 +44,13 @@ export type SupportedLinkPreview = {
| "presentation";
};
// Buzz relay hosts differ per community, so relay git URLs are recognized by
// their distinctive path shape (`/git/<64-hex-pubkey>/<repo>`) rather than by
// hostname, and require an explicit scheme.
const SUPPORTED_URL_RE =
/(^|[\s([{<>"'])((?:https?:\/\/)?(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^\s<>"'\]]+)/gi;
/(^|[\s([{<>"'])((?:https?:\/\/)?(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^\s<>"'\]]+|https?:\/\/[^\s<>"'\]]+\/git\/[a-f0-9]{64}\/[^\s<>"'\]]+|buzz:\/\/(?:pr|issue|repo)\?[^\s<>"'\]]+)/gi;
const MARKDOWN_SUPPORTED_LINK_RE =
/!?\[([^\]\n]+)\]\(((?:https?:\/\/)?(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^)\s<>"']+)\)/gi;
/!?\[([^\]\n]+)\]\(((?:https?:\/\/)?(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^)\s<>"']+|https?:\/\/[^)\s<>"']+\/git\/[a-f0-9]{64}\/[^)\s<>"']+|buzz:\/\/(?:pr|issue|repo)\?[^)\s<>"']+)\)/gi;
const MAX_PREVIEWS = 8;
type HiddenRange = {
@@ -266,6 +282,97 @@ function createPreview(
};
}
/**
* Placeholder title shown before (or instead of) the relay event lookup in
* `useResolvedLinkPreviews` resolves the real `subject` / repo name.
* Exported so the resolver can tell "still the fallback" apart from a
* markdown-label override it must not overwrite.
*/
export function buzzEntityFallbackTitle(link: ParsedEntityLink): string {
if (link.type === "repo") return link.dtag;
return `${link.dtag} #${link.id.slice(0, 8)}`;
}
/**
* Map a `buzz://pr|issue|repo` deep link onto a preview card. The href is
* rebuilt through the canonical builders so equivalent links (case or query
* order variants) dedupe to a single card.
*/
function parseBuzzEntityPreview(href: string): SupportedLinkPreview | null {
const parsed = parseEntityLink(href);
if (!parsed.ok) return null;
const link = parsed.value;
const title = buzzEntityFallbackTitle(link);
if (link.type === "pr") {
return {
kind: "buzz-pull-request",
href: buildPullRequestLink(link),
provider: "Buzz",
title,
typeLabel: "PR",
};
}
if (link.type === "issue") {
return {
kind: "buzz-issue",
href: buildIssueLink(link),
provider: "Buzz",
title,
typeLabel: "issue",
};
}
return {
kind: "buzz-repository",
href: buildRepoLink(link),
provider: "Buzz",
title,
typeLabel: "repo",
};
}
const BUZZ_GIT_PATH_RE =
/^\/git\/([a-f0-9]{64})\/([a-zA-Z0-9._-]+?)(?:\.git)?\/?$/;
/**
* Recognize a Buzz relay git URL (`{relay-origin}/git/<owner-pubkey>/<repo>`,
* the clone URL shape agents paste when announcing work). The preview href
* is normalized to the canonical `buzz://repo` deep link: the raw git
* transport endpoint is not a browsable page, and the buzz:// href gives the
* card the same in-app click navigation as explicit entity links (and
* dedupes the two spellings of the same repository).
*
* Security: the URL origin must equal `activeRelayOrigin` (the currently
* connected relay). Path shape alone is not proof that a host belongs to the
* active Buzz relay — an arbitrary external URL sharing the path shape must
* remain an ordinary external link. Pass `null` when the relay origin is not
* yet resolved; the link stays external until it can be verified.
*/
function parseBuzzGitLink(
parsed: URL,
activeRelayOrigin: string | null,
): SupportedLinkPreview | null {
if (!activeRelayOrigin || parsed.origin !== activeRelayOrigin) {
return null;
}
const match = BUZZ_GIT_PATH_RE.exec(parsed.pathname);
if (!match) return null;
const [, owner, repo] = match;
if (repo.startsWith(".") || repo.includes("..") || repo.length > 64) {
return null;
}
return {
kind: "buzz-repository",
href: buildRepoLink({ owner, dtag: repo }),
provider: "Buzz",
title: repo,
typeLabel: "repo",
};
}
function parseGithubLink(parsed: URL): SupportedLinkPreview | null {
if (normalizeHostname(parsed) !== "github.com") {
return null;
@@ -416,10 +523,15 @@ function parseGoogleDocsLink(parsed: URL): SupportedLinkPreview | null {
/** Parse a supported external URL into a compact preview. */
export function parseSupportedLinkPreview(
href: string,
activeRelayOrigin?: string | null,
): SupportedLinkPreview | null {
const candidate = trimUrlCandidate(href);
if (isEntityLink(candidate)) {
return parseBuzzEntityPreview(candidate);
}
let parsed: URL;
try {
const candidate = trimUrlCandidate(href);
parsed = new URL(
/^https?:\/\//i.test(candidate) ? candidate : `https://${candidate}`,
);
@@ -432,6 +544,7 @@ export function parseSupportedLinkPreview(
}
return (
parseBuzzGitLink(parsed, activeRelayOrigin ?? null) ??
parseGithubLink(parsed) ??
parseLinearIssue(parsed) ??
parseGoogleDriveLink(parsed) ??
@@ -442,16 +555,23 @@ export function parseSupportedLinkPreview(
export function isSupportedLinkAutolinkLabel(
label: string,
preview: SupportedLinkPreview,
activeRelayOrigin?: string | null,
): boolean {
return parseSupportedLinkPreview(label)?.href === preview.href;
return (
parseSupportedLinkPreview(label, activeRelayOrigin)?.href === preview.href
);
}
function titleFromMarkdownLabel(
label: string,
preview: SupportedLinkPreview,
activeRelayOrigin: string | null,
): string | null {
const title = label.replace(/\s+/g, " ").trim();
if (!title || isSupportedLinkAutolinkLabel(title, preview)) {
if (
!title ||
isSupportedLinkAutolinkLabel(title, preview, activeRelayOrigin)
) {
return null;
}
return title;
@@ -474,6 +594,7 @@ type LinkPreviewCandidate = {
/** Extract supported link previews from message text, preserving first-seen order. */
export function extractSupportedLinkPreviews(
content: string,
activeRelayOrigin?: string | null,
): SupportedLinkPreview[] {
const previews: SupportedLinkPreview[] = [];
const seen = new Set<string>();
@@ -506,8 +627,9 @@ export function extractSupportedLinkPreviews(
candidates.sort((a, b) => a.index - b.index || a.order - b.order);
const relayOrigin = activeRelayOrigin ?? null;
for (const candidate of candidates) {
const preview = parseSupportedLinkPreview(candidate.href);
const preview = parseSupportedLinkPreview(candidate.href, relayOrigin);
if (!preview || seen.has(preview.href)) continue;
seen.add(preview.href);
@@ -515,7 +637,7 @@ export function extractSupportedLinkPreviews(
withTitle(
preview,
candidate.label
? titleFromMarkdownLabel(candidate.label, preview)
? titleFromMarkdownLabel(candidate.label, preview, relayOrigin)
: null,
),
);

View File

@@ -34,12 +34,13 @@ function install(ls) {
}
test("startup recovery removes disposable caches but preserves user state", () => {
const ls = makeQuotaLocalStorage({ maxEntries: 5 });
const ls = makeQuotaLocalStorage({ maxEntries: 6 });
install(ls);
ls.store.set("buzz-channel-messages.v1:relay:chan", "big");
ls.store.set("buzz-channels.v1:relay", "big");
ls.store.set("buzz-timeline-skeleton-shape.v1:chan", "small");
ls.store.set("buzz-sidebar-skeleton-shape.v1:community:user", "small");
ls.store.set("buzz-user-labels.v1:relay", "small");
ls.store.set("buzz-communities", "keep");
recoverLocalStorageQuotaOnStartup();
@@ -51,6 +52,7 @@ test("startup recovery removes disposable caches but preserves user state", () =
ls.getItem("buzz-sidebar-skeleton-shape.v1:community:user"),
null,
);
assert.equal(ls.getItem("buzz-user-labels.v1:relay"), null);
assert.equal(ls.getItem("buzz-communities"), "keep");
assert.equal(ls.getItem("buzz-local-storage-quota-recovery.v1"), "1");
});
@@ -200,3 +202,40 @@ test("returns false when eviction frees nothing", () => {
assert.equal(ls.getItem("k"), null);
assert.equal(ls.getItem("buzz-workspaces"), "keep");
});
test("buzz-observed-unread.v1: prefix participates in LRU eviction and durable state survives", () => {
// Sentinel: fails if buzz-observed-unread.v1: is removed from PURE_CACHE_KEY_PREFIXES —
// the bucket becomes invisible to LRU and the wrong entry is evicted instead.
const ls = makeQuotaLocalStorage({ maxEntries: 20 });
install(ls);
ls.store.set("buzz-communities", "keep");
const snapshot = (updatedAt) =>
JSON.stringify({ updatedAt, payload: "x".repeat(400_000) });
const observedKey = "buzz-observed-unread.v1:wss://relay.example.com:pk1";
const olderKey = "buzz-channel-messages.v1:relay:older";
const newestKey = "buzz-channel-messages.v1:relay:newest";
// Seed observed-unread (oldest updatedAt=1) and a sibling channel-messages entry (updatedAt=2).
assert.equal(setLocalStorageItemWithRecovery(observedKey, snapshot(1)), true);
assert.equal(setLocalStorageItemWithRecovery(olderKey, snapshot(2)), true);
// Writing a third pure-cache entry (updatedAt=3) pushes the total above the 2 MiB budget.
// The observed-unread entry must be evicted first (oldest LRU); the sibling survives.
assert.equal(setLocalStorageItemWithRecovery(newestKey, snapshot(3)), true);
assert.equal(
ls.getItem(observedKey),
null,
"observed-unread bucket must be evicted as the oldest LRU pure-cache entry",
);
assert.notEqual(
ls.getItem(olderKey),
null,
"channel-messages bucket with newer updatedAt must survive",
);
assert.equal(
ls.getItem("buzz-communities"),
"keep",
"durable state must survive",
);
});

View File

@@ -10,8 +10,10 @@
const PURE_CACHE_KEY_PREFIXES = [
"buzz-channel-messages.v1:",
"buzz-channels.v1:",
"buzz-observed-unread.v1:",
"buzz-sidebar-skeleton-shape.v1:",
"buzz-timeline-skeleton-shape.v1:",
"buzz-user-labels.v1:",
];
const QUOTA_RECOVERY_MARKER_KEY = "buzz-local-storage-quota-recovery.v1";

View File

@@ -231,6 +231,19 @@ async function fetchProxyPort(): Promise<number | null> {
return cachedPort;
}
/**
* Ensure the relay-origin lookup is active after a community cache reset.
*
* Consumers that classify relay-owned URLs may render before any media URL
* asks for the proxy port. Starting the shared fetch here keeps host
* classification independent from incidental image/video rendering.
*/
export function ensureRelayOriginFetch(): void {
if (!portPromise && typeof window !== "undefined") {
portPromise = fetchProxyPort();
}
}
/** Eagerly fetch the port at module load so it's ready by first render. */
// The try/catch inside fetchProxyPort handles non-Tauri environments gracefully
// (invoke will throw, we retry until timeout, then give up — no side effects).
@@ -319,7 +332,7 @@ export function rewriteRelayUrl(url: string): string {
}
if (!portPromise && typeof window !== "undefined") {
portPromise = fetchProxyPort();
ensureRelayOriginFetch();
}
return `buzz-media://localhost/media/${m[1]}`;

View File

@@ -1,6 +1,10 @@
import * as React from "react";
import { getCachedRelayOrigin, subscribeRelayOrigin } from "./mediaUrl";
import {
ensureRelayOriginFetch,
getCachedRelayOrigin,
subscribeRelayOrigin,
} from "./mediaUrl";
/**
* The resolved relay origin, re-rendering when it resolves or changes.
@@ -16,9 +20,11 @@ import { getCachedRelayOrigin, subscribeRelayOrigin } from "./mediaUrl";
* "not yet downloadable" (fail closed).
*/
export function useRelayOrigin(): string | null {
return React.useSyncExternalStore(
const origin = React.useSyncExternalStore(
subscribeRelayOrigin,
getCachedRelayOrigin,
() => null,
);
React.useEffect(ensureRelayOriginFetch, []);
return origin;
}

View File

@@ -1,8 +1,17 @@
import * as React from "react";
import { invokeTauri } from "@/shared/api/tauri";
import { relayClient } from "@/shared/api/relayClient";
import {
KIND_GIT_ISSUE,
KIND_GIT_PULL_REQUEST,
} from "@/shared/constants/kinds";
import type { SupportedLinkPreview } from "./linkPreview";
import { parseEntityLink } from "./entityLink";
import {
buzzEntityFallbackTitle,
type SupportedLinkPreview,
} from "./linkPreview";
const GOOGLE_FALLBACK_TITLES = new Set([
"Drive file",
@@ -13,33 +22,126 @@ const GOOGLE_FALLBACK_TITLES = new Set([
]);
const titleCache = new Map<string, Promise<string | null> | string | null>();
/**
* Generation counter incremented on every `resetLinkPreviewTitleCache` call.
* Each in-flight promise captures the generation at creation time and only
* writes back if no reset has happened since — preventing a resolved promise
* from a previous community from repopulating stale entries into the fresh
* cache of a new community.
*/
let cacheGeneration = 0;
/**
* Buzz entity titles come from relay events, so they are community-scoped —
* wired into `resetCommunityState()` (see useCommunityInit.ts) to avoid
* leaking titles across community switches.
*/
export function resetLinkPreviewTitleCache(): void {
cacheGeneration += 1;
titleCache.clear();
}
/**
* Returns the current cache generation counter. Used in tests to verify that
* `resetLinkPreviewTitleCache` increments the generation so stale in-flight
* promises cannot seed the new cache.
*
* @internal test-only export
*/
export function getLinkPreviewCacheGeneration(): number {
return cacheGeneration;
}
function fetchLinkPreviewTitle(href: string): Promise<string | null> {
return invokeTauri<string | null>("fetch_link_preview_title", { href });
}
function shouldResolveTitle(preview: SupportedLinkPreview): boolean {
/**
* Resolve a Buzz PR/issue card title from the relay event's `subject` tag
* (first content line as fallback — the same precedence the projects views
* use).
*
* Security: the fetched event's canonical `a` tag must equal
* `30617:<owner>:<d>` from the link before we adopt its title. Without this
* check, a crafted link could pair the real title of a legitimate PR with an
* unrelated repository destination.
*/
async function fetchBuzzEntityTitle(href: string): Promise<string | null> {
const parsed = parseEntityLink(href);
if (!parsed.ok || parsed.value.type === "repo") return null;
const { id, owner, dtag } = parsed.value;
const expectedCoordinate = `30617:${owner}:${dtag}`;
const events = await relayClient.fetchEvents({
kinds: [
parsed.value.type === "pr" ? KIND_GIT_PULL_REQUEST : KIND_GIT_ISSUE,
],
ids: [id],
limit: 1,
});
const event = events[0];
if (!event) return null;
// Verify the event belongs to the claimed repository coordinate.
const aTag = event.tags.find(
(tag) => tag[0] === "a" && tag[1] === expectedCoordinate,
);
if (!aTag) return null;
const subject = event.tags.find((tag) => tag[0] === "subject")?.[1];
return subject || event.content.split("\n")[0] || null;
}
/**
* Returns true when the preview's current title is still the auto-generated
* fallback and a relay lookup should be attempted to replace it. Returns false
* once the user has applied a markdown label (`[My label](link)`) so that the
* label wins over any cached relay title.
*
* Exported for unit testing of the label-must-win invariant.
*/
export function shouldResolveTitle(preview: SupportedLinkPreview): boolean {
if (preview.kind === "buzz-pull-request" || preview.kind === "buzz-issue") {
// A markdown-label override replaces the fallback title and must win
// over the relay lookup.
const parsed = parseEntityLink(preview.href);
return parsed.ok && preview.title === buzzEntityFallbackTitle(parsed.value);
}
return (
preview.kind.startsWith("google-") &&
GOOGLE_FALLBACK_TITLES.has(preview.title)
);
}
function cacheTitle(href: string): Promise<string | null> {
const cached = titleCache.get(href);
function resolveTitle(preview: SupportedLinkPreview): Promise<string | null> {
return preview.href.startsWith("buzz://")
? fetchBuzzEntityTitle(preview.href)
: fetchLinkPreviewTitle(preview.href);
}
function cacheTitle(preview: SupportedLinkPreview): Promise<string | null> {
const cached = titleCache.get(preview.href);
if (cached instanceof Promise) return cached;
if (cached !== undefined) return Promise.resolve(cached);
const promise = fetchLinkPreviewTitle(href)
const generation = cacheGeneration;
const promise = resolveTitle(preview)
.then((title) => {
titleCache.set(href, title);
// Only write back if no community switch has happened since we started.
if (cacheGeneration === generation) {
titleCache.set(preview.href, title);
}
return title;
})
.catch(() => {
titleCache.set(href, null);
if (cacheGeneration === generation) {
titleCache.set(preview.href, null);
}
return null;
});
titleCache.set(href, promise);
titleCache.set(preview.href, promise);
return promise;
}
@@ -66,7 +168,7 @@ export function useResolvedLinkPreviews(
continue;
}
void cacheTitle(preview.href).then((title) => {
void cacheTitle(preview).then((title) => {
if (cancelled || !title) return;
setResolvedTitles((current) =>
current[preview.href] === title
@@ -85,7 +187,14 @@ export function useResolvedLinkPreviews(
() =>
previews.map((preview) => {
const title = resolvedTitles[preview.href];
return title ? { ...preview, title } : preview;
// Only apply a relay-resolved title while the preview still has the
// fallback title (i.e. no markdown-label override). If the user edits
// a bare link into `[My label](same-link)`, `shouldResolveTitle`
// returns false and the label wins — the cached relay title is not
// applied to prevent it from silently overriding the explicit label.
return title && shouldResolveTitle(preview)
? { ...preview, title }
: preview;
}),
[previews, resolvedTitles],
);

View File

@@ -10,6 +10,7 @@
@import "./globals/skeleton.css";
@import "./globals/spoilers.css";
@import "./globals/components.css";
@import "./globals/terminal.css";
@import "./globals/utilities.css";
@import "./globals/media-controls.css";
@import "./globals/avatar-framing.css";
@@ -30,3 +31,6 @@
Must stay below every `@import`: CSS requires `@import` to precede other
at-rules, so placing this above them silently drops the rest of the sheet. */
@custom-variant hover (&:hover);
/* The app persists its selected theme on the root `.dark` class. Use that
class, rather than the system preference, for every `dark:` utility. */
@custom-variant dark (&:where(.dark, .dark *));

View File

@@ -10,10 +10,9 @@
--buzz-huddle-drawer-radius: 24px;
--buzz-huddle-drawer-ease: cubic-bezier(0.32, 0.72, 0, 1);
--buzz-huddle-drawer-surface: var(--huddle-drawer-surface, 0 0% 0%);
/* The lifted app surface reveals this shell at its rounded corners. The
* Buzz theme paints its gradient here; the drawer remains responsible for
* its own dark surface below. */
background: transparent;
/* Keep the drawer surface behind the lifted app so the dark background
* remains visible through its rounded lower corners. */
background: hsl(var(--buzz-huddle-drawer-surface));
}
.buzz-huddle-app-surface {
@@ -61,6 +60,26 @@
opacity: 1;
}
.buzz-huddle-drawer-backdrop {
background: hsl(var(--buzz-huddle-drawer-surface));
bottom: 0;
height: calc(
var(--buzz-huddle-drawer-height) +
var(--buzz-huddle-drawer-radius)
);
left: 0;
opacity: 0;
pointer-events: none;
position: absolute;
right: 0;
transition: opacity 260ms var(--buzz-huddle-drawer-ease);
z-index: 1;
}
.buzz-huddle-drawer-backdrop-open {
opacity: 1;
}
/* The dedicated companion keeps room for the controls without making the
* transcript look like a raised card. */
.buzz-huddle-app-surface-room-open {
@@ -199,6 +218,10 @@
transition: none;
}
.buzz-huddle-drawer-backdrop {
transition: none;
}
.buzz-huddle-mic-meter-bar {
transition: none;
}
@@ -751,119 +774,3 @@
}
}
}
@layer components {
.buzz-terminal-substrate {
background: var(--buzz-terminal-background, #101014);
color: var(--buzz-terminal-foreground, #e8e8ec);
display: flex;
flex-direction: column;
font-family: "JetBrains Mono", monospace;
font-variant-ligatures: none;
inset: 0;
position: absolute;
user-select: none;
z-index: 0;
}
.buzz-terminal-contract-bar {
align-items: stretch;
border-bottom: 1px solid hsl(var(--border));
display: flex;
flex: 0 0 32px;
justify-content: space-between;
min-width: 0;
}
.buzz-terminal-tabs,
.buzz-terminal-readout {
align-items: center;
display: flex;
min-width: 0;
}
.buzz-terminal-tab,
.buzz-terminal-tab-select,
.buzz-terminal-close,
.buzz-terminal-new-tab {
align-items: center;
background: transparent;
border: 0;
color: inherit;
display: flex;
font: inherit;
gap: 8px;
height: 100%;
opacity: 0.62;
padding: 0 10px;
position: relative;
}
.buzz-terminal-tab-active {
opacity: 1;
}
.buzz-terminal-tab-active::after {
background: hsl(var(--buzz-selected-accent));
bottom: 0;
content: "";
height: 1px;
left: 0;
position: absolute;
right: 0;
}
.buzz-terminal-designator,
.buzz-terminal-readout {
font-size: 0.5625rem;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.buzz-terminal-close {
opacity: 0;
}
.buzz-terminal-tab:hover .buzz-terminal-close,
.buzz-terminal-close:focus {
opacity: 1;
}
.buzz-terminal-readout {
gap: 14px;
padding: 0 12px;
white-space: nowrap;
}
.buzz-terminal-viewport,
.buzz-terminal-viewport canvas {
height: 100%;
min-height: 0;
width: 100%;
}
.buzz-terminal-viewport {
overflow: hidden;
position: relative;
}
.buzz-terminal-viewport canvas {
display: block;
}
.buzz-terminal-welcome {
inset: 0;
pointer-events: none;
position: absolute;
z-index: 1;
}
.buzz-terminal-input {
height: 1px;
left: -10000px;
opacity: 0;
position: absolute;
top: 0;
width: 1px;
}
}

View File

@@ -0,0 +1,302 @@
@layer components {
.buzz-terminal-substrate {
background: var(--buzz-terminal-background, #101014);
color: var(--buzz-terminal-foreground, #e8e8ec);
display: flex;
flex-direction: column;
font-family: "JetBrains Mono", monospace;
font-variant-ligatures: none;
inset: 0;
position: absolute;
user-select: none;
z-index: 0;
}
.buzz-terminal-contract-bar {
align-items: center;
background: hsl(var(--secondary));
border-bottom: 1px solid hsl(var(--border));
color: hsl(var(--secondary-foreground));
display: flex;
flex: 0 0 40px;
font-family: inherit;
font-variant-ligatures: normal;
gap: 8px;
justify-content: space-between;
min-width: 0;
padding: 4px 1.25rem;
}
.buzz-terminal-tabs,
.buzz-terminal-readout {
align-items: center;
display: flex;
min-width: 0;
}
.buzz-terminal-tab,
.buzz-terminal-tab-select,
.buzz-terminal-close,
.buzz-terminal-new-tab {
align-items: center;
background: transparent;
border: 0;
border-radius: calc(var(--radius) - 2px);
color: hsl(var(--muted-foreground));
display: flex;
font: inherit;
font-size: 0.75rem;
font-weight: 500;
gap: 6px;
height: 30px;
padding: 0 9px;
position: relative;
}
.buzz-terminal-tabs {
flex: 1 1 auto;
gap: 4px;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
}
.buzz-terminal-tabs::-webkit-scrollbar {
display: none;
}
.buzz-terminal-readout {
background: hsl(var(--secondary));
flex: 0 0 auto;
gap: 4px;
padding: 0;
position: relative;
z-index: 1;
}
.buzz-terminal-tab {
background: hsl(var(--background));
border-radius: 4px;
flex: 0 0 auto;
padding: 0;
}
.buzz-terminal-tab-active {
background: hsl(var(--background));
}
.buzz-terminal-tab:hover,
.buzz-terminal-tab:focus-within {
background: hsl(var(--foreground) / 0.06);
}
.buzz-terminal-tab-select {
border-radius: inherit;
max-width: 12rem;
min-width: 0;
padding-left: 32px;
}
.buzz-terminal-tab-active .buzz-terminal-tab-select {
color: hsl(var(--foreground));
}
.buzz-terminal-new-tab:hover {
background: hsl(var(--foreground) / 0.06);
color: hsl(var(--foreground));
}
.buzz-terminal-new-tab {
align-items: center;
background: hsl(var(--background));
border-radius: 4px;
height: 30px;
justify-content: center;
padding: 7px;
width: 30px;
}
.buzz-terminal-new-tab svg {
height: 16px;
width: 16px;
}
.buzz-terminal-tab-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.buzz-terminal-designator {
align-items: center;
display: inline-flex;
font-size: 0.75rem;
gap: 2px;
letter-spacing: 0;
}
.buzz-terminal-designator svg {
height: 16px;
width: 16px;
}
.buzz-terminal-close {
height: 16px;
left: 8px;
opacity: 0;
padding: 0;
pointer-events: none;
position: absolute;
width: 16px;
z-index: 1;
}
.buzz-terminal-tab:hover .buzz-terminal-close,
.buzz-terminal-tab:focus-within .buzz-terminal-close {
color: hsl(var(--foreground));
opacity: 1;
pointer-events: auto;
}
.buzz-terminal-close svg {
height: 16px;
width: 16px;
}
.buzz-terminal-readout {
white-space: nowrap;
}
.buzz-terminal-viewport,
.buzz-terminal-viewport canvas {
height: 100%;
min-height: 0;
width: 100%;
}
.buzz-terminal-viewport {
overflow: hidden;
position: relative;
}
.buzz-terminal-viewport canvas {
display: block;
}
.buzz-terminal-welcome {
inset: 0;
pointer-events: none;
position: absolute;
z-index: 1;
}
.buzz-terminal-input {
height: 1px;
left: -10000px;
opacity: 0;
position: absolute;
top: 0;
width: 1px;
}
}
@layer components {
.buzz-terminal-dock-host:has(.buzz-terminal-substrate) {
align-items: flex-end;
display: flex;
flex: 0 0 auto;
min-height: 0;
overflow: hidden;
transition:
flex-grow 180ms ease,
flex-basis 180ms ease;
}
.buzz-content-primary {
transition:
flex-grow 180ms ease,
flex-basis 180ms ease;
}
.buzz-terminal-dock-host:has([data-terminal-mode="maximized"]) {
flex: 1 1 auto;
}
.buzz-content-primary:has(
+ .buzz-terminal-dock-host [data-terminal-mode="maximized"]
) {
flex: 0 1 0%;
min-height: 0;
}
.buzz-terminal-substrate {
border-top: 1px solid hsl(var(--border));
inset: auto;
min-height: 180px;
opacity: 1;
position: relative;
transform: translateY(0);
transition:
height 180ms ease,
transform 180ms ease;
width: 100%;
z-index: 20;
}
.buzz-terminal-substrate[data-terminal-resizing="true"] {
transition: none;
}
.buzz-terminal-substrate[data-terminal-visible="false"] {
height: 0 !important;
min-height: 0;
pointer-events: none;
transform: translateY(16px);
}
.buzz-terminal-dock-host [data-terminal-mode="maximized"] {
flex: 1 1 auto;
height: 100%;
}
.buzz-terminal-resize-handle {
cursor: ns-resize;
height: 5px;
left: 0;
position: absolute;
right: 0;
top: -3px;
z-index: 2;
}
.buzz-terminal-window-action {
align-items: center;
background: transparent;
border: 0;
border-radius: calc(var(--radius) - 2px);
color: hsl(var(--muted-foreground));
display: inline-flex;
height: 30px;
justify-content: center;
width: 30px;
}
.buzz-terminal-window-action:hover,
.buzz-terminal-window-action:focus-visible {
background: hsl(var(--foreground) / 0.06);
color: hsl(var(--foreground));
}
.buzz-terminal-window-action svg {
height: 16px;
width: 16px;
}
@media (prefers-reduced-motion: reduce) {
.buzz-content-primary,
.buzz-terminal-dock-host:has(.buzz-terminal-substrate),
.buzz-terminal-substrate {
transition: none;
}
}
}

View File

@@ -1,9 +1,12 @@
import { ArrowDown, ArrowUp } from "lucide-react";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
const UNREAD_PILL_CLASS =
"pointer-events-auto h-7 min-h-7 gap-1.5 rounded-full border-border/70 bg-background/95 px-2 py-1 text-2xs font-medium tracking-[0.02em] text-muted-foreground/70 shadow-xs backdrop-blur-sm hover:bg-muted/70 hover:text-foreground [&_svg]:size-4";
const PRIMARY_UNREAD_PILL_CLASS =
"pointer-events-auto h-7 min-h-7 gap-1.5 rounded-full px-2 py-1 text-xs font-medium shadow-sm [&_svg]:size-4";
export function unreadCountLabel(count: number) {
return `${count} new message${count === 1 ? "" : "s"}`;
@@ -11,11 +14,13 @@ export function unreadCountLabel(count: number) {
export function UnreadPill({
direction,
emphasis = "default",
label,
onClick,
testId,
}: {
direction: "up" | "down";
emphasis?: "default" | "primary";
label: string;
onClick: () => void;
testId: string;
@@ -23,12 +28,14 @@ export function UnreadPill({
const Arrow = direction === "up" ? ArrowUp : ArrowDown;
return (
<Button
className={UNREAD_PILL_CLASS}
className={cn(
emphasis === "primary" ? PRIMARY_UNREAD_PILL_CLASS : UNREAD_PILL_CLASS,
)}
data-testid={testId}
onClick={onClick}
size="sm"
type="button"
variant="outline"
variant={emphasis === "primary" ? "default" : "outline"}
>
<Arrow aria-hidden />
{label}

View File

@@ -2,7 +2,12 @@ import * as React from "react";
import { cn } from "@/shared/lib/cn";
import { DialogContent, DialogHeader, DialogTitle } from "./dialog";
import {
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "./dialog";
type ChooserDialogContentProps = React.ComponentPropsWithoutRef<
typeof DialogContent
@@ -13,6 +18,7 @@ type ChooserDialogContentProps = React.ComponentPropsWithoutRef<
footerTestId?: string;
contentClassName?: string;
headerClassName?: string;
headerSubtitle?: React.ReactNode;
headerTestId?: string;
scrollAreaClassName?: string;
scrollAreaTestId?: string;
@@ -33,6 +39,7 @@ export const ChooserDialogContent = React.forwardRef<
footerClassName,
footerTestId,
headerClassName,
headerSubtitle,
headerTestId,
scrollAreaClassName,
scrollAreaTestId,
@@ -56,6 +63,9 @@ export const ChooserDialogContent = React.forwardRef<
data-testid={headerTestId}
>
<DialogTitle>{title}</DialogTitle>
{headerSubtitle ? (
<DialogDescription>{headerSubtitle}</DialogDescription>
) : null}
</DialogHeader>
<div

View File

@@ -2,6 +2,7 @@ import { ExternalLink } from "lucide-react";
import type { SupportedLinkPreview } from "@/shared/lib/linkPreview";
import { cn } from "@/shared/lib/cn";
import { BuzzMark } from "@/shared/ui/buzz-logo/BuzzMark";
import {
Attachment,
AttachmentActions,
@@ -91,6 +92,10 @@ function GoogleSlidesLogo({ className }: { className?: string }) {
function LinkPreviewLogo({ preview }: { preview: SupportedLinkPreview }) {
switch (preview.kind) {
case "buzz-issue":
case "buzz-pull-request":
case "buzz-repository":
return <BuzzMark className="h-4 w-4" />;
case "github-issue":
case "github-pull-request":
case "github-repository":
@@ -111,9 +116,16 @@ function LinkPreviewLogo({ preview }: { preview: SupportedLinkPreview }) {
export function LinkPreviewAttachment({
className,
onOpen,
preview,
}: {
className?: string;
/**
* In-app navigation handler for links the OS cannot open (e.g. `buzz://`
* entity deep links). When set, the card renders a button trigger instead
* of an external anchor.
*/
onOpen?: () => void;
preview: SupportedLinkPreview;
}) {
return (
@@ -141,18 +153,29 @@ export function LinkPreviewAttachment({
className="h-4 w-4 text-muted-foreground opacity-0 transition-opacity group-hover/attachment:opacity-100 group-focus-within/attachment:opacity-100"
/>
</AttachmentActions>
<AttachmentTrigger asChild>
<a
{onOpen ? (
<AttachmentTrigger
aria-label={`Open ${preview.provider} ${preview.typeLabel}: ${preview.title}`}
href={preview.href}
rel="noreferrer"
target="_blank"
onClick={onOpen}
>
<span className="sr-only">
Open {preview.provider} {preview.typeLabel}: {preview.title}
</span>
</a>
</AttachmentTrigger>
</AttachmentTrigger>
) : (
<AttachmentTrigger asChild>
<a
aria-label={`Open ${preview.provider} ${preview.typeLabel}: ${preview.title}`}
href={preview.href}
rel="noreferrer"
target="_blank"
>
<span className="sr-only">
Open {preview.provider} {preview.typeLabel}: {preview.title}
</span>
</a>
</AttachmentTrigger>
)}
</Attachment>
);
}

View File

@@ -521,9 +521,10 @@ test("rehypeImageGallery: leaves a single trailing image in the text flow", () =
// Regression test: react-markdown's `defaultUrlTransform` strips unknown
// schemes (returns `""`) before our `a` component override can see them,
// which would break copy → paste → click for `buzz://message?…` links
// end-to-end. We pass a custom `urlTransform` that delegates to the
// default for `buzz://message` and legacy `buzz://message` hrefs.
// which would break copy → paste → click for `buzz://message?…` links and
// `buzz://pr|issue|repo?…` entity links end-to-end. We pass a custom
// `urlTransform` (`buzzDeepLinkUrlTransform`) that preserves valid Buzz
// deep links and delegates everything else to `defaultUrlTransform`.
//
// This test renders real `<ReactMarkdown>` with the production transform
// and asserts the link href survives to the rendered DOM. Mirrors the
@@ -534,12 +535,18 @@ import { renderToStaticMarkup } from "react-dom/server";
import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
import { isMessageLink } from "../../features/messages/lib/messageLink.ts";
import { parseEntityLink } from "../lib/entityLink.ts";
import remarkSpoilers from "../lib/remarkSpoilers.ts";
function messageLinkUrlTransform(value, key) {
if (key === "href" && isMessageLink(value)) {
return value;
}
const OWNER_HEX =
"71d67180ba17e749ee825fc8819c9c6ee7003617e1c126504f9b658070ab9224";
const EVENT_HEX =
"c3b589fa5713ba25bad6dc095e2de00a4ac8f50050fdea00fc6444e603be1dd1";
function buzzDeepLinkUrlTransform(value, key) {
if (key !== "href") return defaultUrlTransform(value);
if (isMessageLink(value)) return value;
if (parseEntityLink(value).ok) return value;
return defaultUrlTransform(value);
}
@@ -547,7 +554,7 @@ function renderMarkdown(content) {
return renderToStaticMarkup(
React.createElement(
ReactMarkdown,
{ urlTransform: messageLinkUrlTransform },
{ urlTransform: buzzDeepLinkUrlTransform },
content,
),
);
@@ -592,7 +599,7 @@ test("messageLinkUrlTransform: preserves legacy buzz://message href", () => {
assert.match(html, /href="buzz:\/\/message\?channel=abc&(?:amp;)?id=xyz"/);
});
test("messageLinkUrlTransform: leaves non-message buzz:// schemes to default", () => {
test("messageLinkUrlTransform: leaves non-entity buzz:// schemes to default", () => {
// `buzz://connect?relay=…` is handled by a different code path (Tauri
// single-instance). The markdown renderer should let it pass through
// defaultUrlTransform (which strips it) since it's not clickable in-app.
@@ -602,6 +609,127 @@ test("messageLinkUrlTransform: leaves non-message buzz:// schemes to default", (
assert.match(html, /href=""/);
});
test("buzzDeepLinkUrlTransform: preserves buzz://pr entity link href", () => {
const prLink = `buzz://pr?id=${EVENT_HEX}&owner=${OWNER_HEX}&d=buzz-world`;
const html = renderMarkdown(`[My PR](${prLink})`);
// The href must survive — our transform preserves valid entity links.
assert.match(html, /href="buzz:\/\/pr\?/);
assert.doesNotMatch(html, /href=""/);
});
test("buzzDeepLinkUrlTransform: preserves buzz://pr autolink href", () => {
const prLink = `buzz://pr?id=${EVENT_HEX}&owner=${OWNER_HEX}&d=buzz-world`;
const html = renderMarkdown(`<${prLink}>`);
assert.match(html, /href="buzz:\/\/pr\?/);
assert.doesNotMatch(html, /href=""/);
});
test("buzzDeepLinkUrlTransform: preserves buzz://issue entity link href", () => {
const issueLink = `buzz://issue?id=${EVENT_HEX}&owner=${OWNER_HEX}&d=buzz-world`;
const html = renderMarkdown(`[Issue title](${issueLink})`);
assert.match(html, /href="buzz:\/\/issue\?/);
assert.doesNotMatch(html, /href=""/);
});
test("buzzDeepLinkUrlTransform: preserves buzz://repo entity link href", () => {
const repoLink = `buzz://repo?owner=${OWNER_HEX}&d=buzz-world`;
const html = renderMarkdown(`[My repo](${repoLink})`);
assert.match(html, /href="buzz:\/\/repo\?/);
assert.doesNotMatch(html, /href=""/);
});
test("buzzDeepLinkUrlTransform: strips malformed buzz://pr (unknown param)", () => {
// Strict parser rejects unknown params — transform falls back to default sanitizer.
const html = renderMarkdown(
`[link](buzz://pr?id=${EVENT_HEX}&owner=${OWNER_HEX}&d=buzz-world&extra=ignored)`,
);
assert.match(html, /href=""/);
});
// ── renderEntityLinkAnchor: clone-URL anchor origin gating ───────────────────
//
// `renderEntityLinkAnchor` now accepts `relayOrigin` and passes it to
// `parseSupportedLinkPreview`. A clone URL is only treated as an in-app entity
// link when the URL origin exactly matches the active relay origin. This covers
// the inline anchor click path (not just card extraction).
import { renderEntityLinkAnchor } from "../ui/markdown/entityLinks.tsx";
const CLONE_URL = `https://relay.example/git/${OWNER_HEX}/my-repo`;
test("renderEntityLinkAnchor_matchingOriginCloneUrl_returnsEntityAnchor", () => {
// Origin matches active relay — anchor should navigate in-app (non-null).
const el = renderEntityLinkAnchor({
anchorProps: {},
children: React.createElement("span", null, "my-repo"),
href: CLONE_URL,
onOpenEntityLink: () => {},
relayOrigin: "https://relay.example",
});
assert.notEqual(
el,
null,
"matching-origin clone URL must produce an entity anchor",
);
const html = renderToStaticMarkup(el);
// The entity anchor must be rendered — it carries the original href (for display)
// and navigates in-app via onClick. Verify the anchor is present with the cursor style.
assert.match(
html,
/cursor-pointer/,
"entity anchor must have the in-app navigation cursor style",
);
});
test("renderEntityLinkAnchor_lookalikeDomainCloneUrl_returnsNull", () => {
// Origin does NOT match active relay — must fall through to ExternalLinkAnchor.
const el = renderEntityLinkAnchor({
anchorProps: {},
children: React.createElement("span", null, "my-repo"),
href: CLONE_URL,
onOpenEntityLink: () => {},
relayOrigin: "https://evil.example",
});
assert.equal(
el,
null,
"lookalike-origin clone URL must return null so the anchor falls through to external",
);
});
test("renderEntityLinkAnchor_noRelayOrigin_cloneUrlReturnsNull", () => {
// No known relay origin — must fail closed, not guess.
const el = renderEntityLinkAnchor({
anchorProps: {},
children: React.createElement("span", null, "my-repo"),
href: CLONE_URL,
onOpenEntityLink: () => {},
relayOrigin: null,
});
assert.equal(
el,
null,
"clone URL without a relay origin must return null (fail closed)",
);
});
test("renderEntityLinkAnchor_directEntityLink_returnsAnchorRegardlessOfOrigin", () => {
// A direct buzz://pr link always resolves in-app — it does not require origin.
const prLink = `buzz://pr?id=${EVENT_HEX}&owner=${OWNER_HEX}&d=buzz-world`;
const el = renderEntityLinkAnchor({
anchorProps: {},
children: React.createElement("span", null, "My PR"),
href: prLink,
onOpenEntityLink: () => {},
relayOrigin: null,
});
assert.notEqual(
el,
null,
"direct buzz://pr link must produce an entity anchor regardless of origin",
);
});
test("remarkSpoilers: block delimiter spoilers expose a block prop to React", () => {
let spoilerProps;
renderToStaticMarkup(

View File

@@ -9,7 +9,6 @@ import {
ZoomOut,
} from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { openUrl } from "@tauri-apps/plugin-opener";
import { toast } from "sonner";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
@@ -23,13 +22,13 @@ import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
import { invokeTauri } from "@/shared/api/tauri";
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
import { cn } from "@/shared/lib/cn";
import { copyTextToClipboard } from "@/shared/lib/clipboard";
import {
extractSupportedLinkPreviews,
parseSupportedLinkPreview,
} from "@/shared/lib/linkPreview";
import { useResolvedLinkPreviews } from "@/shared/lib/useResolvedLinkPreviews";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import { useRelayOrigin } from "@/shared/lib/useRelayOrigin";
import { AttachmentGroup } from "@/shared/ui/attachment";
import { ConfigNudgeCard } from "@/shared/ui/config-nudge-attachment";
import { LinkPreviewAttachment } from "@/shared/ui/link-preview-attachment";
@@ -59,6 +58,12 @@ import {
MarkdownCodeBlock,
SyntaxHighlightedCode,
} from "./markdown/CodeBlock";
import {
renderEntityLinkAnchor,
useEntityCardOpenHandlers,
useOpenEntityLink,
} from "./markdown/entityLinks";
import { ExternalLinkAnchor } from "./markdown/ExternalLinkAnchor";
import { FileCard } from "./markdown/FileCard";
import { InlineEmojiPopover } from "./markdown/InlineEmojiPopover";
import { MarkdownInput } from "./markdown/MarkdownInput";
@@ -108,7 +113,6 @@ import {
visibleImageGalleryForTrigger,
} from "./markdown/imageLightbox";
import { MarkdownTable } from "./markdown/MarkdownTable";
import { MaskedLinkTooltip } from "./markdown/MaskedLinkTooltip";
import { ProgressiveImage } from "./markdown/ProgressiveImage";
import { MessageLinkPill } from "./markdown/MessageLinkPill";
import { renderCachedMarkdown } from "./markdown/nodeCache";
@@ -1272,85 +1276,6 @@ function ImageMosaic({ children }: { children: React.ReactNode[] }) {
);
}
/**
* An external `[text](href)` link with a custom right-click menu.
*
* Buzz renders inside a native webview whose default context menu has no
* useful link actions, so a plain right-click on a link is a no-op. This adds
* an in-app menu with "Open link" (via the OS opener, matching the anchor's
* left-click `target="_blank"` behavior) and "Copy link" (the real href, not
* the masked display text).
*/
function ExternalLinkAnchor({
anchorProps,
children,
href,
isLinearLink,
label,
}: {
anchorProps: React.ComponentPropsWithoutRef<"a">;
children: React.ReactNode;
href: string | undefined;
isLinearLink: boolean;
label: string;
}) {
const [menu, setMenu] = React.useState<MediaContextMenuPosition | null>(null);
const closeMenu = React.useCallback(() => setMenu(null), []);
useDismissMediaContextMenu(Boolean(menu), closeMenu);
const anchor = (
<a
{...anchorProps}
className={cn(
"font-medium underline underline-offset-4 transition-colors",
isLinearLink ? "linear-link" : "text-primary hover:text-primary/80",
)}
href={href}
onContextMenuCapture={(event) => {
if (!href) return;
event.preventDefault();
setMenu({ x: event.clientX, y: event.clientY });
}}
rel="noreferrer"
target="_blank"
>
{children}
</a>
);
return (
<>
<MaskedLinkTooltip disabled={isLinearLink} href={href} label={label}>
{anchor}
</MaskedLinkTooltip>
{menu && href ? (
<MediaContextMenu
dataAttributes={["data-link-context-menu"]}
items={[
{
label: "Open link",
onSelect: () => {
closeMenu();
void openUrl(href).catch(() => {
toast.error("Failed to open link");
});
},
},
{
label: "Copy link",
onSelect: () => {
closeMenu();
copyTextToClipboard(href, "Link copied to clipboard");
},
},
]}
position={menu}
/>
) : null}
</>
);
}
function createMarkdownComponents(
interactive = true,
mediaInset = false,
@@ -1366,8 +1291,10 @@ function createMarkdownComponents(
const {
channels,
imetaByUrl,
onOpenEntityLink,
onOpenMessageLink,
onImportSnapshotFromUrl,
relayOrigin,
snapshotSharedBy,
} = useMarkdownRuntime();
if (!interactive) {
@@ -1465,7 +1392,20 @@ function createMarkdownComponents(
// anchor (renders as a normal external link).
}
const supportedLinkPreview = href ? parseSupportedLinkPreview(href) : null;
// `buzz://pr|issue|repo?…` entity links navigate in-app; malformed ones
// fall through to the default anchor.
const entityAnchor = renderEntityLinkAnchor({
anchorProps: props,
children,
href,
onOpenEntityLink,
relayOrigin,
});
if (entityAnchor) return entityAnchor;
const supportedLinkPreview = href
? parseSupportedLinkPreview(href, relayOrigin)
: null;
const isLinearLink = supportedLinkPreview?.kind === "linear-issue";
return (
@@ -1799,7 +1739,7 @@ function createMarkdownComponents(
* four instances ever exist. Module-stable maps mean cached markdown element
* trees (see ./markdown/nodeCache.ts) never embed per-mount closures.
*/
const MARKDOWN_COMPONENT_SCHEMA_VERSION = "4";
const MARKDOWN_COMPONENT_SCHEMA_VERSION = "5";
const markdownComponentsByVariant = new Map<string, MarkdownComponentSet>();
type MarkdownComponentSet = { components: Components; variant: string };
@@ -1852,6 +1792,7 @@ function MarkdownInner({
},
[goChannel],
);
const onOpenEntityLink = useOpenEntityLink();
const onOpenMessageLink = React.useCallback(
(link: ParsedMessageLink) => {
// Always route through `goChannel` with `messageId` set: the channel
@@ -1868,9 +1809,11 @@ function MarkdownInner({
},
[goChannel],
);
const relayOrigin = useRelayOrigin();
const linkPreviews = React.useMemo(
() => (interactive ? extractSupportedLinkPreviews(content) : []),
[content, interactive],
() =>
interactive ? extractSupportedLinkPreviews(content, relayOrigin) : [],
[content, interactive, relayOrigin],
);
const configNudge = React.useMemo(
() => computeConfigNudge(content, interactive, configNudgeAuthorPubkey),
@@ -1883,7 +1826,9 @@ function MarkdownInner({
imetaByUrl,
mentionPubkeysByName,
onOpenChannel,
onOpenEntityLink,
onOpenMessageLink,
relayOrigin,
snapshotSharedBy,
onImportSnapshotFromUrl: (
fileBytes: number[],
@@ -1900,7 +1845,9 @@ function MarkdownInner({
imetaByUrl,
mentionPubkeysByName,
onOpenChannel,
onOpenEntityLink,
onOpenMessageLink,
relayOrigin,
snapshotSharedBy,
goAgents,
],
@@ -1922,6 +1869,10 @@ function MarkdownInner({
}
const resolvedLinkPreviews = useResolvedLinkPreviews(linkPreviews);
const entityCardOpenHandlers = useEntityCardOpenHandlers(
resolvedLinkPreviews,
onOpenEntityLink,
);
// When a config-nudge suppresses the prose (selectProseOrNudge returns
// null), skip the parse entirely — it would be thrown away unrendered.
@@ -1977,7 +1928,11 @@ function MarkdownInner({
data-link-preview-list=""
>
{resolvedLinkPreviews.map((preview) => (
<LinkPreviewAttachment key={preview.href} preview={preview} />
<LinkPreviewAttachment
key={preview.href}
onOpen={entityCardOpenHandlers.get(preview.href)}
preview={preview}
/>
))}
</AttachmentGroup>
) : null}

View File

@@ -0,0 +1,92 @@
import * as React from "react";
import { openUrl } from "@tauri-apps/plugin-opener";
import { toast } from "sonner";
import { cn } from "@/shared/lib/cn";
import { copyTextToClipboard } from "@/shared/lib/clipboard";
import { MaskedLinkTooltip } from "./MaskedLinkTooltip";
import {
MediaContextMenu,
type MediaContextMenuPosition,
useDismissMediaContextMenu,
} from "./MediaContextMenu";
/**
* An external `[text](href)` link with a custom right-click menu.
*
* Buzz renders inside a native webview whose default context menu has no
* useful link actions, so a plain right-click on a link is a no-op. This adds
* an in-app menu with "Open link" (via the OS opener, matching the anchor's
* left-click `target="_blank"` behavior) and "Copy link" (the real href, not
* the masked display text).
*/
export function ExternalLinkAnchor({
anchorProps,
children,
href,
isLinearLink,
label,
}: {
anchorProps: React.ComponentPropsWithoutRef<"a">;
children: React.ReactNode;
href: string | undefined;
isLinearLink: boolean;
label: string;
}) {
const [menu, setMenu] = React.useState<MediaContextMenuPosition | null>(null);
const closeMenu = React.useCallback(() => setMenu(null), []);
useDismissMediaContextMenu(Boolean(menu), closeMenu);
const anchor = (
<a
{...anchorProps}
className={cn(
"font-medium underline underline-offset-4 transition-colors",
isLinearLink ? "linear-link" : "text-primary hover:text-primary/80",
)}
href={href}
onContextMenuCapture={(event) => {
if (!href) return;
event.preventDefault();
setMenu({ x: event.clientX, y: event.clientY });
}}
rel="noreferrer"
target="_blank"
>
{children}
</a>
);
return (
<>
<MaskedLinkTooltip disabled={isLinearLink} href={href} label={label}>
{anchor}
</MaskedLinkTooltip>
{menu && href ? (
<MediaContextMenu
dataAttributes={["data-link-context-menu"]}
items={[
{
label: "Open link",
onSelect: () => {
closeMenu();
void openUrl(href).catch(() => {
toast.error("Failed to open link");
});
},
},
{
label: "Copy link",
onSelect: () => {
closeMenu();
copyTextToClipboard(href, "Link copied to clipboard");
},
},
]}
position={menu}
/>
) : null}
</>
);
}

View File

@@ -0,0 +1,112 @@
import * as React from "react";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import {
entityLinkProjectRouteId,
isEntityLink,
parseEntityLink,
type ParsedEntityLink,
} from "@/shared/lib/entityLink";
import {
parseSupportedLinkPreview,
type SupportedLinkPreview,
} from "@/shared/lib/linkPreview";
/**
* Navigate to the project detail view for a `buzz://pr|issue|repo` link.
* The link's (owner, d) coordinate is exactly the `/projects/$projectId`
* route id, so no read-model resolution is needed.
*/
export function useOpenEntityLink(): (link: ParsedEntityLink) => void {
const { goProject } = useAppNavigation();
return React.useCallback(
(link: ParsedEntityLink) => {
void goProject(entityLinkProjectRouteId(link), {
...(link.type === "pr" ? { pullRequestId: link.id } : {}),
...(link.type === "issue" ? { issueId: link.id } : {}),
});
},
[goProject],
);
}
/**
* In-app open handlers for `buzz://` entity preview cards, keyed by href.
* External cards get no handler and keep their OS-opened anchor.
*/
export function useEntityCardOpenHandlers(
previews: SupportedLinkPreview[],
onOpenEntityLink: (link: ParsedEntityLink) => void,
): Map<string, () => void> {
return React.useMemo(() => {
const handlers = new Map<string, () => void>();
for (const preview of previews) {
if (!isEntityLink(preview.href)) continue;
const parsed = parseEntityLink(preview.href);
if (parsed.ok) {
handlers.set(preview.href, () => onOpenEntityLink(parsed.value));
}
}
return handlers;
}, [onOpenEntityLink, previews]);
}
/**
* Resolve an anchor href to a canonical `buzz://` entity link, accepting
* both the deep-link scheme directly and HTTPS relay clone URLs (which the
* preview parser normalizes onto `buzz://repo` only when the URL origin
* matches the active relay origin).
*/
function resolveEntityHref(
href: string,
relayOrigin: string | null,
): string | null {
if (isEntityLink(href)) return href;
if (!/^https?:\/\//i.test(href)) return null;
const preview = parseSupportedLinkPreview(href, relayOrigin);
return preview && isEntityLink(preview.href) ? preview.href : null;
}
/**
* Render an inline anchor for a Buzz entity link (`buzz://pr|issue|repo` or
* an HTTPS relay clone URL whose origin matches the active relay) that
* navigates in-app instead of handing the URL to the OS. Returns null when
* the href is not a valid entity link so the caller can fall through to its
* default anchor.
*/
export function renderEntityLinkAnchor({
anchorProps,
children,
href,
onOpenEntityLink,
relayOrigin,
}: {
anchorProps: React.ComponentPropsWithoutRef<"a">;
children: React.ReactNode;
href: string | undefined;
onOpenEntityLink: (link: ParsedEntityLink) => void;
relayOrigin: string | null;
}): React.ReactElement | null {
if (!href) return null;
const canonicalHref = resolveEntityHref(href, relayOrigin);
if (!canonicalHref) return null;
const parsed = parseEntityLink(canonicalHref);
if (!parsed.ok) return null;
return (
<a
{...anchorProps}
className="font-medium text-primary underline underline-offset-4 transition-colors hover:text-primary/80 cursor-pointer"
href={href}
onClick={(event) => {
event.preventDefault();
onOpenEntityLink(parsed.value);
}}
>
{children}
</a>
);
}

View File

@@ -13,7 +13,7 @@ import remarkCustomEmoji, {
import remarkMentions from "@/shared/lib/remarkMentions";
import remarkSpoilers from "@/shared/lib/remarkSpoilers";
import { messageLinkUrlTransform } from "./utils";
import { buzzDeepLinkUrlTransform } from "./utils";
/**
* Parsed-markdown element cache.
@@ -105,7 +105,7 @@ function buildMarkdownElement(input: MarkdownParseInputs): React.ReactElement {
// biome-ignore lint/suspicious/noExplicitAny: PluggableList type not directly importable
] as any[],
rehypePlugins,
urlTransform: messageLinkUrlTransform,
urlTransform: buzzDeepLinkUrlTransform,
});
}

View File

@@ -15,7 +15,9 @@ import type { MarkdownRuntime } from "./types";
const INERT_MARKDOWN_RUNTIME: MarkdownRuntime = {
channels: [],
onOpenChannel: () => {},
onOpenEntityLink: () => {},
onOpenMessageLink: () => {},
relayOrigin: null,
};
export const MarkdownRuntimeContext = React.createContext<MarkdownRuntime>(

View File

@@ -1,4 +1,5 @@
import type { ParsedMessageLink } from "@/features/messages/lib/messageLink";
import type { ParsedEntityLink } from "@/shared/lib/entityLink";
import type { Channel } from "@/shared/api/types";
import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji";
import type { VideoReviewContext } from "../VideoPlayer";
@@ -31,7 +32,15 @@ export type MarkdownRuntime = {
imetaByUrl?: ImetaLookup;
mentionPubkeysByName?: Record<string, string>;
onOpenChannel: (channelId: string) => void;
/** Navigate to a Buzz git entity (`buzz://pr|issue|repo` deep link). */
onOpenEntityLink: (link: ParsedEntityLink) => void;
onOpenMessageLink: (link: ParsedMessageLink) => void;
/**
* The resolved relay origin (e.g. `https://buzz.block.builderlab.xyz`),
* or `null` when not yet resolved. Used by the anchor component to
* validate that clone-URL rewrites point to the active relay only.
*/
relayOrigin: string | null;
/** Display name of the message author sharing an agent snapshot. */
snapshotSharedBy?: string;
/**

View File

@@ -2,6 +2,7 @@ import * as React from "react";
import { defaultUrlTransform } from "react-markdown";
import { isMessageLink } from "@/features/messages/lib/messageLink";
import { parseEntityLink } from "@/shared/lib/entityLink";
export function useStableArray<T>(arr: T[]): T[] {
const ref = React.useRef(arr);
@@ -166,16 +167,32 @@ export function isInsideHiddenSpoiler(element: Element): boolean {
}
/**
* `urlTransform` for `<ReactMarkdown>` that preserves `buzz://message?…`
* links. The default transform strips unknown schemes (returns `""`) before
* the `a` component override can see them, which would break copy → paste →
* click end-to-end. Everything else delegates to `defaultUrlTransform`.
* `urlTransform` for `<ReactMarkdown>` that preserves `buzz://` deep links
* used by Buzz — both `buzz://message?…` links and `buzz://pr|issue|repo?…`
* entity links. The default transform strips unknown schemes (returns `""`)
* before the `a` component override can see them, which would break copy →
* paste → click end-to-end.
*
* Policy:
* - `buzz://message` hrefs — preserved unconditionally (handled by the
* message-link pill renderer).
* - `buzz://pr|issue|repo` hrefs — preserved only when `parseEntityLink`
* succeeds, keeping the sanitizer active against arbitrary `buzz://` URIs.
* - Everything else delegates to `defaultUrlTransform`.
*/
export function buzzDeepLinkUrlTransform(value: string, key: string): string {
if (key !== "href") return defaultUrlTransform(value);
if (isMessageLink(value)) return value;
if (parseEntityLink(value).ok) return value;
return defaultUrlTransform(value);
}
/**
* @deprecated Preserved for external callers; use `buzzDeepLinkUrlTransform`
* which also handles `buzz://pr|issue|repo` entity links.
*/
export function messageLinkUrlTransform(value: string, key: string): string {
if (key === "href" && isMessageLink(value)) {
return value;
}
return defaultUrlTransform(value);
return buzzDeepLinkUrlTransform(value, key);
}
export function getReactNodeText(node: React.ReactNode): string {