fix(ws): show a conversation that first appears as a message - #222
Conversation
A group DM another user created mid-session never appeared: its messages reached OnMessage and were cached, but no conversation event before them produced a sidebar row, so the unread writes for it updated no row and there was nothing to show. The message is the one signal proven to arrive, so an unknown channel ID on a message now triggers one conversations.info lookup and the existing OnConversationOpened path. The lookup is synchronous on the WebSocket goroutine so the row and channel type exist before the unread write and the UI dispatch, and so wctx.Channels keeps its single writer. Failures are not remembered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The sidebar's staleness filter reads read state the moment a row arrives. Sending ConversationOpenedMsg before OnMessage wrote has_unread let the UI goroutine evaluate a never-opened group DM as read, hide it under the default 30-day threshold, and keep it hidden: the following NewMessageMsg only invalidates the render, it does not re-filter. So OnConversationOpened is split into addConversation and publishConversation, and OnMessage publishes after the unread write. A failed lookup now backs off for a minute per channel. Where conversations.info is refused (as users.conversations is on some Enterprise Grid orgs) a busy unknown channel would otherwise issue a blocking request per message, and a 429 would be retried immediately. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A flat one-minute wait after any failed lookup could strand a conversation: a transient error on one message suppressed retries for the rest of a quick burst, and once the burst ended nothing looked the conversation up again. Network errors and timeouts may clear by the next message, so they retry immediately again. A Slack API refusal (SlackErrorResponse) waits a minute and a 429 waits its RetryAfter, since neither changes on the next message and retrying would cost a blocking request per message on a busy channel. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Verified the mechanism and this is right. Merging. The two design decisions that make it work:
I verified the tests have teeth: with The sync lookup blocking the WS loop for up to 10s is the one thing I weighed — but the backoff means it's bounded per conversation, and your "Why not fix it elsewhere" section covers the async alternative honestly (buffering + replay + another writer to Also noted you checked Merging. |
…ammons#222 gammons#162 added FinderItem to ConversationOpenedMsg so a newly opened conversation appears in the channel finder. gammons#222 (based on an older main) split OnConversationOpened into addConversation/publishConversation on a version of the code that predates gammons#162. The three-way textual merge was clean, so GitHub allowed it, but the result placed `FinderItem: finderItem` inside publishConversation, where finderItem is out of scope -- main did not build: cmd/slk/main.go:5119:15: undefined: finderItem addConversation now returns the finder item alongside the sidebar item, discoverConversation forwards it, and publishConversation takes and sends both, restoring both behaviors: gammons#222's announce-after-unread-write ordering and gammons#162's finder sync. go build, go vet, gofmt and go test ./... -race (57 packages) all pass, including the gammons#222 discovery tests.
Problem
A coworker started a new group DM with me and another person and sent three messages. The official client showed the group with a
3badge; slk showed nothing, not even a hidden row. slk had been running for about 35 hours when the group was created. Restarting would have shown it, becauseclient.userBootlists it now, so this looks intermittent from the outside: it only affects a conversation created while slk is running.The workspace is Enterprise Grid, where
users.conversationsis refused (enterprise_is_restricted) and the sidebar comes from thebootConversationsfallback.Cause
Verified on 2026-09-16 against the running v0.20.0 session's cache and live API responses:
messagesfor that channel, withcreated_atmatching the send times, soOnMessageran for each.channelshas no row for it.OnConversationOpenedis the only mid-session writer of that row and it always upserts, so it never ran for this conversation. With no row,OnMessage'sUpdateChannelReadState(a plainUPDATE) changed nothing, and the sidebar had no item to show.dispatchWebSocketEvent:mpim_joinedis not handled, andmpim_open/im_opendecodechannelas a fullslack.Channel, while slack-go modelsim_open'schannelas a string ID (IMOpenEventisChannelInfoEvent), which would fail to decode and be dropped silently. The socket also connects withlazy_channels=1, so Slack may not push a conversation event at all. In the field check below, reopening a closed DM produced an unhandledchannel_updatedwhosechannelis a bare ID (updates: {properties: {}, updated: …}), with no conversation object; whether anim_opencame too can't be told, because that case drops a failed decode without logging.conversations.infoanswers for it on this Grid workspace:is_mpim: true,is_member: true, and thempdm-…namebuildChannelItemneeds.Fix
OnMessagenow callsdiscoverConversationfirst. If the channel ID is not inchannelTypes, it callsconversations.info(newClient.GetConversationInfo) and feeds the result through the existingOnConversationOpenedlogic. Three commits:OnMessagewrites the unread flag and chooses the notification type. It also keepswctx.Channelswritten only from that goroutine (data race: WorkspaceContext.Channels read on UI goroutine, written by WS handler #208). It costs one request per new conversation per session.OnConversationOpenedis split intoaddConversation(row,wctx.Channels, finder, name/type maps) andpublishConversation(the UI message).OnMessagepublishes only after the unread write. Sending first let the UI goroutine runrebuildFilteragainst a never-opened, not-yet-unread group DM and hide it under the default 30-day threshold. The nextNewMessageMsgonly invalidates the render; it doesn't re-filter, so the group stayed hidden.SlackErrorResponse, e.g.enterprise_is_restrictedorchannel_not_found) waits a minute, and a 429 waits itsRetryAfter. Otherwise an org that refusesconversations.info, as some refuseusers.conversations, would pay a blocking request per message on every channel missing from the boot list. A network error or timeout retries on the next message; a flat wait would skip the rest of a quick burst, which may be the only retry that conversation gets.No
is_membercheck: a delivered message is treated as membership, andconversations.infohas nois_memberfor ims.Why not fix it elsewhere
mpim_joined, or decodeim_open's string form? Without a capture, either one guesses the payload shape, and the existing handler's test payloads were written from its plan (docs/superpowers/plans/2026-05-01-mpdm-unread-indicator.md), not captured. The message is the one thing proven to arrive, and looking up an unknown ID when it's needed also covers thelazy_channelscase.wctx.Channels.Tests
In
cmd/slk/event_handler_test.go. Theconversations.infofixtures are the result shapes the Grid workspace returned for an mpim and an im, with IDs and names replaced. The mpim hasis_channel: truenext tois_mpim: true; the im has nois_member.TestOnMessage_UnknownConversation_AddsItUnread: two messages on an unknown group DM give one lookup, agroup_dmitem inwctx.Channels, and the UI messages in orderConversationOpenedMsg,NewMessageMsg,NewMessageMsg. WhenConversationOpenedMsgis sent,has_unreadis already true in the DB.TestOnMessage_UnknownConversation_RetryDependsOnFailure: a timeout is retried on the next message and the conversation is added. A wrappedSlackErrorResponseor*RateLimitedErroris not retried within the wait, and is retried and added after it.Red with each change reverted: remove the
discoverConversationcall and both go red; publish inside discovery, before the unread write, and_AddsItUnreadgoes red (opened unread=false); back off on every error and thetimeoutcase goes red; never back off, or ignore the stored retry time, and therefusedandrate limitedcases go red. Build, vet,go test ./... -race,gofmt -landgolangci-lint run(v2.13.1) are clean.Field check (macOS, the same Grid workspace, patched build). Two runs. In each, I first closed a DM in the official client, confirmed
client.userBootno longer listed it as open, and relaunched slk. Both boots loaded 37 conversations instead of 38, and the log never mentioned the closed DM.messageon the unknown DM, the log showsdiscovered conversation from message … im=true, and the cache has admrow withhas_unread=1and a badge count of 1. The workspace wasn't the active one: its rail dot lit, and switching in showed the DM in the sidebar, unread, with its badge. That is the reported path end to end, for a 1:1 DM rather than a group DM; the group DM shape is whatTestOnMessage_UnknownConversation_AddsItUnreaduses.im_markedecho that followed setlast_readon the new row; before this change it would have updated nothing. The DM ended up in the channel finder but not the sidebar, because I had also marked it unread in the official client: Slack sentim_markedwith the DM's Marchlast_readandunread_count: 0(my own message never counts),OnChannelMarkedrecorded it read and months stale, and the staleness filter hid it. That is the existing mark-unread handling, not this change.Things that usually bite, checked: a DM opened with the new-message picker is known to the sidebar but not to
channelTypes, so its first message does one lookup;sidebar.UpsertItemreplaces the minimal item by ID,buildChannelItemrecomputes section and mute, andUpsertChanneldoesn't touch read-state columns, so nothing is clobbered and no finder entry is duplicated. Self-sends, edits, thread replies and bot messages reach the lookup and then the existing unread gates, unchanged. Inactive workspaces get the row andwctx.Channels, so the rail dot can light.One visible side effect: when
users.conversationsis refused, the boot list isuserBoot's subset, so member channels missing from it now appear in the sidebar when their first message arrives.Noted but not changed
notifyReadStateChangedcallssidebar.Invalidate, which re-renders but doesn't re-runrebuildFilter, so the row only reappears on the next filter rebuild (a channel switch, for example). Checked with a throwawayApptest. Pre-existing and a different layer.mpim_open/im_open/im_createdare still decoded as before. Ifim_openreally carries a string ID, that case is dropped silently today.im_markedwith an olderlast_readandunread_count: 0, which slk records as read. Seen in the field check; pre-existing.rtmEventHandler.channelTypesis read from the mark-unread goroutine (main.go, thecountMentionsSincecall) while the WebSocket goroutine writes it. Pre-existing; this PR adds writes only when a new conversation is discovered.AI assistance
Written with Claude (Opus 5) driving the investigation, code and tests after I hit the bug in a live session. The cause was confirmed against the running session's cache rows and live
client.counts,client.userBootandconversations.inforesponses, not inferred from the code. Codex and a second Claude model (Fable 5.1) reviewed the diff; the unread-ordering race and the retry backoff came from those reviews, and the error-type split from Codex's second pass. I've read the diff and will defend it in review.🤖 Generated with Claude Code