mirror of
https://github.com/block/buzz.git
synced 2026-11-13 21:01:53 +00:00
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:
commit
1e865b5b0b
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -43,6 +43,7 @@ export type ProjectRepoDiff = {
|
||||
files: ProjectRepoDiffFile[];
|
||||
additions: number;
|
||||
deletions: number;
|
||||
commitBody: string | null;
|
||||
};
|
||||
|
||||
export type ProjectLocalRepoSnapshot = {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
28
desktop/src/shared/api/searchTypes.ts
Normal file
28
desktop/src/shared/api/searchTypes.ts
Normal 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;
|
||||
};
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 } = {},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
63
desktop/src/shared/ui/markdown/parseImeta.ts
Normal file
63
desktop/src/shared/ui/markdown/parseImeta.ts
Normal 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;
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user