merge: resolve conflict vs main (playwright smoke union)

Union of smoke test list entries from both branches:
- agent-usage.spec.ts (this branch)
- agent-usage-screenshots.spec.ts (this branch)
- harness-management.spec.ts (main)
- harness-catalog-screenshots.spec.ts (main)
- inline-custom-harness.spec.ts (main)

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-07-29 17:31:56 -04:00
743 changed files with 75639 additions and 14854 deletions

View File

@@ -7,6 +7,8 @@ export type DiscoverAgentModelsInput = {
agentArgs?: string[];
provider?: string;
envVars?: Record<string, string>;
/** Definition-level env from the harness definition (custom/preset). Merged below user envVars. */
definitionEnv?: Record<string, string>;
};
export async function discoverAgentModels(input: DiscoverAgentModelsInput) {

View File

@@ -3,6 +3,7 @@ import test from "node:test";
import {
inviteErrorMessage,
isInviteExhaustedError,
isInviteExpiredError,
relayHttpFromWs,
} from "./inviteHelpers.ts";
@@ -34,3 +35,9 @@ test("invite expiry sentinel is recognized without hiding other errors", () => {
"network unavailable",
);
});
test("invite exhaustion sentinel is recognized distinctly from expiry", () => {
assert.equal(isInviteExhaustedError(new Error("invite_exhausted")), true);
assert.equal(isInviteExhaustedError(new Error("invite_expired")), false);
assert.equal(isInviteExhaustedError(new Error("invite_invalid")), false);
});

View File

