diff --git a/apps/web/src/modules/mesh/topic-chat-panel.tsx b/apps/web/src/modules/mesh/topic-chat-panel.tsx
index 721f094..9a86b82 100644
--- a/apps/web/src/modules/mesh/topic-chat-panel.tsx
+++ b/apps/web/src/modules/mesh/topic-chat-panel.tsx
@@ -180,6 +180,8 @@ export function TopicChatPanel({
start: number;
selected: number;
} | null>(null);
+ const [searchQuery, setSearchQuery] = useState("");
+ const [searchOpen, setSearchOpen] = useState(false);
const scrollRef = useRef(null);
const textareaRef = useRef(null);
const seenIdsRef = useRef>(new Set());
@@ -344,11 +346,14 @@ export function TopicChatPanel({
}, [apiKeySecret, topicName, markRead]);
useEffect(() => {
+ // Don't yank scroll while the user is searching — they're reading
+ // matches, not the live tail.
+ if (searchQuery.trim()) return;
scrollRef.current?.scrollTo({
top: scrollRef.current.scrollHeight,
behavior: "smooth",
});
- }, [messages.length]);
+ }, [messages.length, searchQuery]);
// Member name lookup for autocomplete. Filtered by case-insensitive
// prefix match on displayName; shorter names rank higher so e.g. "@al"
@@ -457,6 +462,19 @@ export function TopicChatPanel({
const onlineCount = members.filter((m) => m.online).length;
+ // Client-side search over loaded messages. Decodes once per query so
+ // we can filter on plaintext, then highlights matches in render.
+ // Server-side fulltext lands when we move ciphertext to per-topic
+ // keys (v0.3.0) — until then there's no server index to query.
+ const searchTerm = searchQuery.trim().toLowerCase();
+ const filteredMessages = useMemo(() => {
+ if (!searchTerm) return messages;
+ return messages.filter((m) =>
+ decodeIncoming(m.ciphertext).toLowerCase().includes(searchTerm) ||
+ (m.senderName ?? "").toLowerCase().includes(searchTerm),
+ );
+ }, [messages, searchTerm]);
+
return (
{/* Header — mono strip, clay-pulse dot, metadata right */}
@@ -470,9 +488,46 @@ export function TopicChatPanel({
#{topicName}
-
- {messages.length} msg · {stateLabel}
-
+
+ {searchOpen ? (
+ setSearchQuery(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Escape") {
+ e.preventDefault();
+ setSearchQuery("");
+ setSearchOpen(false);
+ }
+ }}
+ placeholder="search…"
+ className="w-44 rounded-[var(--cm-radius-sm)] border border-[var(--cm-border)] bg-[var(--cm-bg)] px-2 py-1 text-[11px] text-[var(--cm-fg)] placeholder:text-[var(--cm-fg-tertiary)] focus:border-[var(--cm-border-hover)] focus:outline-none"
+ />
+ ) : null}
+
+
+ {searchTerm
+ ? `${filteredMessages.length}/${messages.length}`
+ : `${messages.length} msg`}
+ {" · "}
+ {stateLabel}
+
+
{/* Body — message stream + member sidebar */}
@@ -486,9 +541,16 @@ export function TopicChatPanel({
>
no envelopes on this topic yet
+ ) : filteredMessages.length === 0 ? (
+
+ no matches for “{searchTerm}”
+
) : (
- {messages.map((m) => (
+ {filteredMessages.map((m) => (
-