The problem
AgentsMesh runs agents as pods. A channel is where humans and pods talk: humans type, pods reply, and a human can pull a specific pod into the conversation by @-mentioning it. Two things were wrong with the first version.
The message UI was built around a single global store keyed to the "current" channel. Switching channels threw away scroll position, new-message tracking flipped on the wrong channel, and WebSocket events were routed by currentChannel rather than by the channel the event belonged to. The store grew without bound — every channel ever opened stayed resident.
The @mention path forwarded a prompt to a pod, but the prompt frequently did not submit. The text landed in the agent's terminal and just sat there at the input line.
Both got fixed in the same arc of work: a channel redesign on IM-standard patterns (PR #198), and a forwarding chain that ends in a real Enter keystroke (PR #401).
Per-channel cache, not a global store
The store rewrite is the structural change. Instead of one mutable "current channel" blob, the cache is keyed by channel id:
cache: Record<number, ChannelMessageCache>
Each entry carries its own messages, hasMore, loading, and error. A channel switch reads a different key; it doesn't tear down and rebuild the one global slot. WebSocket events route by channelId, so a message arriving on channel B updates B's cache whether or not B is on screen.
Unbounded growth is bounded with LRU eviction. The cap is a constant:
const MAX_CACHED_CHANNELS = 20;
When a 21st channel is written, the oldest entries beyond the cap are dropped. The trade-off is deliberate: 20 channels of history in memory, and re-opening a long-idle channel costs one fetch.
On the backend, "is there more history" stopped being a guess. The list API fetches limit+1 rows and reports has_more:
messages, err := s.repo.GetMessages(ctx, channelID, before, limit+1)
hasMore := len(messages) > limit
if hasMore {
messages = messages[:limit]
}
slices.Reverse(messages)
return messages, hasMore, nil
The +1 row never reaches the client; it only answers whether the "load older" affordance should exist. MCP's getMessages gained a hard cap of 100 per call so a client can't ask for an unbounded page.
IM-standard scroll
The scroll behavior is the part users actually feel. useMessageListScroll resolves three scenarios:
- Stick to bottom. If you're at the bottom and a message arrives, follow it down.
- Preserve position on load-older. Prepending history must not jump the viewport.
- Jump to Latest. If you've scrolled up and messages arrive, surface a floating button with an unread count instead of yanking you to the bottom.
"At the bottom" is a threshold, not an equality:
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 50;
The 50px slack absorbs sub-pixel layout and avoids the button flickering on and off at the boundary. The new-message count is reset inside the scroll handler rather than in an effect — reacting to setState inside an effect is what the React compiler flags, and the scroll handler already has the signal.
@mention → pod prompt
The forwarding chain is where IM semantics meet a real process. A mention is not a string match. The message body is structured content; extractMentions walks the block tree and collects typed mentions, deduplicating as it goes:
case channel.EntityPod:
if !podsSeen[el.EntityKey] {
podsSeen[el.EntityKey] = true
m.Pods = append(m.Pods, el.EntityKey)
}
The result is persisted as jsonb on the message (Mentions MessageMentions, with Pods, Users, Channel). The frontend inserts pod mention text as the first 8 characters of the pod key (pod_key.slice(0, 8)); the backend mirrors that exact length:
const podMentionTextLen = 8
Sending a message runs a chain of post-send hooks, in registration order: validate mentions → publish event → notify → forward to pod. The validator drops pod keys that don't resolve in the org before forwarding ever sees them. The forwarding hook then builds the prompt:
func buildPodPrompt(content, channelName string, channelID int64, podKeys []string) string {
rawPrompt := stripPodMentions(content, podKeys)
rawPrompt = ptyPromptFlattener.Replace(rawPrompt)
return fmt.Sprintf("Message from channel(#%s, channel_id=%d): %s. If you finish it, please reply to this channel using send_channel_message(channel_id=%d).", channelName, channelID, rawPrompt, channelID)
}
Three things happen here. The @abcd1234 token is stripped so the agent doesn't see its own handle. The body is flattened — \r\n, \n, \r all become a space. And the template tells the agent where to reply, so a pod's answer lands back in the same channel via the send_channel_message tool. A pod that mentions itself is skipped (SenderPod == podKey), so an agent can't loop on its own message.
Why the prompt didn't submit
The original submit wrote the prompt text, then wrote \r as a second call, expecting the terminal UI to read the carriage return as Enter. It didn't. Both writes hit the PTY microseconds apart, the TUI's read(2) loop folded them into one chunk, and the trailing \r was consumed as the tail of a paste — not as a submit keystroke.
The reason MCP's path worked and this one didn't is timing. MCP issues the text and the keypress as two separate RPCs; the network round-trip gives the TUI's read loop a natural tick between them. The in-process gRPC control plane has no such gap.
The fix reconstructs the gap explicitly and uses the keypress API instead of a raw byte:
const ptySubmitGap = 80 * time.Millisecond
if err := pod.IO.SendInput(cmd.Prompt); err != nil {
return err
}
if ta, ok := pod.IO.(TerminalAccess); ok {
time.Sleep(ptySubmitGap)
return ta.SendKeys([]string{"enter"})
}
SendInput is "raw bytes"; SendKeys(["enter"]) is "press a key." After an 80ms gap the TUI sees one read(2) carrying just the Enter, and treats it as a real keystroke. The newline-flattening in buildPodPrompt is the other half: an embedded \n in the body would pre-submit a partial prompt or sink the final Enter the same way. A test pins the invariant so the body can never grow newlines again:
func TestBuildPodPrompt_NeverContainsNewlines(t *testing.T) {
// "line1\nline2", "line1\r\nline2", "trailing\n" ...
if strings.ContainsAny(got, "\n\r") {
t.Fatalf("buildPodPrompt must not emit \\n or \\r (breaks PTY Enter submit)")
}
}
Counting agents separately from members
One adjacent correctness bug is worth naming. A channel has two backing tables — channel_members for users, channel_pods for agents — but the member counter only counted users. Joining an agent showed "Members · 0" and an empty right rail. The fix adds an independent AgentCount filled by a separate GetChannelPodCount, so the user count and the agent count are computed from their own tables rather than one standing in for the other (PR #406).
Takeaways
A few constraints carry beyond this codebase.
Key your message cache by conversation id, not by a mutable "current" pointer, and route realtime events by the same id. The global-current pattern fails the moment two conversations are live.
When a write and its submit keystroke share an in-process channel, they will coalesce. IM and terminal UIs both read input in chunks; a submit needs to arrive in its own read. Reproduce the round-trip gap deliberately, and prefer a "press a key" API over emitting a raw control byte and hoping the reader segments it.
Forward structured intent, not raw text. The mention is typed (EntityPod + key), validated against the org, stripped from the body, flattened to a single line, and wrapped with an explicit reply path. Each step removes a way for the agent to receive something it can't act on.