@@ -1,4 +1,5 @@
export const INVITE_EXPIRED_ERROR = "invite_expired";
export const INVITE_EXHAUSTED_ERROR = "invite_exhausted";
/**
* Parsed invite — either a full (relay + code) or bare-code form.
@@ -89,3 +90,7 @@ export function inviteErrorMessage(error: unknown): string {
export function isInviteExpiredError(error: unknown): boolean {
return inviteErrorMessage(error) === INVITE_EXPIRED_ERROR;
}
export function isInviteExhaustedError(error: unknown): boolean {
return inviteErrorMessage(error) === INVITE_EXHAUSTED_ERROR;
}

View File

@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import { getJoinPolicy } from "./invites.ts";
import { getJoinPolicy, mintInvite } from "./invites.ts";
function withFetch(response, run) {
const originalFetch = globalThis.fetch;
@@ -28,7 +28,7 @@ test("getJoinPolicy maps relay-hosted Markdown and age requirements", async () =
{ status: 200 },
),
async () => {
assert.deepEqual(await getJoinPolicy("wss://relay.example"), {
assert.deepEqual(await getJoinPolicy("wss://relay.example", "webview"), {
termsMarkdown: "# Terms",
privacyMarkdown: "# Privacy",
ageAttestationRequired: true,
@@ -40,15 +40,172 @@ test("getJoinPolicy maps relay-hosted Markdown and age requirements", async () =
test("getJoinPolicy preserves opt-in behavior for unconfigured and older relays", async () => {
await withFetch(new Response(JSON.stringify({}), { status: 200 }), async () =>
assert.equal(await getJoinPolicy("wss://relay.example"), null),
assert.equal(await getJoinPolicy("wss://relay.example", "webview"), null),
);
await withFetch(new Response(null, { status: 404 }), async () =>
assert.equal(await getJoinPolicy("wss://relay.example"), null),
assert.equal(await getJoinPolicy("wss://relay.example", "webview"), null),
);
});
test("getJoinPolicy fails closed on a policy endpoint error", async () => {
await withFetch(new Response(null, { status: 503 }), async () =>
assert.rejects(getJoinPolicy("wss://relay.example"), /HTTP 503/),
assert.rejects(getJoinPolicy("wss://relay.example", "webview"), /HTTP 503/),
);
});
test("getJoinPolicy maps the native command response", async () => {
const previousWindow = globalThis.window;
globalThis.window = {
__TAURI_INTERNALS__: {
invoke(command, args) {
assert.equal(command, "fetch_join_policy");
assert.deepEqual(args, { relayUrl: "wss://relay.example" });
return Promise.resolve({
terms_markdown: "# Terms",
privacy_markdown: "# Privacy",
age_attestation_required: true,
version: "policy-v1",
});
},
},
};
try {
assert.deepEqual(await getJoinPolicy("wss://relay.example", "native"), {
termsMarkdown: "# Terms",
privacyMarkdown: "# Privacy",
ageAttestationRequired: true,
version: "policy-v1",
});
} finally {
globalThis.window = previousWindow;
}
});
// --- mintInvite serialization ---
// The test-loader transpiles TS imports. tauri.ts imports `invoke` from
// @tauri-apps/api/core, which calls `window.__TAURI_INTERNALS__.invoke`.
// We stub that here so getRelayHttpUrl() and signRelayEvent() work in node.
function setupTauriStubs(
httpBase,
authEvent = {
id: "x",
sig: "y",
pubkey: "z",
kind: 27235,
created_at: 1,
tags: [],
},
) {
const calls = { invokeArgs: [] };
globalThis.window = globalThis.window ?? {};
globalThis.window.__TAURI_INTERNALS__ = {
invoke: async (command, args) => {
calls.invokeArgs.push({ command, args });
if (command === "get_relay_http_url") return httpBase;
if (command === "sign_event") return JSON.stringify(authEvent);
throw new Error(`Unexpected Tauri command: ${command}`);
},
};
return calls;
}
function teardownTauriStubs() {
delete globalThis.window.__TAURI_INTERNALS__;
}
test("mintInvite serializes bounded max_uses in the request body", async () => {
setupTauriStubs("https://relay.example");
try {
const originalFetch = globalThis.fetch;
let capturedBody;
globalThis.fetch = async (_url, init) => {
capturedBody = JSON.parse(init.body);
return new Response(
JSON.stringify({
code: "v2.abc123",
expires_at: 1785100000,
url: "https://relay.example/invite/v2.abc123",
max_uses: 10,
uses_remaining: 10,
}),
);
};
try {
const result = await mintInvite({ ttlSecs: 259200, maxUses: 10 });
assert.equal(capturedBody.ttl_secs, 259200);
assert.equal(capturedBody.max_uses, 10);
assert.equal(result.code, "v2.abc123");
assert.equal(result.maxUses, 10);
assert.equal(result.usesRemaining, 10);
assert.equal(result.expiresAt, 1785100000);
assert.equal(result.url, "https://relay.example/invite/v2.abc123");
} finally {
globalThis.fetch = originalFetch;
}
} finally {
teardownTauriStubs();
}
});
test("mintInvite omits max_uses when null (unlimited)", async () => {
setupTauriStubs("https://relay.example");
try {
const originalFetch = globalThis.fetch;
let capturedBody;
globalThis.fetch = async (_url, init) => {
capturedBody = JSON.parse(init.body);
return new Response(
JSON.stringify({
code: "v2.abc123",
expires_at: 1785100000,
url: "https://relay.example/invite/v2.abc123",
max_uses: null,
uses_remaining: null,
}),
);
};
try {
const result = await mintInvite({ ttlSecs: 259200, maxUses: null });
assert.equal(capturedBody.ttl_secs, 259200);
assert.equal(Object.hasOwn(capturedBody, "max_uses"), false);
assert.equal(result.maxUses, null);
assert.equal(result.usesRemaining, null);
} finally {
globalThis.fetch = originalFetch;
}
} finally {
teardownTauriStubs();
}
});
test("mintInvite omits max_uses when not provided (unlimited default)", async () => {
setupTauriStubs("https://relay.example");
try {
const originalFetch = globalThis.fetch;
let capturedBody;
globalThis.fetch = async (_url, init) => {
capturedBody = JSON.parse(init.body);
return new Response(
JSON.stringify({
code: "v2.abc123",
expires_at: 1785100000,
url: "https://relay.example/invite/v2.abc123",
max_uses: null,
uses_remaining: null,
}),
);
};
try {
await mintInvite({ ttlSecs: 86400 });
assert.equal(capturedBody.ttl_secs, 86400);
assert.equal(Object.hasOwn(capturedBody, "max_uses"), false);
} finally {
globalThis.fetch = originalFetch;
}
} finally {
teardownTauriStubs();
}
});

View File

@@ -1,5 +1,9 @@
import { relayHttpFromWs } from "@/shared/api/inviteHelpers";
import { getRelayHttpUrl, signRelayEvent } from "@/shared/api/tauri";
import {
getRelayHttpUrl,
invokeTauri,
signRelayEvent,
} from "@/shared/api/tauri";
// Relay invite data layer. Both endpoints are NIP-98-authed HTTP POSTs
// (mirrors the read path in moderation.ts, plus the payload tag the relay
@@ -21,6 +25,8 @@ export type MintedInvite = {
code: string;
expiresAt: number;
url: string;
maxUses: number | null;
usesRemaining: number | null;
};
export type JoinPolicy = {
@@ -124,26 +130,38 @@ export function isJoinPolicyDiscoveryCandidate(relayWsUrl: string): boolean {
/** Fetch relay-hosted policy content for any join surface. */
export async function getJoinPolicy(
relayWsUrl: string,
transport: "native" | "webview",
): Promise<JoinPolicy | null> {
const base = relayHttpFromWs(relayWsUrl);
const response = await fetch(`${base.replace(/\/+$/, "")}/api/join-policy`);
// Relays predating join-policy support have no configured policy.
if (response.status === 404) return null;
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const raw = (await response.json()) as {
policy?: {
terms_markdown?: string;
privacy_markdown?: string;
age_attestation_required: boolean;
version: string;
};
type RawJoinPolicy = {
terms_markdown?: string;
privacy_markdown?: string;
age_attestation_required: boolean;
version: string;
};
return raw.policy
let raw: RawJoinPolicy | null;
if (transport === "native") {
raw = await invokeTauri<RawJoinPolicy | null>("fetch_join_policy", {
relayUrl: relayWsUrl,
});
} else {
const base = relayHttpFromWs(relayWsUrl);
const response = await fetch(`${base.replace(/\/+$/, "")}/api/join-policy`);
// Relays predating join-policy support have no configured policy.
if (response.status === 404) return null;
if (!response.ok) throw new Error(`HTTP ${response.status}`);
raw =
(
(await response.json()) as {
policy?: RawJoinPolicy;
}
).policy ?? null;
}
return raw
? {
termsMarkdown: raw.policy.terms_markdown,
privacyMarkdown: raw.policy.privacy_markdown,
ageAttestationRequired: raw.policy.age_attestation_required,
version: raw.policy.version,
termsMarkdown: raw.terms_markdown,
privacyMarkdown: raw.privacy_markdown,
ageAttestationRequired: raw.age_attestation_required,
version: raw.version,
}
: null;
}
@@ -173,15 +191,29 @@ export async function acceptJoinPolicy(
}
/** Mint an invite code on the active community's relay (owner/admin only). */
export async function mintInvite(ttlSecs?: number): Promise<MintedInvite> {
export async function mintInvite(options?: {
ttlSecs?: number;
maxUses?: number | null;
}): Promise<MintedInvite> {
const base = await getRelayHttpUrl();
const body = JSON.stringify(ttlSecs ? { ttl_secs: ttlSecs } : {});
const payload: Record<string, unknown> = {};
if (options?.ttlSecs != null) payload.ttl_secs = options.ttlSecs;
if (options?.maxUses != null) payload.max_uses = options.maxUses;
const body = JSON.stringify(payload);
const raw = await invitePost<{
code: string;
expires_at: number;
url: string;
max_uses: number | null;
uses_remaining: number | null;
}>(base, "/api/invites", body);
return { code: raw.code, expiresAt: raw.expires_at, url: raw.url };
return {
code: raw.code,
expiresAt: raw.expires_at,
url: raw.url,
maxUses: raw.max_uses,
usesRemaining: raw.uses_remaining,
};
}
/**

View File

@@ -116,6 +116,7 @@ type RawProjectRepoDiff = {
files: RawProjectRepoDiffFile[];
additions: number;
deletions: number;
commit_body: string | null;
};
function fromRawProjectRepoSnapshot(
@@ -192,6 +193,7 @@ export async function getProjectRepoDiff(input: {
return {
additions: diff.additions,
deletions: diff.deletions,
commitBody: diff.commit_body,
files: diff.files.map((file) => ({
path: file.path,
additions: file.additions,
@@ -227,6 +229,7 @@ export async function getProjectLocalRepoDiff(input: {
return {
additions: diff.additions,
deletions: diff.deletions,
commitBody: diff.commit_body,
files: diff.files.map((file) => ({
path: file.path,
additions: file.additions,

View File

@@ -43,6 +43,7 @@ export type ProjectRepoDiff = {
files: ProjectRepoDiffFile[];
additions: number;
deletions: number;
commitBody: string | null;
};
export type ProjectLocalRepoSnapshot = {

View File

@@ -20,14 +20,12 @@ import {
type RelaySubscriptionFilter,
} from "@/shared/api/relayClientShared";
import {
AUX_BACKFILL_CHUNK_SIZE,
buildChannelAuxDeletionFilter,
buildChannelFilter,
buildChannelHistoryFilter,
buildChannelMentionFilter,
buildGlobalStreamFilter,
} from "@/shared/api/relayChannelFilters";
import { collectWithConcurrency } from "@/shared/api/concurrency";
import {
clearClosedRetry,
handleRelayClosed,
@@ -40,7 +38,11 @@ import {
parseRateLimitHint,
waitForRateLimit,
} from "@/shared/api/relayRateLimitGate";
import { requestHistoryGated } from "@/shared/api/relayGateBoundary";
import {
fetchChunkedHistory,
requestFirstEventGated,
requestHistoryGated,
} from "@/shared/api/relayGateBoundary";
import { RelayConnectionStateEmitter } from "@/shared/api/relayConnectionStateEmitter";
import {
isServiceRestartClose,
@@ -53,8 +55,7 @@ import { closeWebSocket } from "@/shared/api/relayWebSocketClose";
import { buildThreadReferenceTags } from "@/features/messages/lib/threading";
const RECONNECT_BASE_DELAY_MS = 1_000,
RECONNECT_MAX_DELAY_MS = 30_000,
EVENT_BATCH_MS = 16,
AUX_BACKFILL_CONCURRENCY = 4;
EVENT_BATCH_MS = 16;
/**
* Op-level timeout constants. Raised from 8 s to 25 s to survive degraded
@@ -176,7 +177,7 @@ export class RelayClient {
}
for (const [subId, sub] of this.subscriptions) {
if (sub.mode === "history") {
if (sub.mode !== "live") {
window.clearTimeout(sub.timeout);
sub.reject(error);
} else {
@@ -224,10 +225,10 @@ export class RelayClient {
eventIds: string[],
) => RelaySubscriptionFilter,
) {
return this.fetchChunkedAuxEvents(
channelId,
return fetchChunkedHistory(
referencedEventIds,
buildFilter,
(eventIds) => buildFilter(channelId, eventIds),
(filter) => this.fetchHistory(filter),
);
}
@@ -235,10 +236,10 @@ export class RelayClient {
channelId: string,
auxEventIds: string[],
): Promise<RelayEvent[]> {
return this.fetchChunkedAuxEvents(
channelId,
return fetchChunkedHistory(
auxEventIds,
buildChannelAuxDeletionFilter,
(eventIds) => buildChannelAuxDeletionFilter(channelId, eventIds),
(filter) => this.fetchHistory(filter),
);
}
@@ -246,32 +247,21 @@ export class RelayClient {
return this.fetchHistory(filter);
}
private async fetchChunkedAuxEvents(
channelId: string,
eventIds: string[],
buildFilter: (
channelId: string,
eventIds: string[],
) => RelaySubscriptionFilter,
): Promise<RelayEvent[]> {
if (eventIds.length === 0) {
return [];
}
/**
* Return the first event matching `filter` as soon as it arrives, without
* waiting for EOSE. Resolves to `null` when EOSE arrives before any event.
*/
async fetchFirstEvent(
filter: RelaySubscriptionFilter,
): Promise<RelayEvent | null> {
await this.ensureConnected();
const chunks: string[][] = [];
for (let i = 0; i < eventIds.length; i += AUX_BACKFILL_CHUNK_SIZE) {
chunks.push(eventIds.slice(i, i + AUX_BACKFILL_CHUNK_SIZE));
}
const batches = await collectWithConcurrency(
chunks,
AUX_BACKFILL_CONCURRENCY,
(ids) => this.requestHistory(buildFilter(channelId, ids)),
return requestFirstEventGated(
this.subscriptions,
(payload) => this.sendRaw(payload),
(subId) => this.closeSubscription(subId),
filter,
HISTORY_TIMEOUT_MS,
);
return batches.flat();
}
private async fetchHistory(filter: RelaySubscriptionFilter) {
@@ -877,6 +867,11 @@ export class RelayClient {
return;
}
if (subscription.mode === "first") {
subscription.onEvent(event);
return;
}
if (!prepareSubscriptionEvent(subscription, event)) return;
this.eventBuffer.push({ subId, event });
this.flushTimeout ??= window.setTimeout(
@@ -1068,7 +1063,7 @@ export class RelayClient {
}
for (const [subId, subscription] of this.subscriptions) {
if (subscription.mode === "history") {
if (subscription.mode !== "live") {
window.clearTimeout(subscription.timeout);
subscription.reject(error);
this.subscriptions.delete(subId);

View File

@@ -46,6 +46,14 @@ type HistorySubscription = {
timeout: number;
};
type FirstEventSubscription = {
mode: "first";
onEvent: (event: RelayEvent) => void;
resolve: (event: RelayEvent | null) => void;
reject: (error: Error) => void;
timeout: number;
};
type LiveSubscription = {
mode: "live";
filter: RelaySubscriptionFilter;
@@ -63,7 +71,10 @@ export type PendingEvent = {
timeout: number;
};
export type RelaySubscription = HistorySubscription | LiveSubscription;
export type RelaySubscription =
| HistorySubscription
| FirstEventSubscription
| LiveSubscription;
export function sortEvents(events: RelayEvent[]) {
return [...events].sort((left, right) => {

View File

@@ -1,8 +1,14 @@
import assert from "node:assert/strict";
import test from "node:test";
import { handleRelayClosed } from "./relayClosedRecovery.ts";
import { requestHistoryGated } from "./relayGateBoundary.ts";
import {
handleRelayClosed,
handleSubscriptionEose,
} from "./relayClosedRecovery.ts";
import {
requestFirstEventGated,
requestHistoryGated,
} from "./relayGateBoundary.ts";
// ── Fake-timer setup ──────────────────────────────────────────────────────────
// The rate-limit gate and closed-retry logic use window.setTimeout/clearTimeout.
@@ -227,6 +233,33 @@ test("gate armed by rate-limited history CLOSED defers the next REQ until expiry
assert.ok(sentAt[0] >= 5_001, "REQ must fire only after gate expiry");
});
test("first-event request resolves null when EOSE arrives without an event", async () => {
resetAll(0);
const subscriptions = new Map();
let requestedSubId = "";
const firstEventPromise = requestFirstEventGated(
subscriptions,
async (payload) => {
requestedSubId = payload[1];
},
async () => {},
{ kinds: [13_534], limit: 1 },
25_000,
);
await Promise.resolve();
assert.match(requestedSubId, /^first-/);
handleSubscriptionEose({
subscriptions,
subId: requestedSubId,
closeSubscription: async () => {},
});
assert.equal(await firstEventPromise, null);
assert.equal(subscriptions.has(requestedSubId), false);
});
test("production CLOSED handler removes terminal live subscriptions", () => {
let readyCalls = 0;
const subscriptions = new Map([

View File

@@ -35,7 +35,7 @@ export function handleRelayClosed({
}) {
const subscription = subscriptions.get(subId);
if (!subscription) return;
if (subscription.mode === "history") {
if (subscription.mode !== "live") {
// Classify before rejecting so a `rate-limited:` history CLOSED arms the
// gate for concurrent ops. A history sub can't be retried (the caller holds
// the promise), so we still reject immediately after arming.
@@ -133,6 +133,9 @@ export function prepareSubscriptionEvent(
subscription.events.push(event);
return false;
}
if (subscription.mode === "first") {
return false;
}
subscription.closedRetryAttempt = 0;
clearClosedRetry(subscription);
subscription.lastSeenCreatedAt = Math.max(
@@ -163,5 +166,9 @@ export function handleSubscriptionEose({
window.clearTimeout(subscription.timeout);
subscriptions.delete(subId);
void closeSubscription(subId);
subscription.resolve(sortEvents(subscription.events));
if (subscription.mode === "first") {
subscription.resolve(null);
} else {
subscription.resolve(sortEvents(subscription.events));
}
}

View File

@@ -5,6 +5,8 @@
* the gate-await + op-timeout pattern lives in one place and the op timeout
* budget starts only after the rate-limit window has cleared.
*/
import { collectWithConcurrency } from "@/shared/api/concurrency";
import { AUX_BACKFILL_CHUNK_SIZE } from "@/shared/api/relayChannelFilters";
import { waitForRateLimit } from "@/shared/api/relayRateLimitGate";
import type {
RelaySubscription,
@@ -12,6 +14,8 @@ import type {
} from "@/shared/api/relayClientShared";
import type { RelayEvent } from "@/shared/api/types";
const AUX_BACKFILL_CONCURRENCY = 4;
/**
* Issue a history REQ on `filter`, waiting for any active rate-limit gate
* before starting the subscription so the op timeout begins only after
@@ -54,3 +58,75 @@ export async function requestHistoryGated(
});
});
}
/**
* Issue a REQ that resolves as soon as its first matching event arrives.
*
* This keeps single-event lookups from waiting for EOSE while still resolving
* `null` when the relay completes an empty result set.
*/
export async function requestFirstEventGated(
subscriptions: Map<string, RelaySubscription>,
sendRaw: (payload: unknown[]) => Promise<void>,
closeSubscription: (subId: string) => Promise<void>,
filter: RelaySubscriptionFilter,
historyTimeoutMs: number,
): Promise<RelayEvent | null> {
await waitForRateLimit();
return new Promise<RelayEvent | null>((resolve, reject) => {
const subId = `first-${crypto.randomUUID()}`;
const timeout = window.setTimeout(() => {
subscriptions.delete(subId);
void closeSubscription(subId);
reject(new Error("Timed out while loading relay event."));
}, historyTimeoutMs);
subscriptions.set(subId, {
mode: "first",
onEvent: (event) => {
window.clearTimeout(timeout);
subscriptions.delete(subId);
void closeSubscription(subId);
resolve(event);
},
resolve,
reject,
timeout,
});
void sendRaw(["REQ", subId, filter]).catch((error) => {
window.clearTimeout(timeout);
subscriptions.delete(subId);
reject(
error instanceof Error
? error
: new Error("Failed to request relay event."),
);
});
});
}
/**
* Fetch relay history for event IDs in bounded, concurrent chunks and flatten
* the responses into one list. Returns an empty list without issuing a request
* when `eventIds` is empty.
*/
export async function fetchChunkedHistory(
eventIds: string[],
buildFilter: (eventIds: string[]) => RelaySubscriptionFilter,
fetchHistory: (filter: RelaySubscriptionFilter) => Promise<RelayEvent[]>,
): Promise<RelayEvent[]> {
if (eventIds.length === 0) return [];
const chunks: string[][] = [];
for (let i = 0; i < eventIds.length; i += AUX_BACKFILL_CHUNK_SIZE) {
chunks.push(eventIds.slice(i, i + AUX_BACKFILL_CHUNK_SIZE));
}
const batches = await collectWithConcurrency(
chunks,
AUX_BACKFILL_CONCURRENCY,
(ids) => fetchHistory(buildFilter(ids)),
);
return batches.flat();
}

View File

@@ -104,12 +104,10 @@ export function relayMembershipLookupFromEvent(
}
async function fetchMembershipListEvent(): Promise<RelayEvent | null> {
const events = await relayClient.fetchEvents({
return relayClient.fetchFirstEvent({
kinds: [KIND_NIP43_MEMBERSHIP_LIST],
limit: 1,
});
return events[events.length - 1] ?? null;
}
/** Loads the NIP-43 snapshot only when the relay advertises membership support. */

View File

@@ -0,0 +1,28 @@
export type SearchMessagesInput = {
q: string;
limit?: number;
channelId?: string;
/** Hex pubkeys for `from:` operator. */
authors?: string[];
/** Unix seconds (`after:YYYY-MM-DD`). */
since?: number;
/** Unix seconds (`before:YYYY-MM-DD`). */
until?: number;
};
export type SearchHit = {
eventId: string;
content: string;
kind: number;
pubkey: string;
channelId: string | null;
channelName: string | null;
createdAt: number;
score: number;
threadRootId?: string | null;
};
export type SearchMessagesResponse = {
hits: SearchHit[];
found: number;
};

View File

@@ -112,6 +112,105 @@ test("relay rate-limited: prefix check is case-sensitive (Rust always emits lowe
);
});
// ── fromRawAcpRuntimeCatalogEntry: custom row API-boundary (B-2) ─────────────
//
// These tests feed real raw custom catalog rows through fromRawAcpRuntimeCatalogEntry
// and verify the Rust→TypeScript mapping boundary: definition_env (snake_case)
// arrives as definitionEnv (camelCase), source "custom" is preserved, and the
// env round-trips end-to-end so a save-then-edit cycle cannot erase env.
const { fromRawAcpRuntimeCatalogEntry } = await import("./tauri.ts");
test("fromRawAcpRuntimeCatalogEntry maps definition_env to definitionEnv", () => {
const raw = {
id: "my-harness",
label: "My Harness",
availability: "available",
command: "my-bin",
source: "custom",
definition_env: { ANTHROPIC_API_KEY: "sk-test", MODEL: "claude-3" },
default_args: [],
can_auto_install: false,
requires_external_cli: false,
install_hint: "",
install_instructions_url: "",
};
const entry = fromRawAcpRuntimeCatalogEntry(raw);
assert.deepStrictEqual(entry.definitionEnv, {
ANTHROPIC_API_KEY: "sk-test",
MODEL: "claude-3",
});
assert.equal(entry.source, "custom");
});
test("fromRawAcpRuntimeCatalogEntry defaults definitionEnv to {} when absent", () => {
// Rust serialization skips empty BTreeMap, so definition_env will be absent
// for harnesses with no env defined — the mapper must default to {}.
const raw = {
id: "no-env-harness",
label: "No Env",
availability: "available",
command: "no-env-bin",
source: "custom",
default_args: [],
can_auto_install: false,
requires_external_cli: false,
install_hint: "",
install_instructions_url: "",
};
const entry = fromRawAcpRuntimeCatalogEntry(raw);
assert.deepStrictEqual(
entry.definitionEnv,
{},
"absent definition_env must map to empty object, not undefined",
);
});
test("fromRawAcpRuntimeCatalogEntry preserves source preset", () => {
const raw = {
id: "cursor",
label: "Cursor",
availability: "available",
command: "cursor",
source: "preset",
default_args: [],
can_auto_install: false,
requires_external_cli: false,
install_hint: "",
install_instructions_url: "",
};
const entry = fromRawAcpRuntimeCatalogEntry(raw);
assert.equal(entry.source, "preset");
assert.deepStrictEqual(entry.definitionEnv, {});
});
test("fromRawAcpRuntimeCatalogEntry env round-trips through edit payload shape", () => {
// Simulate the full save → re-open cycle: raw entry comes back from Rust
// with definition_env populated; the edit form reads entry.definitionEnv.
// Verify the env values are identical before and after the mapper.
const envValues = { OPENAI_API_KEY: "sk-live-abc", REGION: "us-east-1" };
const raw = {
id: "openai-harness",
label: "OpenAI",
availability: "not_installed",
command: "openai-agent",
source: "custom",
definition_env: envValues,
default_args: ["--acp"],
can_auto_install: false,
requires_external_cli: true,
install_hint: "Install the OpenAI CLI",
install_instructions_url: "https://platform.openai.com/docs",
};
const entry = fromRawAcpRuntimeCatalogEntry(raw);
// The edit form reads entry.definitionEnv; it must equal the original env.
assert.deepStrictEqual(
entry.definitionEnv,
envValues,
"env must round-trip: edit form must see the same values that Rust serialized",
);
});
// ── Teardown ──────────────────────────────────────────────────────────────────
test("teardown — restore Date.now", () => {

View File

@@ -118,6 +118,8 @@ export type RawManagedAgent = {
pubkey: string;
name: string;
persona_id: string | null;
// Optional: pre-feature fixtures may omit it. The record's harness/runtime id.
runtime?: string | null;
team_id?: string | null;
relay_url: string;
acp_command: string;
@@ -132,6 +134,7 @@ export type RawManagedAgent = {
system_prompt: string | null;
avatar_url?: string | null;
model: string | null;
model_source?: ManagedAgent["modelSource"];
provider: string | null;
persona_out_of_date: boolean;
persona_orphaned: boolean;
@@ -191,6 +194,12 @@ export type RawAcpRuntimeCatalogEntry = {
/** Tagged union with snake_case status values — same shape as `AuthStatus`. */
auth_status: AuthStatus;
login_hint?: string;
source: "builtin" | "preset" | "custom";
/**
* Definition-level env vars for `source: custom` entries.
* Omitted/absent for builtin and preset — skipped in Rust serialization when empty.
*/
definition_env?: Record<string, string>;
};
export type RawInstallStepResult = {
@@ -460,6 +469,9 @@ export async function searchMessages(
q: input.q,
limit: input.limit,
channelId: input.channelId,
authors: input.authors,
since: input.since,
until: input.until,
});
return {
@@ -687,6 +699,7 @@ export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent {
pubkey: agent.pubkey,
name: agent.name,
personaId: agent.persona_id,
runtime: agent.runtime ?? null,
teamId: agent.team_id ?? null,
relayUrl: agent.relay_url,
acpCommand: agent.acp_command,
@@ -701,6 +714,8 @@ export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent {
systemPrompt: agent.system_prompt,
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,
@@ -727,7 +742,7 @@ export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent {
};
}
function fromRawAcpRuntimeCatalogEntry(
export function fromRawAcpRuntimeCatalogEntry(
entry: RawAcpRuntimeCatalogEntry,
): AcpRuntimeCatalogEntry {
return {
@@ -750,6 +765,10 @@ function fromRawAcpRuntimeCatalogEntry(
nodeRequired: entry.node_required,
authStatus: entry.auth_status,
loginHint: entry.login_hint ?? null,
source: entry.source,
// Map definition_env (snake_case from Rust) to definitionEnv (camelCase).
// Absent when empty (Rust serialization skips empty BTreeMap) — default to {}.
definitionEnv: entry.definition_env ?? {},
};
}
@@ -931,6 +950,45 @@ export async function discoverAcpRuntimes(): Promise<AcpRuntimeCatalogEntry[]> {
).map(fromRawAcpRuntimeCatalogEntry);
}
/** Input shape for creating or updating a custom harness. */
export type HarnessDefinitionInput = {
id: string;
label: string;
command: string;
args?: string[];
env?: Record<string, string>;
installInstructionsUrl?: string;
installHint?: string;
};
/** Save (create or overwrite) a custom harness definition. Returns the catalog entry. */
export async function saveCustomHarness(
definition: HarnessDefinitionInput,
originalId?: string,
): Promise<AcpRuntimeCatalogEntry> {
const raw = await invokeTauri<RawAcpRuntimeCatalogEntry>(
"save_custom_harness",
{
definition: {
id: definition.id,
label: definition.label,
command: definition.command,
args: definition.args ?? [],
env: definition.env ?? {},
installInstructionsUrl: definition.installInstructionsUrl ?? "",
installHint: definition.installHint ?? "",
},
originalId: originalId ?? null,
},
);
return fromRawAcpRuntimeCatalogEntry(raw);
}
/** Delete a custom harness definition by id. No-op if already gone. */
export async function deleteCustomHarness(id: string): Promise<void> {
await invokeTauri<void>("delete_custom_harness", { id });
}
export async function installAcpRuntime(
runtimeId: string,
): Promise<InstallRuntimeResult> {

View File

@@ -12,6 +12,9 @@ import test from "node:test";
function makePreview(overrides = {}) {
return {
displayName: "Test Agent",
isBuiltIn: false,
model: null,
runtime: null,
systemPrompt: "You are helpful.",
avatarUrl: null,
memoryLevel: "none",

View File

@@ -17,7 +17,14 @@ export type RawPersona = {
name_pool?: string[];
is_builtin: boolean;
is_active?: boolean;
shared?: boolean;
source_team?: string | null;
/**
* Provenance of a local copy of another owner's catalog entry. Serialized by
* the backend `CatalogSource` in snake_case; the create payload sends the
* camelCase aliases it accepts.
*/
catalog_source?: { owner_pubkey: string; persona_id: string } | null;
env_vars?: Record<string, string>;
respond_to?: string | null;
respond_to_allowlist?: string[];
@@ -40,7 +47,14 @@ export function fromRawPersona(persona: RawPersona): AgentPersona {
namePool: persona.name_pool ?? [],
isBuiltIn: persona.is_builtin,
isActive: persona.is_active ?? true,
shared: persona.shared ?? false,
sourceTeam: persona.source_team ?? null,
catalogSource: persona.catalog_source
? {
ownerPubkey: persona.catalog_source.owner_pubkey,
personaId: persona.catalog_source.persona_id,
}
: null,
envVars: persona.env_vars ?? {},
respondTo: (persona.respond_to as RespondToMode | undefined) ?? null,
respondToAllowlist: persona.respond_to_allowlist ?? [],
@@ -69,31 +83,37 @@ export async function createPersona(
namePool: input.namePool ?? [],
envVars: input.envVars ?? {},
behavior: input.behavior,
catalogSource: input.catalogSource,
},
}),
);
}
/** The `UpdatePersonaRequest` payload shared by both edit commands. */
function updatePersonaPayload(input: UpdatePersonaInput) {
return {
id: input.id,
displayName: input.displayName,
avatarUrl: input.avatarUrl,
systemPrompt: input.systemPrompt,
runtime: input.runtime,
model: input.model,
provider: input.provider,
namePool: input.namePool ?? [],
// Send envVars only when caller explicitly provided it; omitting
// tells the backend "don't touch the stored env vars" so editing
// unrelated fields can't silently wipe saved credentials.
envVars: input.envVars,
// Same absent-vs-present contract as envVars for the behavioral quad.
behavior: input.behavior,
};
}
export async function updatePersona(
input: UpdatePersonaInput,
): Promise<AgentPersona> {
const raw = await invokeTauri<RawPersona>("update_persona", {
input: {
id: input.id,
displayName: input.displayName,
avatarUrl: input.avatarUrl,
systemPrompt: input.systemPrompt,
runtime: input.runtime,
model: input.model,
provider: input.provider,
namePool: input.namePool ?? [],
// Send envVars only when caller explicitly provided it; omitting
// tells the backend "don't touch the stored env vars" so editing
// unrelated fields can't silently wipe saved credentials.
envVars: input.envVars,
// Same absent-vs-present contract as envVars for the behavioral quad.
behavior: input.behavior,
},
input: updatePersonaPayload(input),
});
if (raw.writeback_warning) {
console.warn(
@@ -103,6 +123,41 @@ export async function updatePersona(
return fromRawPersona(raw);
}
/**
* Save an edit AND publish the persona's catalog head, reporting whether the
* relay accepted it.
*
* `updatePersona` only enqueues the head best-effort, so it cannot tell the UI
* whether the community catalog actually received the change. Use this for the
* "Save and publish" affordance, which promises exactly that.
*/
export async function updatePersonaAndPublish(
input: UpdatePersonaInput,
): Promise<PersonaSharePublicationResult> {
return fromRawPublicationResult(
await invokeTauri<RawPersonaSharePublicationResult>(
"update_persona_and_publish",
{ input: updatePersonaPayload(input) },
),
);
}
type RawPersonaSharePublicationResult = {
persona: RawPersona;
publicationStatus: "published" | "queued";
relayMessage?: string;
};
function fromRawPublicationResult(
raw: RawPersonaSharePublicationResult,
): PersonaSharePublicationResult {
return {
persona: fromRawPersona(raw.persona),
publicationStatus: raw.publicationStatus,
relayMessage: raw.relayMessage ?? null,
};
}
export async function deletePersona(id: string): Promise<void> {
await invokeTauri("delete_persona", { id });
}
@@ -116,6 +171,24 @@ export async function setPersonaActive(
);
}
export async function setPersonaShared(
id: string,
shared: boolean,
): Promise<PersonaSharePublicationResult> {
return fromRawPublicationResult(
await invokeTauri<RawPersonaSharePublicationResult>("set_persona_shared", {
id,
shared,
}),
);
}
export type PersonaSharePublicationResult = {
persona: AgentPersona;
publicationStatus: "published" | "queued";
relayMessage: string | null;
};
export type SnapshotMemoryLevel = "none" | "core" | "everything";
export type SnapshotFormat = "json" | "png";
@@ -172,6 +245,10 @@ export async function encodeAgentSnapshotForSend(
/** Preview returned by `preview_agent_snapshot_import` before any write. */
export type AgentSnapshotImportPreview = {
displayName: string;
/** Source classification shown in the preview; imports remain custom. */
isBuiltIn: boolean;
model: string | null;
runtime: string | null;
systemPrompt: string | null;
/** Effective avatar: data URL if present, source URL fallback otherwise. */
avatarUrl: string | null;
@@ -235,9 +312,15 @@ export async function confirmAgentSnapshotImport(
// Patches a single inbound persona/team/agent projection event into the local
// store (personas.json). The backend resolves the match key and the
// pending-edit race; the frontend only forwards the raw Nostr event JSON.
// pending-edit race; the frontend forwards the raw Nostr event JSON plus the
// relay it arrived on, so a workspace switch mid-flight cannot retain the event
// into the newly active community's scoped store.
export async function reconcileInboundPersonaEvent(
eventJson: string,
arrivalRelayUrl: string,
): Promise<void> {
await invokeTauri("reconcile_inbound_persona_event", { eventJson });
await invokeTauri("reconcile_inbound_persona_event", {
eventJson,
arrivalRelayUrl,
});
}

View File

@@ -268,28 +268,11 @@ export type GetHomeFeedInput = {
types?: string;
};
export type SearchMessagesInput = {
q: string;
limit?: number;
channelId?: string;
};
export type SearchHit = {
eventId: string;
content: string;
kind: number;
pubkey: string;
channelId: string | null;
channelName: string | null;
createdAt: number;
score: number;
threadRootId?: string | null;
};
export type SearchMessagesResponse = {
hits: SearchHit[];
found: number;
};
export type {
SearchHit,
SearchMessagesInput,
SearchMessagesResponse,
} from "./searchTypes";
// ── Relay Members ────────────────────────────────────────────────────────────
@@ -343,6 +326,12 @@ export type ManagedAgent = {
pubkey: string;
name: string;
personaId: string | null;
/**
* The record's harness/runtime id (e.g. "goose", "my-custom-harness").
* `null` means the agent inherits its harness from the linked persona.
* Used to count agents referencing a harness definition (delete confirm).
*/
runtime: string | null;
teamId?: string | null;
relayUrl: string;
acpCommand: string;
@@ -363,6 +352,7 @@ export type ManagedAgent = {
systemPrompt: string | null;
avatarUrl: string | null;
model: string | null;
modelSource: "definition" | "global" | "instance_legacy" | null;
/** LLM inference provider, from the agent's pinned record snapshot. */
provider: string | null;
/**
@@ -557,6 +547,21 @@ export type AcpRuntimeCatalogEntry = {
authStatus: AuthStatus;
/** Hint for completing authentication; null when not applicable or already logged in. */
loginHint: string | null;
/**
* Whether this entry is compiled into the app ("builtin"), a bundled preset
* ("preset" — PATH-probed, not editable/deletable), or loaded from a user
* JSON file in `custom_harnesses/` ("custom"). Controls editability in the
* UI — only "custom" entries can be edited or deleted.
*/
source: "builtin" | "preset" | "custom";
/**
* Definition-level environment variables for `source: custom` entries.
*
* Populated by the backend from `HarnessDefinition.env` so the edit form can
* read them back without losing existing env vars on save. Always absent/empty
* for `builtin` and `preset` entries.
*/
definitionEnv?: Record<string, string>;
};
/** An AcpRuntimeCatalogEntry that is confirmed available — command and binaryPath are non-null. */
@@ -748,10 +753,17 @@ export type AgentPersona = {
namePool: string[];
isBuiltIn: boolean;
isActive: boolean;
/** Whether this persona is discoverable in the active community catalog. */
shared: boolean;
/** Team ID if this persona was imported from a team directory. Team personas are non-editable. */
sourceTeam?: string | null;
/** Environment variables injected for agents created from this persona.
* Layered as: desktop parent env < persona envVars < agent envVars. */
/**
* Set only on a local copy of another owner's shared catalog entry. A copy
* carries a fresh local `id`, so this coordinate is the only thing that can
* answer "is this catalog entry already added" without minting a duplicate.
*/
catalogSource?: CatalogSourceCoordinate | null;
/** Agent environment variables, layered after desktop parent and persona values. */
envVars: Record<string, string>;
/** NIP-AP behavioral defaults (wire shape). Null/empty = unset. */
respondTo: RespondToMode | null;
@@ -762,9 +774,18 @@ export type AgentPersona = {
};
/**
* NIP-AP behavioral group for a definition, sent as one group: absent = don't
* touch the stored behavior group (legacy callers), present = replace the fields as a
* unit. Mirrors `PersonaBehaviorRequest`.
* A catalog publication's coordinate: the owner who published it and the
* `d`-tag identifying the persona within that owner's catalog. Mirrors the
* backend `CatalogSource`.
*/
export type CatalogSourceCoordinate = {
ownerPubkey: string;
personaId: string;
};
/**
* NIP-AP behavioral group for a definition: absent preserves the stored group
* for legacy callers; present replaces it as a unit. Mirrors `PersonaBehaviorRequest`.
*/
export type PersonaBehaviorInput = {
respondTo?: RespondToMode;
@@ -782,6 +803,11 @@ export type CreatePersonaInput = {
namePool?: string[];
envVars?: Record<string, string>;
behavior?: PersonaBehaviorInput;
/**
* Set when this persona is a copy of another owner's shared catalog entry,
* so the catalog can tell an already-added foreign entry from a new one.
*/
catalogSource?: CatalogSourceCoordinate;
};
export type UpdatePersonaInput = {

View File

@@ -50,7 +50,17 @@ export type ConfigNudgeRequirement =
/** One-line stderr excerpt from the CLI's parse error. */
diagnostic: string;
}
| { surface: "git_bash" };
| { surface: "git_bash" }
| {
/**
* A custom harness command that cannot be resolved in the current PATH.
* No in-app action can fix this — the user must install the binary or
* update their PATH.
*/
surface: "missing_binary";
/** The command name that was not found (e.g. "my-acp-agent"). */
command: string;
};
/**
* The structured payload embedded in the `buzz:config-nudge` sentinel block.
@@ -155,6 +165,8 @@ function isConfigNudgeRequirement(v: unknown): v is ConfigNudgeRequirement {
typeof r.setup_copy === "string" &&
typeof r.diagnostic === "string"
);
case "missing_binary":
return typeof r.command === "string";
default:
return false;
}

View File

@@ -5,6 +5,7 @@ import {
fuzzyStandardEmoji,
normalizeShortcode,
rankByShortcode,
rankShortcodeMatchesFirst,
scoreShortcodeMatch,
} from "./emojiSearch.ts";
@@ -77,6 +78,35 @@ test("rankByShortcode respects the limit", () => {
assert.equal(ranked.length, 2);
});
test("exact shortcode matches rank ahead of weaker custom shortcode matches", () => {
const items = [
{ code: "bufo_joy", source: "custom" },
{ code: "joy", source: "standard" },
{ code: "joy_cat", source: "custom" },
{ code: "face_with_tears_of_joy", source: "standard" },
];
const ranked = rankShortcodeMatchesFirst("joy", items, (item) => item.code);
assert.deepEqual(
ranked.map((item) => item.code),
["joy", "joy_cat", "bufo_joy", "face_with_tears_of_joy"],
);
});
test("semantic results stay ahead of loose shortcode matches", () => {
const items = [
{ code: "frowning_face", source: "semantic" },
{ code: "sandwich", source: "custom" },
{ code: "sad", source: "standard" },
];
const ranked = rankShortcodeMatchesFirst("sad", items, (item) => item.code);
assert.deepEqual(
ranked.map((item) => item.code),
["sad", "frowning_face", "sandwich"],
);
});
test("fuzzyStandardEmoji surfaces point_up for `pointup`", () => {
const hits = fuzzyStandardEmoji("pointup", 8, new Set());
const ids = hits.map((e) => e.id);

View File

@@ -114,6 +114,33 @@ export function rankByShortcode<T>(
return scored.slice(0, limit).map((s) => s.item);
}
/**
* Place exact and prefix shortcode matches ahead of items that only matched an
* emoji name or keyword. This lets an exact standard emoji like `joy` beat a
* weaker custom shortcode match such as `bufo_joy`, while retaining emoji-mart's
* order for name- and keyword-only results ahead of loose shortcode matches.
*/
export function rankShortcodeMatchesFirst<T>(
query: string,
items: readonly T[],
shortcodeOf: (item: T) => string,
): T[] {
const strongShortcodeMatches = rankByShortcode(
query,
items,
shortcodeOf,
Number.POSITIVE_INFINITY,
).filter((item) => {
const match = scoreShortcodeMatch(query, shortcodeOf(item));
return match !== null && match.tier <= TIER_PREFIX;
});
const matchedItems = new Set(strongShortcodeMatches);
return [
...strongShortcodeMatches,
...items.filter((item) => !matchedItems.has(item)),
];
}
export interface StandardEmoji {
id: string;
name: string;

View File

@@ -1,7 +1,10 @@
import assert from "node:assert/strict";
import test from "node:test";
import { setLocalStorageItemWithRecovery } from "./localStorageQuota.ts";
import {
recoverLocalStorageQuotaOnStartup,
setLocalStorageItemWithRecovery,
} from "./localStorageQuota.ts";
function makeQuotaLocalStorage({ maxEntries }) {
const store = new Map();
@@ -30,6 +33,144 @@ function install(ls) {
globalThis.localStorage = ls;
}
test("startup recovery removes disposable caches but preserves user state", () => {
const ls = makeQuotaLocalStorage({ maxEntries: 5 });
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-communities", "keep");
recoverLocalStorageQuotaOnStartup();
assert.equal(ls.getItem("buzz-channel-messages.v1:relay:chan"), null);
assert.equal(ls.getItem("buzz-channels.v1:relay"), null);
assert.equal(ls.getItem("buzz-timeline-skeleton-shape.v1:chan"), null);
assert.equal(
ls.getItem("buzz-sidebar-skeleton-shape.v1:community:user"),
null,
);
assert.equal(ls.getItem("buzz-communities"), "keep");
assert.equal(ls.getItem("buzz-local-storage-quota-recovery.v1"), "1");
});
test("healthy startup preserves disposable caches", () => {
const ls = makeQuotaLocalStorage({ maxEntries: 10 });
install(ls);
ls.store.set("buzz-channel-messages.v1:relay:new", "snapshot");
recoverLocalStorageQuotaOnStartup();
assert.equal(ls.getItem("buzz-channel-messages.v1:relay:new"), "snapshot");
assert.equal(ls.getItem("buzz-local-storage-quota-recovery.v1"), "1");
});
test("startup recovery does not remove namespace near misses", () => {
const ls = makeQuotaLocalStorage({ maxEntries: 2 });
install(ls);
ls.store.set("buzz-channels.v10:durable", "keep");
ls.store.set("buzz-channel-messages.v1-durable", "keep");
recoverLocalStorageQuotaOnStartup();
assert.equal(ls.getItem("buzz-channels.v10:durable"), "keep");
assert.equal(ls.getItem("buzz-channel-messages.v1-durable"), "keep");
assert.equal(ls.getItem("buzz-local-storage-quota-recovery.v1"), null);
});
test("startup recovery runs only once", () => {
const ls = makeQuotaLocalStorage({ maxEntries: 10 });
install(ls);
recoverLocalStorageQuotaOnStartup();
ls.store.set("buzz-channel-messages.v1:relay:new", "new snapshot");
recoverLocalStorageQuotaOnStartup();
assert.equal(
ls.getItem("buzz-channel-messages.v1:relay:new"),
"new snapshot",
);
});
test("startup recovery retries after marker write fails", () => {
const ls = makeQuotaLocalStorage({ maxEntries: 1 });
install(ls);
ls.store.set("buzz-communities", "keep");
recoverLocalStorageQuotaOnStartup();
assert.equal(ls.getItem("buzz-local-storage-quota-recovery.v1"), null);
ls.store.delete("buzz-communities");
ls.store.set("buzz-channel-messages.v1:relay:chan", "big");
recoverLocalStorageQuotaOnStartup();
assert.equal(ls.getItem("buzz-channel-messages.v1:relay:chan"), null);
assert.equal(ls.getItem("buzz-local-storage-quota-recovery.v1"), "1");
});
test("global cache byte budget evicts only oldest entries needed", () => {
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 oldestKey = "buzz-channel-messages.v1:relay:oldest";
const newerKey = "buzz-channels.v1:relay-newer";
const newestKey = "buzz-channel-messages.v1:relay:newest";
assert.equal(setLocalStorageItemWithRecovery(oldestKey, snapshot(1)), true);
assert.equal(setLocalStorageItemWithRecovery(newerKey, snapshot(2)), true);
assert.equal(setLocalStorageItemWithRecovery(newestKey, snapshot(3)), true);
assert.equal(ls.getItem(oldestKey), null);
assert.notEqual(ls.getItem(newerKey), null);
assert.notEqual(ls.getItem(newestKey), null);
assert.equal(ls.getItem("buzz-communities"), "keep");
});
test("global cache byte budget spans relays and preserves durable state", () => {
const ls = makeQuotaLocalStorage({ maxEntries: 20 });
install(ls);
ls.store.set("buzz-communities", "keep");
const largeSnapshot = "x".repeat(600_000);
assert.equal(
setLocalStorageItemWithRecovery(
"buzz-channel-messages.v1:relay-one:chan",
largeSnapshot,
),
true,
);
assert.equal(
setLocalStorageItemWithRecovery(
"buzz-channel-messages.v1:relay-two:chan",
largeSnapshot,
),
true,
);
assert.equal(ls.getItem("buzz-channel-messages.v1:relay-one:chan"), null);
assert.equal(
ls.getItem("buzz-channel-messages.v1:relay-two:chan"),
largeSnapshot,
);
assert.equal(ls.getItem("buzz-communities"), "keep");
});
test("rejects a single cache entry larger than the global byte budget", () => {
const ls = makeQuotaLocalStorage({ maxEntries: 10 });
install(ls);
const key = "buzz-channel-messages.v1:relay:oversized";
ls.store.set(key, "previous snapshot");
assert.equal(
setLocalStorageItemWithRecovery(key, "x".repeat(1_100_000)),
false,
);
assert.equal(ls.getItem(key), null);
});
test("writes normally when under quota", () => {
const ls = makeQuotaLocalStorage({ maxEntries: 10 });
install(ls);

View File

@@ -8,19 +8,32 @@
*/
const PURE_CACHE_KEY_PREFIXES = [
"buzz-channel-messages.v1",
"buzz-channels.v1",
"buzz-timeline-skeleton-shape.v1",
"buzz-channel-messages.v1:",
"buzz-channels.v1:",
"buzz-sidebar-skeleton-shape.v1:",
"buzz-timeline-skeleton-shape.v1:",
];
const QUOTA_RECOVERY_MARKER_KEY = "buzz-local-storage-quota-recovery.v1";
// Keep disposable snapshots below 2 MiB, leaving roughly 3 MiB of WebKit's
// observed ~5 MiB origin quota for identities, communities, preferences, and
// read state. localStorage strings are UTF-16, so count two bytes per code unit.
const PURE_CACHE_BYTE_BUDGET = 2 * 1024 * 1024;
function isPureCacheKey(key: string): boolean {
return PURE_CACHE_KEY_PREFIXES.some((prefix) => key.startsWith(prefix));
}
function storageEntryBytes(key: string, value: string): number {
return (key.length + value.length) * 2;
}
function evictPureCacheEntries(): number {
const toRemove: string[] = [];
for (let i = 0; i < window.localStorage.length; i++) {
const key = window.localStorage.key(i);
if (
key !== null &&
PURE_CACHE_KEY_PREFIXES.some((prefix) => key.startsWith(prefix))
) {
if (key !== null && isPureCacheKey(key)) {
toRemove.push(key);
}
}
@@ -30,6 +43,83 @@ function evictPureCacheEntries(): number {
return toRemove.length;
}
function pureCacheEntriesExcluding(excludedKey: string): Array<{
bytes: number;
key: string;
updatedAt: number;
}> {
const entries: Array<{ bytes: number; key: string; updatedAt: number }> = [];
for (let i = 0; i < window.localStorage.length; i++) {
const key = window.localStorage.key(i);
if (key === null || key === excludedKey || !isPureCacheKey(key)) continue;
const value = window.localStorage.getItem(key);
if (value === null) continue;
let updatedAt = 0;
try {
const parsed = JSON.parse(value) as { updatedAt?: unknown };
if (
typeof parsed.updatedAt === "number" &&
Number.isFinite(parsed.updatedAt)
) {
updatedAt = parsed.updatedAt;
}
} catch {
// Legacy or malformed cache entries are safe to evict first.
}
entries.push({ bytes: storageEntryBytes(key, value), key, updatedAt });
}
return entries;
}
function trimPureCacheForWrite(key: string, writeBytes: number): void {
const entries = pureCacheEntriesExcluding(key);
let totalBytes = entries.reduce((total, entry) => total + entry.bytes, 0);
if (totalBytes + writeBytes <= PURE_CACHE_BYTE_BUDGET) return;
entries.sort((a, b) => a.updatedAt - b.updatedAt);
for (const entry of entries) {
window.localStorage.removeItem(entry.key);
totalBytes -= entry.bytes;
if (totalBytes + writeBytes <= PURE_CACHE_BYTE_BUDGET) return;
}
}
function preparePureCacheWrite(key: string, value: string): boolean {
if (!isPureCacheKey(key)) return true;
const writeBytes = storageEntryBytes(key, value);
if (writeBytes > PURE_CACHE_BYTE_BUDGET) {
window.localStorage.removeItem(key);
return false;
}
trimPureCacheForWrite(key, writeBytes);
return true;
}
/**
* Probes storage once at startup so installs that already filled WebKit
* localStorage recover before app initialization. Healthy installs keep their
* caches; only a failed marker write triggers disposable-cache eviction. The
* marker is written after recovery, so an interrupted/unavailable backend
* retries on the next launch. Load-bearing state is never touched.
*/
export function recoverLocalStorageQuotaOnStartup(): void {
try {
if (window.localStorage.getItem(QUOTA_RECOVERY_MARKER_KEY) === "1") return;
try {
window.localStorage.setItem(QUOTA_RECOVERY_MARKER_KEY, "1");
return;
} catch {
evictPureCacheEntries();
}
window.localStorage.setItem(QUOTA_RECOVERY_MARKER_KEY, "1");
} catch (error) {
console.warn("[localStorageQuota] startup cache cleanup failed:", error);
}
}
let warnedPersistentFailure = false;
function notifyStorageFull(): void {
@@ -56,6 +146,7 @@ export function setLocalStorageItemWithRecovery(
value: string,
): boolean {
try {
if (!preparePureCacheWrite(key, value)) return false;
window.localStorage.setItem(key, value);
return true;
} catch (error) {

View File

@@ -137,6 +137,24 @@ test("remarkSpoilers: groups block nodes between delimiter paragraphs", () => {
assert.equal(tree.children[1].children[0].children[0].type, "image");
});
test("remarkSpoilers: leaves collapsed GFM table separators as text", () => {
const value =
"| Time | Purpose ||---:|---|| 0:00 | Frame strategy || 3:30 | Orient the board |";
const tree = runPlugin({
type: "root",
children: [{ type: "paragraph", children: [{ type: "text", value }] }],
});
assert.equal(
tree.children[0].children.some((child) => child.type === "spoiler"),
false,
);
assert.equal(
tree.children[0].children.map((child) => child.value ?? "").join(""),
value,
);
});
test("remarkSpoilers: leaves unmatched delimiters as text", () => {
const tree = runPlugin({
type: "root",

View File

@@ -37,17 +37,29 @@ function transformNode(node: Node) {
transformNode(child);
}
node.children = groupBlockSpoilers(groupSpoilers(node.children));
node.children = groupBlockSpoilers(
groupSpoilers(node.children, node.type === "paragraph"),
);
}
function groupSpoilers(children: Node[]): Node[] {
function groupSpoilers(
children: Node[],
rejectTableDelimiterRow: boolean,
): Node[] {
const output: Node[] = [];
let spoilerBuffer: Node[] | null = null;
for (const part of splitDelimiterParts(children)) {
if (part.type === "delimiter") {
if (spoilerBuffer) {
output.push(buildSpoilerNode(spoilerBuffer));
if (rejectTableDelimiterRow && isTableDelimiterRow(spoilerBuffer)) {
output.push({ type: "text", value: "||" }, ...spoilerBuffer, {
type: "text",
value: "||",
});
} else {
output.push(buildSpoilerNode(spoilerBuffer));
}
spoilerBuffer = null;
} else {
spoilerBuffer = [];
@@ -100,6 +112,13 @@ function splitDelimiterParts(children: Node[]): Part[] {
return parts;
}
function isTableDelimiterRow(children: Node[]): boolean {
if (children.some((child) => child.type !== "text")) return false;
const value = children.map((child) => String(child.value ?? "")).join("");
return /^\s*:?-{3,}:?(?:\s*\|\s*:?-{3,}:?)+\s*$/.test(value);
}
function buildSpoilerNode(
children: Node[],
options: { block?: boolean } = {},

View File

@@ -2,6 +2,69 @@
animation: sprout-arc-spinner-spin 500ms linear infinite;
}
@keyframes buzz-qr-cell-reveal {
0% {
opacity: 0;
transform: scale(0);
}
27% {
opacity: 0.27;
transform: scale(0);
}
30% {
opacity: 0.3;
transform: scale(0.043);
}
35% {
opacity: 0.35;
transform: scale(0.167);
}
40% {
opacity: 0.4;
transform: scale(0.358);
}
45% {
opacity: 0.45;
transform: scale(0.632);
}
49% {
opacity: 0.49;
transform: scale(0.918);
}
50% {
opacity: 0.5;
transform: scale(1.1);
}
100% {
opacity: 1;
transform: scale(1);
}
}
.buzz-qr-cell-reveal {
opacity: 0;
transform-box: fill-box;
transform-origin: center;
animation: buzz-qr-cell-reveal 58ms linear var(--buzz-qr-reveal-delay, 0ms)
forwards;
}
@media (prefers-reduced-motion: reduce) {
.buzz-qr-cell-reveal {
opacity: 1;
transform: none;
animation: none;
}
}
@keyframes sprout-arc-spinner-spin {
to {
transform: rotate(360deg);
@@ -124,54 +187,58 @@
}
.buzz-shimmer {
--buzz-shimmer-duration: 2000ms;
--buzz-shimmer-band: 400%;
--buzz-shimmer-duration: 2600ms;
--buzz-shimmer-band: 250%;
--buzz-shimmer-highlight: color-mix(
in srgb,
hsl(var(--foreground)) 82%,
hsl(var(--muted-foreground)) 18%
hsl(var(--background)) 60%,
hsl(var(--muted-foreground)) 40%
);
--buzz-shimmer-spread: 2rem;
color: hsl(var(--muted-foreground));
display: inline-block;
position: relative;
}
.buzz-shimmer::before {
animation: buzz-shimmer var(--buzz-shimmer-duration) linear infinite;
background-clip: text;
background-image: linear-gradient(
90deg,
transparent 0%,
transparent 40%,
var(--buzz-shimmer-highlight) 50%,
transparent 60%,
transparent 100%
);
background-image:
linear-gradient(
90deg,
transparent calc(50% - var(--buzz-shimmer-spread)),
var(--buzz-shimmer-highlight) 50%,
transparent calc(50% + var(--buzz-shimmer-spread))
),
linear-gradient(hsl(var(--muted-foreground)), hsl(var(--muted-foreground)));
background-position:
100% 0,
0 0;
background-repeat: no-repeat;
background-size: var(--buzz-shimmer-band) 100%;
background-size:
var(--buzz-shimmer-band) 100%,
100% 100%;
color: transparent;
content: attr(data-shimmer-text);
inset: 0;
pointer-events: none;
position: absolute;
display: inline-block;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
@keyframes buzz-shimmer {
0% {
background-position: 100% 0;
background-position:
100% 0,
0 0;
}
100% {
background-position: 0% 0;
background-position:
0 0,
0 0;
}
}
@media (prefers-reduced-motion: reduce) {
.buzz-shimmer::before {
.buzz-shimmer {
animation: none;
background: none;
color: hsl(var(--muted-foreground));
-webkit-text-fill-color: currentcolor;
}
}

View File

@@ -1,12 +1,67 @@
.composer-dock {
--composer-dock-active-rail: 2.125rem;
--composer-dock-activity-bottom: 0.5rem;
--composer-dock-quiet-inset: 1.25rem;
--composer-dock-release: calc(
var(--composer-dock-active-rail) -
var(--composer-dock-quiet-inset)
);
padding-bottom: var(--composer-dock-quiet-inset);
transition: padding-bottom 200ms cubic-bezier(0.23, 1, 0.32, 1);
}
.composer-dock--with-activity {
padding-bottom: var(--composer-dock-active-rail);
}
.composer-dock-quiet-spacer {
height: var(--composer-dock-release);
transition: height 200ms cubic-bezier(0.23, 1, 0.32, 1);
}
.composer-dock--with-activity .composer-dock-quiet-spacer {
height: 0;
}
.composer-dock-rail-mask {
height: var(--composer-dock-quiet-inset);
transition: height 200ms cubic-bezier(0.23, 1, 0.32, 1);
}
.composer-dock--with-activity .composer-dock-rail-mask {
height: var(--composer-dock-active-rail);
}
.composer-dock-activity {
bottom: var(--composer-dock-activity-bottom);
}
.composer-overlay-corner-masks::before,
.composer-overlay-corner-masks::after {
position: absolute;
bottom: 2.5rem;
bottom: var(--composer-dock-quiet-inset);
z-index: 0;
width: 1rem;
height: 1rem;
background: hsl(var(--background));
content: "";
transition: bottom 200ms cubic-bezier(0.23, 1, 0.32, 1);
}
.composer-dock--with-activity.composer-overlay-corner-masks::before,
.composer-dock--with-activity.composer-overlay-corner-masks::after {
bottom: var(--composer-dock-active-rail);
}
@media (prefers-reduced-motion: reduce) {
.composer-dock,
.composer-dock-quiet-spacer,
.composer-dock-rail-mask,
.composer-overlay-corner-masks::before,
.composer-overlay-corner-masks::after {
transition: none;
}
}
.composer-overlay-corner-masks::before {

View File

@@ -118,6 +118,10 @@
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
font-size: inherit;
font-weight: inherit;
letter-spacing: inherit;
line-height: inherit;
overflow: hidden;
}

View File

@@ -245,6 +245,28 @@
--buzz-active-foreground: 0 0% 100%;
}
/*
* Use the Buzz primary color for the floating selection-formatting tray.
* Other themes retain the standard popover treatment.
*/
:root[data-buzz-sidebar] [data-buzz-selection-formatting-tray] {
border-color: hsl(var(--primary-foreground) / 0.18);
background-color: hsl(var(--primary));
color: hsl(var(--primary-foreground));
}
:root[data-buzz-sidebar] [data-buzz-selection-formatting-tray] button {
color: hsl(var(--primary-foreground));
}
:root[data-buzz-sidebar] [data-buzz-selection-formatting-tray] button:hover,
:root[data-buzz-sidebar]
[data-buzz-selection-formatting-tray]
button[aria-pressed="true"] {
background-color: hsl(var(--primary-foreground) / 0.16);
color: hsl(var(--primary-foreground));
}
/* Keep the subtle content-card lift in Buzz Light only. */
:root[data-buzz-sidebar]:not(.dark) [data-buzz-content-surface] {
box-shadow:

View File

@@ -1,3 +1,5 @@
import type { CSSProperties } from "react";
import { cn } from "@/shared/lib/cn";
type ShimmerProps = {
@@ -9,7 +11,9 @@ export function Shimmer({ children, className }: ShimmerProps) {
return (
<span
className={cn("buzz-shimmer", className)}
data-shimmer-text={children}
style={
{ "--buzz-shimmer-spread": `${children.length * 2}px` } as CSSProperties
}
>
{children}
</span>

View File

@@ -20,6 +20,7 @@ type UserAvatarProps = {
size?: UserAvatarSize;
accent?: boolean;
className?: string;
fallbackDelayMs?: number;
testId?: string;
};
@@ -29,6 +30,7 @@ export function UserAvatar({
size = "md",
accent = false,
className,
fallbackDelayMs = 200,
testId,
}: UserAvatarProps) {
const initials = getInitials(displayName);
@@ -67,7 +69,7 @@ export function UserAvatar({
: "bg-secondary text-secondary-foreground",
)}
data-testid={testId ? `${testId}-fallback` : undefined}
delayMs={200}
delayMs={fallbackDelayMs}
>
{initials}
</AvatarFallback>

View File

@@ -37,6 +37,8 @@ function requirementKey(
return `cli_config_invalid:${req.probe_args.join(",")}:${index}`;
case "git_bash":
return `git_bash:${index}`;
case "missing_binary":
return `missing_binary:${req.command}:${index}`;
}
}
@@ -280,7 +282,7 @@ export function ConfigNudgeCard({
<AttachmentTrigger
aria-label={
opensDoctor
? `Open Agent runtimes settings for ${nudge.agent_name}`
? `Open Agent runtimes for ${nudge.agent_name}`
: `Open Edit Agent for ${nudge.agent_name}`
}
onClick={handleOpen}
@@ -381,6 +383,20 @@ function RequirementRow({
</span>
</div>
);
case "missing_binary": {
// Missing-binary rows are purely informational — the user must install the
// binary or update their PATH. No in-app action can fix this.
return (
<div className="flex items-center gap-2 text-xs leading-4 text-muted-foreground">
<span className="flex-1 [overflow-wrap:anywhere]">
<code className="rounded bg-muted px-1 py-0.5 font-mono text-xs text-foreground">
{requirement.command}
</code>{" "}
not found in PATH — install it or check your PATH settings
</span>
</div>
);
}
case "cli_config_invalid": {
// Config-invalid rows are purely informational — the user must edit an
// external file. No Agent runtimes CTA (Buzz can't repair ~/.codex/config.toml)

View File

@@ -0,0 +1,63 @@
import type { ImetaEntry } from "./types";
export type ParsedImetaEntry = ImetaEntry & {
url: string;
m: string;
x: string;
size: number;
blurhash?: string;
alt?: string;
};
export function parseImetaTags(
tags: string[][],
): Map<string, ParsedImetaEntry> {
const map = new Map<string, ParsedImetaEntry>();
for (const tag of tags) {
if (tag[0] !== "imeta") continue;
const entry: Partial<ParsedImetaEntry> = {};
for (const part of tag.slice(1)) {
const spaceIdx = part.indexOf(" ");
if (spaceIdx === -1) continue;
const key = part.slice(0, spaceIdx);
const val = part.slice(spaceIdx + 1);
switch (key) {
case "url":
entry.url = val;
break;
case "m":
entry.m = val;
break;
case "x":
entry.x = val;
break;
case "size":
entry.size = parseInt(val, 10);
break;
case "dim":
entry.dim = val;
break;
case "blurhash":
entry.blurhash = val;
break;
case "alt":
entry.alt = val;
break;
case "thumb":
entry.thumb = val;
break;
case "duration":
entry.duration = parseFloat(val);
break;
case "image":
entry.image = val;
break;
case "filename":
entry.filename = val;
break;
}
}
if (entry.url) map.set(entry.url, entry as ParsedImetaEntry);
}
return map;
}

View File

@@ -37,3 +37,18 @@ test("uses a deterministic lower-density matrix for the same payload", () => {
assert.equal(first, second);
});
test("adds the row-based reveal motion when requested", () => {
const html = renderToStaticMarkup(
React.createElement(StyledQrCode, {
animate: true,
value: TEST_PAIRING_URI,
}),
);
assert.match(html, /class="buzz-qr-cell-reveal"/);
assert.match(html, /data-qr-cell-row="0"/);
assert.match(html, /--buzz-qr-reveal-delay:0ms/);
assert.match(html, /data-qr-cell-row="56"/);
assert.match(html, /--buzz-qr-reveal-delay:189ms/);
});

View File

@@ -1,4 +1,10 @@
import { useId, useMemo, type ReactNode, type SVGProps } from "react";
import {
useId,
useMemo,
type CSSProperties,
type ReactNode,
type SVGProps,
} from "react";
import { create } from "qrcode";
const CELL_SPACING_RATIO = 0.2;
@@ -6,11 +12,16 @@ const FINDER_PATTERN_SIZE = 7;
const QUIET_ZONE_SIZE = 4;
const MAX_CENTER_OBSCURED_RATIO = 0.1;
const CENTER_ICON_SIZE_RATIO = 0.8;
const QR_REVEAL_DURATION_MS = 250;
const QR_REVEAL_WAVE_SPREAD = 0.3;
const QR_REVEAL_ROW_TRAVEL_MS =
QR_REVEAL_DURATION_MS / (1 + QR_REVEAL_WAVE_SPREAD);
type StyledQrCodeProps = Omit<
SVGProps<SVGSVGElement>,
"children" | "height" | "title" | "width"
> & {
animate?: boolean;
backgroundColor?: string;
centerImageSrc?: string;
foregroundColor?: string;
@@ -76,6 +87,7 @@ function FinderPattern({
}
export function StyledQrCode({
animate = false,
backgroundColor = "#ffffff",
centerImageSrc,
foregroundColor = "#000000",
@@ -118,11 +130,22 @@ export function StyledQrCode({
cells.push(
<circle
className={animate ? "buzz-qr-cell-reveal" : undefined}
cx={column + 0.5}
cy={row + 0.5}
data-qr-cell-row={row}
fill={foregroundColor}
key={`${row}-${column}`}
r={dataCellRadius}
style={
animate
? ({
"--buzz-qr-reveal-delay": `${Math.round(
(row / Math.max(matrix.size, 1)) * QR_REVEAL_ROW_TRAVEL_MS,
)}ms`,
} as CSSProperties)
: undefined
}
/>,
);
}