Skip to content

fix(ui): stop rendering the notification fallback text beside its blocks - #209

Merged
gammons merged 2 commits into
gammons:mainfrom
rfist:fix/blockkit-text-fallback-duplication
Sep 16, 2026
Merged

gammons merged 2 commits into
gammons:mainfrom
rfist:fix/blockkit-text-fallback-duplication

Conversation

@rfist

@rfist rfist commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Problem

Bot messages that post Block Kit blocks render twice. Slack defines text as a notification fallback whenever blocks is present: it is what shows in a desktop notification or in a client that cannot render blocks, not a second copy of the body. slk draws the blocks, then also draws msg.Text as the body row, so a section-block digest prints once as blocks and once as its fallback string. It is very visible on any bot that posts a formatted digest.

Fix

A new blockkit.RendersBody(blocks) (internal/ui/messages/blockkit/render.go:43) reports whether the blocks carry the message's own content, and messages.BlocksCarryBody(msg) (internal/ui/messages/model.go:599) wraps it for the renderers. When it is true, renderMessagePlain (internal/ui/messages/model.go:1949) and renderThreadMessage (internal/ui/thread/model.go:1831) draw no body row at all (not an empty one), and leave that row out of the arithmetic that places sixel images and reaction hit rects (internal/ui/messages/model.go:2178, internal/ui/thread/model.go:2075).

MessageTextSource (internal/ui/messages/model.go:583) is deliberately unchanged. It is also what y copies (copyMessageOfSelected, internal/ui/app.go:1278), so suppressing the text there would make copying a bot message report "Message has no text". Hiding the fallback is a rendering decision, so it lives in the renderers.

Some blocks deliberately do not count:

  • RichTextBlock, because the host renders it through the body row. appendBlock skips it for exactly that reason, so a rich_text body keeps its row even with a section block beside it.
  • UnknownBlock, because all slk can draw for it is an "unsupported" marker. Slack's rule says the fallback is redundant; here it is the only readable thing left, so it is kept.
  • A lone DividerBlock, because a rule with no text beside it is not what the author wrote.

Each case also checks that the block actually has content. An empty section is still a section, and suppressing the fallback against one would render the message blank, which is strictly worse than rendering it twice.

RendersBody must agree with the appendBlock switch beside it (internal/ui/messages/blockkit/render.go:74); a new content-bearing case belongs in both, and the function comment states that invariant.

What this drops

Summary-only fallbacks. If a bot sends blocks that render and a text that says something the blocks do not, that text no longer appears on screen. That is the intended trade: per Slack's own contract that field is a notification string, and the alternative is printing every such message twice. Copying the message with y still yields it.

Golden snapshots

The deploybot fixture in golden_test.go is exactly this case (Text: "build #421 green" plus a section block), so all eight internal/ui/testdata/golden/*.ansi files are regenerated. TestGolden reports "rendered text differs" because the build #421 green row is gone and everything below it in that pane moves up one row. I checked the regenerated files with the escapes stripped: the fallback string appears in none of them, and the row under the deploybot header is the section block in every scenario.

Tests

internal/ui/messages/blockkit/rendersbody_test.go covers the predicate directly: TestRendersBody_ContentBearingBlocks, TestRendersBody_NonBodyBlocks, TestRendersBody_RichTextDoesNotCount.

internal/ui/messages/blockkit_integration_test.go:

  • New: TestBlocksCarryBody_ContentBlocks, TestBlocksCarryBody_KeepsBodyRow (no blocks, empty section, empty header, divider only, unknown only) and TestBlocksCarryBody_RichTextBodyKeepsBodyRow.
  • New: TestBuildCache_BlocksCarryBodyReactionHitRow pins the row arithmetic: the reaction hit rect lands on the reaction line.
  • Changed: TestRenderMessagePlainEmitsBlockKitContent used to assert the fallback is drawn; it now asserts it is not, and that the row under the header is the block content rather than an empty body row.
  • TestMessageTextSource_NonRichTextBlocksReturnRawText keeps its assertion. Only its comment changes, to say that whether msg.Text gets a body row is now BlocksCarryBody's call.

internal/ui/thread/render_test.go: TestRenderThreadMessageBlocksCarryBody covers the thread pane (fallback not drawn, no empty row, reaction hit on the reaction line).

internal/ui/copy_message_test.go: TestCopyMessage_BlockKitBodyStillCopiesText.

Test plan

Each part of the fix was reverted on its own to confirm a test catches it:

  • Drawing the body row anyway in the messages pane fails TestRenderMessagePlainEmitsBlockKitContent and TestBuildCache_BlocksCarryBodyReactionHitRow.
  • Hiding the row but still counting it in the messages row arithmetic fails TestBuildCache_BlocksCarryBodyReactionHitRow.
  • Drawing the body row anyway in the thread pane fails TestRenderThreadMessageBlocksCarryBody.
  • Hiding the row but still counting it in the thread row arithmetic fails TestRenderThreadMessageBlocksCarryBody (reaction hit row = 3, want 2).
  • Suppressing the text in MessageTextSource instead fails TestCopyMessage_BlockKitBodyStillCopiesText and TestMessageTextSource_NonRichTextBlocksReturnRawText.
  • Making BlocksCarryBody always false (the original duplication) fails TestBlocksCarryBody_ContentBlocks, TestRenderMessagePlainEmitsBlockKitContent and TestRenderThreadMessageBlocksCarryBody.

Also: gofmt -l . clean, go build -ldflags="-s -w" -trimpath ./..., go vet ./..., go test -count=1 ./..., go test -race ./..., golangci-lint run ./... (v2.13.1, 0 issues). git diff --check is clean outside the golden .ansi files, where trailing whitespace is part of the terminal snapshot (the commit that added them trips it the same way). Rebased onto current main.

@gammons

gammons commented Sep 16, 2026

Copy link
Copy Markdown
Owner

Verified. Slack defines text as the notification fallback when blocks is present — it's what shows in a client that can't render blocks, not a second copy of the body. Rendering both is the bug, and it's very visible on any bot that posts a formatted digest (which is most of them).

RendersBody is the right shape: only blocks that actually carry the author's content suppress the fallback, and DividerBlock and empty blocks deliberately don't, so a message that is blocks-that-don't-carry-body still shows its fallback instead of going blank. The note to keep the switch aligned with appendBlock is the right instinct — those two must agree on which types exist, and the comment says so.

Both renderers (renderMessagePlain and renderThreadMessage) use the same BlocksCarryBody wrapper, so the main pane and the thread panel stay consistent.

Build, vet, gofmt clean; 55 packages green under -race.

Merging.

@gammons gammons added the ready to merge Reviewed, approved, no blockers label Sep 16, 2026
Conflict in AGENTS.md: both branches appended rows to the shared-code
helpers table at the same position — this branch's RendersBody/
BlocksCarryBody row and main's DND/peerstatus rows (from gammons#219). Union:
keep all three.
@gammons
gammons merged commit 57ca81b into gammons:main Sep 16, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready to merge Reviewed, approved, no blockers

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants