perf: eliminate rendering jank across desktop, relay, and ACP (#106)

This commit is contained in:
tlongwell-block
2026-03-18 20:00:04 -04:00
committed by GitHub
parent 3d963da30c
commit 5151366d29
12 changed files with 507 additions and 346 deletions

View File

@@ -604,12 +604,15 @@ pub async fn run_prompt_task(
return;
};
// 💬 — awaited inline so it completes before the prompt fires.
// This guarantees add-before-remove ordering for 💬: the guard's
// cleanup (spawned on drop) always runs after this returns.
// 👀 is fire-and-forget from main.rs (see race note in guard docs).
// 💬 — fire-and-forget so the prompt fires immediately.
// The guard's cleanup (spawned on drop) removes 💬 after the turn completes.
// A brief race where 💬 appears slightly after the agent starts is acceptable.
if !reaction_ids.is_empty() {
react_working(&ctx.rest_client, &reaction_ids).await;
let rest = ctx.rest_client.clone();
let ids = reaction_ids.clone();
tokio::spawn(async move {
react_working(&rest, &ids).await;
});
}
// ── Send the actual prompt ────────────────────────────────────────────
@@ -1035,8 +1038,8 @@ fn log_stop_reason(source: &PromptSource, stop_reason: &StopReason) {
///
/// ## Ordering
///
/// 💬 (`react_working`) is awaited inline before the prompt fires, so it is
/// guaranteed to precede cleanup. No race possible.
/// 💬 (`react_working`) is fire-and-forget (spawned before the prompt fires).
/// A brief race where 💬 appears slightly after the agent starts is acceptable.
///
/// 👀 (`react_seen`) is fire-and-forget from `main.rs` at queue-push time.
/// On rare fast-failure paths (e.g., `session_new` error on an idle agent),

View File

@@ -37,7 +37,7 @@ pub use event::EventQuery;
use chrono::{DateTime, Utc};
use sqlx::mysql::MySqlPoolOptions;
use sqlx::{MySqlPool, Row};
use sqlx::{MySqlPool, QueryBuilder, Row};
use std::time::Duration;
use uuid::Uuid;
@@ -78,31 +78,42 @@ pub async fn insert_mentions(
let channel_id_bytes = channel_id.map(|id| id.as_bytes().to_vec());
let kind = event.kind.as_u16() as u32;
for pubkey_hex in p_tags {
// Validate: must be exactly 64 hex characters (32-byte pubkey)
if pubkey_hex.len() != 64 || !pubkey_hex.chars().all(|c| c.is_ascii_hexdigit()) {
tracing::debug!(
event_id = %event.id,
invalid_ptag = pubkey_hex,
"skipping malformed p-tag in mentions insert"
);
continue;
}
// Normalize to lowercase — queries use hex::encode which produces lowercase.
let pubkey_lower = pubkey_hex.to_ascii_lowercase();
sqlx::query(
"INSERT IGNORE INTO event_mentions \
(pubkey_hex, event_id, event_created_at, channel_id, event_kind) \
VALUES (?, ?, ?, ?, ?)",
)
.bind(&pubkey_lower)
.bind(event_id_bytes.as_slice())
.bind(created_at)
.bind(channel_id_bytes.as_deref())
.bind(kind)
.execute(pool)
.await?;
// Validate and normalize pubkeys, logging any malformed ones.
let valid_pubkeys: Vec<String> = p_tags
.into_iter()
.filter(|pk| {
if pk.len() != 64 || !pk.chars().all(|c| c.is_ascii_hexdigit()) {
tracing::debug!(
event_id = %event.id,
invalid_ptag = pk,
"skipping malformed p-tag in mentions insert"
);
return false;
}
true
})
.map(|pk| pk.to_ascii_lowercase())
.collect();
if valid_pubkeys.is_empty() {
return Ok(());
}
// Single multi-row INSERT IGNORE — one round-trip regardless of mention count.
let mut qb: QueryBuilder<'_, sqlx::MySql> = QueryBuilder::new(
"INSERT IGNORE INTO event_mentions \
(pubkey_hex, event_id, event_created_at, channel_id, event_kind) ",
);
qb.push_values(&valid_pubkeys, |mut b, pubkey| {
b.push_bind(pubkey.as_str())
.push_bind(event_id_bytes.as_slice())
.push_bind(created_at)
.push_bind(channel_id_bytes.as_deref())
.push_bind(kind);
});
qb.build().execute(pool).await?;
Ok(())
}

View File

@@ -100,22 +100,15 @@ pub(crate) async fn dispatch_persistent_event(
// Skip search indexing for NIP-17 gift wraps — content is ciphertext,
// and indexing would leak #p tag metadata into the search index.
if kind_u32 != KIND_GIFT_WRAP {
let search = Arc::clone(&state.search);
let stored_for_search = stored_event.clone();
tokio::spawn(async move {
let t = std::time::Instant::now();
match search.index_event(&stored_for_search).await {
Ok(()) => {
metrics::histogram!("sprout_search_index_seconds")
.record(t.elapsed().as_secs_f64());
}
Err(e) => {
metrics::counter!("sprout_search_index_errors_total").increment(1);
error!(event_id = %stored_for_search.event.id.to_hex(), "Search index failed: {e}");
}
}
});
// Use the bounded mpsc channel to prevent OOM if Typesense is slow/down.
if kind_u32 != KIND_GIFT_WRAP
&& state
.search_index_tx
.try_send(stored_event.clone())
.is_err()
{
metrics::counter!("sprout_search_index_errors_total").increment(1);
warn!(event_id = %event_id_hex, "Search index channel full — dropping event");
}
let audit = Arc::clone(&state.audit);
@@ -651,8 +644,39 @@ async fn check_channel_membership(
conn_id: uuid::Uuid,
event_id_hex: &str,
) -> Result<(), String> {
let cache_key = (ch_id, pubkey_bytes.to_vec());
// Check cache first.
if let Some(is_member) = state.membership_cache.get(&cache_key) {
if is_member {
return Ok(());
}
// Cached as non-member — still need to check open channel fallback.
let is_open = match state.db.get_channel(ch_id).await {
Ok(ch) => ch.visibility == "open",
Err(e) => {
tracing::warn!(conn_id = %conn_id, channel = %ch_id, error = %e,
"channel lookup failed during membership fallback, denying");
false
}
};
return if is_open {
Ok(())
} else {
Err(RelayMessage::ok(
event_id_hex,
false,
"restricted: not a channel member",
))
};
}
// Cache miss — query DB.
match state.db.is_member(ch_id, pubkey_bytes).await {
Ok(true) => Ok(()),
Ok(true) => {
state.membership_cache.insert(cache_key, true);
Ok(())
}
Ok(false) => {
let is_open = match state.db.get_channel(ch_id).await {
Ok(ch) => ch.visibility == "open",
@@ -663,8 +687,11 @@ async fn check_channel_membership(
}
};
if is_open {
// Don't cache — non-members in open channels are always allowed,
// and caching false would force a redundant get_channel on every hit.
Ok(())
} else {
state.membership_cache.insert(cache_key, false);
Err(RelayMessage::ok(
event_id_hex,
false,
@@ -890,10 +917,12 @@ async fn resolve_nip10_thread_meta(
let parent_bytes =
hex::decode(&parent_hex).map_err(|_| "invalid parent event ID hex".to_string())?;
let parent_event = state
.db
.get_event_by_id(&parent_bytes)
.await
let (parent_event_result, parent_meta_result) = tokio::join!(
state.db.get_event_by_id(&parent_bytes),
state.db.get_thread_metadata_by_event(&parent_bytes),
);
let parent_event = parent_event_result
.map_err(|e| format!("db error looking up parent: {e}"))?
.ok_or_else(|| "reply parent not found".to_string())?;
@@ -913,11 +942,8 @@ async fn resolve_nip10_thread_meta(
let client_root_bytes =
hex::decode(&root_hex).map_err(|_| "invalid root event ID hex".to_string())?;
let parent_meta = state
.db
.get_thread_metadata_by_event(&parent_bytes)
.await
.map_err(|e| format!("db error looking up thread metadata: {e}"))?;
let parent_meta =
parent_meta_result.map_err(|e| format!("db error looking up thread metadata: {e}"))?;
let (final_root_bytes, root_created, depth) = match parent_meta {
Some(meta) => {

View File

@@ -13,6 +13,7 @@ use uuid::Uuid;
use deadpool_redis;
use sprout_audit::AuditService;
use sprout_auth::AuthService;
use sprout_core::event::StoredEvent;
use sprout_db::Db;
use sprout_media::MediaStorage;
use sprout_pubsub::PubSubManager;
@@ -151,6 +152,13 @@ pub struct AppState {
/// consumer skips them to avoid double delivery. Entries expire after
/// 60 seconds via moka's TTL eviction — bounded regardless of subscriber health.
pub local_event_ids: Arc<moka::sync::Cache<[u8; 32], ()>>,
/// Membership cache: (channel_id, pubkey_bytes) → is_member.
/// Short TTL (10s) — membership changes are rare but must propagate.
pub membership_cache: Arc<moka::sync::Cache<(Uuid, Vec<u8>), bool>>,
/// Bounded channel for search indexing — prevents OOM if Typesense is slow/down.
/// Capacity 1000: at ~1KB/event that's ~1MB of backlog before we start dropping.
pub search_index_tx: mpsc::Sender<StoredEvent>,
/// Media storage client (S3/MinIO).
pub media_storage: Arc<MediaStorage>,
/// Set to `true` on SIGTERM — readiness probe returns 503.
@@ -176,6 +184,30 @@ impl AppState {
) -> Self {
let max_connections = config.max_connections;
let max_concurrent_handlers = config.max_concurrent_handlers;
let search_arc = Arc::new(search);
let (search_index_tx, mut search_index_rx) = mpsc::channel::<StoredEvent>(1000);
let search_for_worker = Arc::clone(&search_arc);
tokio::spawn(async move {
while let Some(stored_event) = search_index_rx.recv().await {
let t = std::time::Instant::now();
match search_for_worker.index_event(&stored_event).await {
Ok(()) => {
metrics::histogram!("sprout_search_index_seconds")
.record(t.elapsed().as_secs_f64());
}
Err(e) => {
metrics::counter!("sprout_search_index_errors_total").increment(1);
tracing::error!(
event_id = %stored_event.event.id.to_hex(),
"Search index failed: {e}"
);
}
}
}
tracing::warn!("search index worker exited (expected on shutdown)");
});
Self {
config: Arc::new(config),
db,
@@ -183,7 +215,7 @@ impl AppState {
audit: Arc::new(audit),
pubsub,
auth: Arc::new(auth),
search: Arc::new(search),
search: search_arc,
sub_registry: Arc::new(SubscriptionRegistry::new()),
conn_manager: Arc::new(ConnectionManager::new()),
conn_semaphore: Arc::new(Semaphore::new(max_connections)),
@@ -198,6 +230,14 @@ impl AppState {
.time_to_live(std::time::Duration::from_secs(60))
.build(),
),
membership_cache: Arc::new(
moka::sync::Cache::builder()
.max_capacity(10_000)
.time_to_live(std::time::Duration::from_secs(10))
.build(),
),
search_index_tx,
media_storage: Arc::new(media_storage),
shutting_down: Arc::new(AtomicBool::new(false)),
started_at: Instant::now(),

View File

@@ -150,8 +150,9 @@ export function useChannelsQuery() {
return useQuery({
queryKey: channelsQueryKey,
queryFn: async () => sortChannels(await getChannels()),
staleTime: 30_000,
refetchInterval: 15_000,
staleTime: 60_000,
refetchInterval: 60_000,
refetchIntervalInBackground: false,
});
}

View File

@@ -17,6 +17,13 @@ import {
KIND_SYSTEM_MESSAGE,
} from "@/shared/constants/kinds";
const HEX_RE = /^[0-9a-f]+$/i;
const TIME_FORMATTER = new Intl.DateTimeFormat("en-US", {
hour: "numeric",
minute: "2-digit",
});
function isTimelineContentEvent(event: RelayEvent) {
return (
event.kind === KIND_STREAM_MESSAGE ||
@@ -32,7 +39,7 @@ function getDeletionTargets(tags: string[][]) {
tag[0] === "e" &&
typeof tag[1] === "string" &&
tag[1].length === 64 &&
/^[0-9a-f]+$/i.test(tag[1]),
HEX_RE.test(tag[1]),
)
.map((tag) => tag[1]);
}
@@ -44,7 +51,7 @@ function getReactionTargetId(tags: string[][]) {
tag?.[0] === "e" &&
typeof tag[1] === "string" &&
tag[1].length === 64 &&
/^[0-9a-f]+$/i.test(tag[1])
HEX_RE.test(tag[1])
) {
return tag[1];
}
@@ -250,10 +257,7 @@ export function formatTimelineMessages(
currentUserAvatarUrl,
profiles,
}),
time: new Intl.DateTimeFormat("en-US", {
hour: "numeric",
minute: "2-digit",
}).format(new Date(event.created_at * 1_000)),
time: TIME_FORMATTER.format(new Date(event.created_at * 1_000)),
body: event.content,
parentId: thread.parentId,
rootId: thread.rootId,

View File

@@ -31,7 +31,7 @@ function getHostname(url: string): string {
}
}
export function DiffMessage({
export default function DiffMessage({
content,
repoUrl,
filePath,

View File

@@ -231,7 +231,6 @@ export function MessageComposer({
const cursorPosition = textarea.selectionStart ?? content.length;
const existingMention = detectMentionQuery(content, cursorPosition);
if (existingMention) {
pendingSelectionRef.current = cursorPosition;
setMentionStartIndex(existingMention.startIndex);
setMentionQuery(existingMention.query);
setMentionSelectedIndex(0);
@@ -503,6 +502,7 @@ export function MessageComposer({
],
);
// biome-ignore lint/correctness/useExhaustiveDependencies: content triggers height recalc and pending selection restore
React.useLayoutEffect(() => {
const textarea = textareaRef.current;
if (!textarea) {
@@ -528,7 +528,7 @@ export function MessageComposer({
textarea.setSelectionRange(pendingSelection, pendingSelection);
pendingSelectionRef.current = null;
}
});
}, [content]);
React.useEffect(() => {
if (!replyTarget || disabled) {

View File

@@ -8,288 +8,323 @@ import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
import { KIND_STREAM_MESSAGE_DIFF } from "@/shared/constants/kinds";
import { cn } from "@/shared/lib/cn";
import { Markdown } from "@/shared/ui/markdown";
import { DiffMessage } from "./DiffMessage";
import { MessageActionBar } from "./MessageActionBar";
const DiffMessage = React.lazy(() => import("./DiffMessage"));
const DiffMessageExpanded = React.lazy(() => import("./DiffMessageExpanded"));
export function MessageRow({
activeReplyTargetId = null,
message,
onToggleReaction,
onReply,
}: {
activeReplyTargetId?: string | null;
message: TimelineMessage;
onToggleReaction?: (
message: TimelineMessage,
emoji: string,
remove: boolean,
) => Promise<void>;
onReply?: (message: TimelineMessage) => void;
}) {
const [hasAvatarError, setHasAvatarError] = React.useState(false);
const [expandedDiffId, setExpandedDiffId] = React.useState<string | null>(
null,
);
const [reactionErrorMessage, setReactionErrorMessage] = React.useState<
string | null
>(null);
const [reactionPending, setReactionPending] = React.useState(false);
const visibleDepth = Math.min(message.depth, 6);
const indentPx = visibleDepth * 28;
const initials = message.author
.split(" ")
.map((part) => part[0])
.join("")
.slice(0, 2)
.toUpperCase();
export const MessageRow = React.memo(
function MessageRow({
activeReplyTargetId = null,
highlighted = false,
message,
onToggleReaction,
onReply,
}: {
activeReplyTargetId?: string | null;
highlighted?: boolean;
message: TimelineMessage;
onToggleReaction?: (
message: TimelineMessage,
emoji: string,
remove: boolean,
) => Promise<void>;
onReply?: (message: TimelineMessage) => void;
}) {
const [hasAvatarError, setHasAvatarError] = React.useState(false);
const [expandedDiffId, setExpandedDiffId] = React.useState<string | null>(
null,
);
const [reactionErrorMessage, setReactionErrorMessage] = React.useState<
string | null
>(null);
const [reactionPending, setReactionPending] = React.useState(false);
const visibleDepth = Math.min(message.depth, 6);
const indentPx = visibleDepth * 28;
const initials = message.author
.split(" ")
.map((part) => part[0])
.join("")
.slice(0, 2)
.toUpperCase();
const getTag = (name: string) =>
message.tags?.find((tag) => tag[0] === name)?.[1];
const getTag = (name: string) =>
message.tags?.find((tag) => tag[0] === name)?.[1];
const renderBody = () => {
switch (message.kind) {
case KIND_STREAM_MESSAGE_DIFF:
return (
<DiffMessage
commitSha={getTag("commit")}
content={message.body}
description={getTag("description")}
filePath={getTag("file")}
onExpand={() => {
setExpandedDiffId(message.id);
}}
repoUrl={getTag("repo")}
truncated={getTag("truncated") === "true"}
/>
);
default:
return <Markdown className="max-w-3xl" content={message.body} tight />;
}
};
const reactions = [...(message.reactions ?? [])].sort((left, right) => {
if (left.count !== right.count) {
return right.count - left.count;
}
return left.emoji.localeCompare(right.emoji);
});
const canToggleReactions = Boolean(onToggleReaction && !message.pending);
const handleReactionSelect = React.useCallback(
async (emoji: string) => {
if (!onToggleReaction || reactionPending) {
return;
}
const remove = reactions.some(
(reaction) => reaction.emoji === emoji && reaction.reactedByCurrentUser,
);
setReactionErrorMessage(null);
setReactionPending(true);
try {
await onToggleReaction(message, emoji, remove);
} catch (error) {
const nextMessage =
error instanceof Error
? error.message
: "Failed to update the reaction.";
setReactionErrorMessage(nextMessage);
throw error;
} finally {
setReactionPending(false);
}
},
[message, onToggleReaction, reactionPending, reactions],
);
return (
<div
className="relative"
style={indentPx > 0 ? { paddingLeft: `${indentPx}px` } : undefined}
>
{message.depth > 0 ? (
<div
aria-hidden
className="absolute bottom-1.5 left-3 top-1.5 rounded-full border-l border-border/70"
style={{ left: `${Math.max(indentPx - 14, 12)}px` }}
/>
) : null}
<article
className={cn(
"group/message flex gap-2.5 rounded-2xl px-2 py-1 transition-colors",
message.highlighted ? "bg-primary/10 ring-1 ring-primary/30" : "",
activeReplyTargetId === message.id
? "bg-muted/60 ring-1 ring-border"
: "",
)}
data-message-id={message.id}
data-testid="message-row"
>
{message.pubkey ? (
<UserProfilePopover pubkey={message.pubkey}>
<button
className="shrink-0 rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
type="button"
>
{message.avatarUrl && !hasAvatarError ? (
<img
alt={`${message.author} avatar`}
className="h-8 w-8 rounded-lg bg-secondary object-cover shadow-sm"
data-testid="message-avatar-image"
onError={() => {
setHasAvatarError(true);
}}
referrerPolicy="no-referrer"
src={message.avatarUrl}
/>
) : (
<div
className={cn(
"flex h-8 w-8 items-center justify-center rounded-lg text-[11px] font-semibold shadow-sm",
message.accent
? "bg-primary text-primary-foreground"
: "bg-secondary text-secondary-foreground",
)}
data-testid="message-avatar-fallback"
>
{initials}
</div>
)}
</button>
</UserProfilePopover>
) : message.avatarUrl && !hasAvatarError ? (
<img
alt={`${message.author} avatar`}
className="h-8 w-8 shrink-0 rounded-lg bg-secondary object-cover shadow-sm"
data-testid="message-avatar-image"
onError={() => {
setHasAvatarError(true);
}}
referrerPolicy="no-referrer"
src={message.avatarUrl}
/>
) : (
<div
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-[11px] font-semibold shadow-sm",
message.accent
? "bg-primary text-primary-foreground"
: "bg-secondary text-secondary-foreground",
)}
data-testid="message-avatar-fallback"
>
{initials}
</div>
)}
<div className="min-w-0 flex-1 space-y-0.5">
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
{message.pubkey ? (
<UserProfilePopover pubkey={message.pubkey}>
<button
className="truncate rounded text-sm font-semibold tracking-tight hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
type="button"
>
{message.author}
</button>
</UserProfilePopover>
) : (
<h3 className="truncate text-sm font-semibold tracking-tight">
{message.author}
</h3>
)}
{message.role ? (
<p className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
{message.role}
</p>
) : null}
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
<MessageActionBar
activeReplyTargetId={activeReplyTargetId}
message={message}
onReactionSelect={
canToggleReactions ? handleReactionSelect : undefined
}
onReply={onReply}
reactionErrorMessage={reactionErrorMessage}
reactionPending={reactionPending}
reactions={reactions}
/>
{message.pending ? (
<p className="font-medium uppercase tracking-[0.14em] text-primary/80">
Sending
</p>
) : null}
<p className="whitespace-nowrap">{message.time}</p>
</div>
</div>
{renderBody()}
{reactions.length > 0 ? (
<div className="mt-1.5 flex flex-wrap items-center gap-1.5 pt-1">
{reactions.map((reaction: TimelineReaction) => (
<button
aria-label={`Toggle ${reaction.emoji} reaction`}
aria-pressed={reaction.reactedByCurrentUser}
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium transition-colors",
reaction.reactedByCurrentUser
? "border-primary/40 bg-primary/10 text-primary"
: "border-border/70 bg-muted/70 text-foreground/90",
canToggleReactions
? "hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
: "cursor-default",
)}
disabled={!canToggleReactions || reactionPending}
key={`${message.id}-${reaction.emoji}`}
onClick={() => {
if (!canToggleReactions) {
return;
}
void handleReactionSelect(reaction.emoji).catch(() => {
return;
});
}}
type="button"
>
<span>{reaction.emoji}</span>
<span className="text-muted-foreground">
{reaction.count}
</span>
</button>
))}
</div>
) : null}
{reactionErrorMessage ? (
<p className="mt-1.5 text-xs text-destructive">
{reactionErrorMessage}
</p>
) : null}
{expandedDiffId === message.id ? (
const renderBody = () => {
switch (message.kind) {
case KIND_STREAM_MESSAGE_DIFF:
return (
<React.Suspense
fallback={
<div className="p-3 text-sm text-muted-foreground">
Loading diff viewer…
Loading diff…
</div>
}
>
<DiffMessageExpanded
<DiffMessage
commitSha={getTag("commit")}
content={message.body}
description={getTag("description")}
filePath={getTag("file")}
onClose={() => {
setExpandedDiffId(null);
onExpand={() => {
setExpandedDiffId(message.id);
}}
repoUrl={getTag("repo")}
truncated={getTag("truncated") === "true"}
/>
</React.Suspense>
) : null}
</div>
</article>
</div>
);
}
);
default:
return (
<Markdown className="max-w-3xl" content={message.body} tight />
);
}
};
const reactions = [...(message.reactions ?? [])].sort((left, right) => {
if (left.count !== right.count) {
return right.count - left.count;
}
return left.emoji.localeCompare(right.emoji);
});
const canToggleReactions = Boolean(onToggleReaction && !message.pending);
const handleReactionSelect = React.useCallback(
async (emoji: string) => {
if (!onToggleReaction || reactionPending) {
return;
}
const remove = reactions.some(
(reaction) =>
reaction.emoji === emoji && reaction.reactedByCurrentUser,
);
setReactionErrorMessage(null);
setReactionPending(true);
try {
await onToggleReaction(message, emoji, remove);
} catch (error) {
const nextMessage =
error instanceof Error
? error.message
: "Failed to update the reaction.";
setReactionErrorMessage(nextMessage);
throw error;
} finally {
setReactionPending(false);
}
},
[message, onToggleReaction, reactionPending, reactions],
);
return (
<div
className="relative"
style={indentPx > 0 ? { paddingLeft: `${indentPx}px` } : undefined}
>
{message.depth > 0 ? (
<div
aria-hidden
className="absolute bottom-1.5 left-3 top-1.5 rounded-full border-l border-border/70"
style={{ left: `${Math.max(indentPx - 14, 12)}px` }}
/>
) : null}
<article
className={cn(
"group/message flex gap-2.5 rounded-2xl px-2 py-1 transition-colors",
highlighted ? "bg-primary/10 ring-1 ring-primary/30" : "",
activeReplyTargetId === message.id
? "bg-muted/60 ring-1 ring-border"
: "",
)}
data-message-id={message.id}
data-testid="message-row"
>
{message.pubkey ? (
<UserProfilePopover pubkey={message.pubkey}>
<button
className="shrink-0 rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
type="button"
>
{message.avatarUrl && !hasAvatarError ? (
<img
alt={`${message.author} avatar`}
className="h-8 w-8 rounded-lg bg-secondary object-cover shadow-sm"
data-testid="message-avatar-image"
onError={() => {
setHasAvatarError(true);
}}
referrerPolicy="no-referrer"
src={message.avatarUrl}
/>
) : (
<div
className={cn(
"flex h-8 w-8 items-center justify-center rounded-lg text-[11px] font-semibold shadow-sm",
message.accent
? "bg-primary text-primary-foreground"
: "bg-secondary text-secondary-foreground",
)}
data-testid="message-avatar-fallback"
>
{initials}
</div>
)}
</button>
</UserProfilePopover>
) : message.avatarUrl && !hasAvatarError ? (
<img
alt={`${message.author} avatar`}
className="h-8 w-8 shrink-0 rounded-lg bg-secondary object-cover shadow-sm"
data-testid="message-avatar-image"
onError={() => {
setHasAvatarError(true);
}}
referrerPolicy="no-referrer"
src={message.avatarUrl}
/>
) : (
<div
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-[11px] font-semibold shadow-sm",
message.accent
? "bg-primary text-primary-foreground"
: "bg-secondary text-secondary-foreground",
)}
data-testid="message-avatar-fallback"
>
{initials}
</div>
)}
<div className="min-w-0 flex-1 space-y-0.5">
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
{message.pubkey ? (
<UserProfilePopover pubkey={message.pubkey}>
<button
className="truncate rounded text-sm font-semibold tracking-tight hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
type="button"
>
{message.author}
</button>
</UserProfilePopover>
) : (
<h3 className="truncate text-sm font-semibold tracking-tight">
{message.author}
</h3>
)}
{message.role ? (
<p className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
{message.role}
</p>
) : null}
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
<MessageActionBar
activeReplyTargetId={activeReplyTargetId}
message={message}
onReactionSelect={
canToggleReactions ? handleReactionSelect : undefined
}
onReply={onReply}
reactionErrorMessage={reactionErrorMessage}
reactionPending={reactionPending}
reactions={reactions}
/>
{message.pending ? (
<p className="font-medium uppercase tracking-[0.14em] text-primary/80">
Sending
</p>
) : null}
<p className="whitespace-nowrap">{message.time}</p>
</div>
</div>
{renderBody()}
{reactions.length > 0 ? (
<div className="mt-1.5 flex flex-wrap items-center gap-1.5 pt-1">
{reactions.map((reaction: TimelineReaction) => (
<button
aria-label={`Toggle ${reaction.emoji} reaction`}
aria-pressed={reaction.reactedByCurrentUser}
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium transition-colors",
reaction.reactedByCurrentUser
? "border-primary/40 bg-primary/10 text-primary"
: "border-border/70 bg-muted/70 text-foreground/90",
canToggleReactions
? "hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
: "cursor-default",
)}
disabled={!canToggleReactions || reactionPending}
key={`${message.id}-${reaction.emoji}`}
onClick={() => {
if (!canToggleReactions) {
return;
}
void handleReactionSelect(reaction.emoji).catch(() => {
return;
});
}}
type="button"
>
<span>{reaction.emoji}</span>
<span className="text-muted-foreground">
{reaction.count}
</span>
</button>
))}
</div>
) : null}
{reactionErrorMessage ? (
<p className="mt-1.5 text-xs text-destructive">
{reactionErrorMessage}
</p>
) : null}
{expandedDiffId === message.id ? (
<React.Suspense
fallback={
<div className="p-3 text-sm text-muted-foreground">
Loading diff viewer…
</div>
}
>
<DiffMessageExpanded
content={message.body}
filePath={getTag("file")}
onClose={() => {
setExpandedDiffId(null);
}}
/>
</React.Suspense>
) : null}
</div>
</article>
</div>
);
// Callbacks (onReply, onToggleReaction) intentionally excluded: inline arrows
// from parent create new refs every render — including them defeats memo.
},
(prev, next) =>
prev.message.id === next.message.id &&
prev.message.pubkey === next.message.pubkey &&
prev.message.body === next.message.body &&
prev.message.author === next.message.author &&
prev.message.avatarUrl === next.message.avatarUrl &&
prev.message.accent === next.message.accent &&
prev.message.time === next.message.time &&
prev.message.depth === next.message.depth &&
prev.message.kind === next.message.kind &&
prev.message.pending === next.message.pending &&
prev.message.reactions === next.message.reactions &&
prev.message.tags === next.message.tags &&
prev.message.role === next.message.role &&
prev.highlighted === next.highlighted &&
prev.activeReplyTargetId === next.activeReplyTargetId,
);
MessageRow.displayName = "MessageRow";

View File

@@ -109,11 +109,9 @@ export function MessageTimeline({
) : (
<MessageRow
activeReplyTargetId={activeReplyTargetId}
highlighted={message.id === highlightedMessageId}
key={message.id}
message={{
...message,
highlighted: message.id === highlightedMessageId,
}}
message={message}
onToggleReaction={onToggleReaction}
onReply={onReply}
/>

View File

@@ -136,15 +136,15 @@ export function SearchDialog({
onOpenResult,
}: SearchDialogProps) {
const [query, setQuery] = React.useState("");
const [debouncedQuery, setDebouncedQuery] = React.useState("");
const [selectedIndex, setSelectedIndex] = React.useState(0);
const deferredQuery = React.useDeferredValue(query.trim());
const inputRef = React.useRef<HTMLInputElement>(null);
const channelLookup = React.useMemo(
() => new Map(channels.map((channel) => [channel.id, channel])),
[channels],
);
const searchQuery = useSearchMessagesQuery(deferredQuery, {
const searchQuery = useSearchMessagesQuery(debouncedQuery, {
enabled: open,
limit: 12,
});
@@ -166,6 +166,22 @@ export function SearchDialog({
[onOpenChange, onOpenResult],
);
React.useEffect(() => {
const trimmed = query.trim();
if (trimmed.length < MIN_QUERY_LENGTH) {
setDebouncedQuery("");
return;
}
const timeout = window.setTimeout(() => {
setDebouncedQuery(trimmed);
}, 300);
return () => {
window.clearTimeout(timeout);
};
}, [query]);
React.useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
if (
@@ -190,6 +206,7 @@ export function SearchDialog({
React.useEffect(() => {
if (!open) {
setQuery("");
setDebouncedQuery("");
setSelectedIndex(0);
return;
}
@@ -276,7 +293,7 @@ export function SearchDialog({
</DialogHeader>
<div className="max-h-[60vh] overflow-y-auto">
{deferredQuery.length < MIN_QUERY_LENGTH ? (
{debouncedQuery.length < MIN_QUERY_LENGTH ? (
<SearchState
description="Type at least two characters to search the relay-backed history for streams, forums, DMs, approvals, and agent updates."
icon={MessagesSquare}

View File

@@ -22,6 +22,7 @@ import {
const RECONNECT_BASE_DELAY_MS = 1_000;
const RECONNECT_MAX_DELAY_MS = 30_000;
const RECONNECT_REPLAY_SKEW_SECS = 5;
const EVENT_BATCH_MS = 16;
export class RelayClient {
private wsId: number | null = null;
@@ -38,6 +39,8 @@ export class RelayClient {
} | null = null;
private subscriptions = new Map<string, RelaySubscription>();
private pendingEvents = new Map<string, PendingEvent>();
private eventBuffer: Array<{ subId: string; event: RelayEvent }> = [];
private flushTimeout: ReturnType<typeof setTimeout> | null = null;
private reconnectListeners = new Set<() => void>();
private hasConnectedOnce = false;
private notifyReconnectListeners = false;
@@ -494,7 +497,26 @@ export class RelayClient {
subscription.lastSeenCreatedAt ?? 0,
event.created_at,
);
subscription.onEvent(event);
this.eventBuffer.push({ subId, event });
this.flushTimeout ??= window.setTimeout(
() => this.flushEventBuffer(),
EVENT_BATCH_MS,
);
}
private flushEventBuffer() {
this.flushTimeout = null;
const buffer = this.eventBuffer;
this.eventBuffer = [];
// Re-lookup: subscriptions removed during batch window are intentionally skipped.
for (const { subId, event } of buffer) {
const subscription = this.subscriptions.get(subId);
if (subscription?.mode === "live") {
subscription.onEvent(event);
}
}
}
private handleEose(subId: string) {
@@ -648,6 +670,10 @@ export class RelayClient {
reconnect?: boolean;
},
) {
if (this.flushTimeout !== null) window.clearTimeout(this.flushTimeout);
this.flushTimeout = null;
this.eventBuffer = [];
if (options?.reconnect !== false && this.hasConnectedOnce) {
this.notifyReconnectListeners = true;
}