Skip to content

fix(desktop): channel topic and membership metadata cleanup - #3642

Merged
delkc merged 4 commits into
mainfrom
claydelk/system-message-copy
Jul 30, 2026
Merged

fix(desktop): channel topic and membership metadata cleanup#3642
delkc merged 4 commits into
mainfrom
claydelk/system-message-copy

Conversation

@delkc

@delkc delkc commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

First slice of #2216, scoped to the system/status lines in the chat timeline.

Why

Three problems on the same surface.

Clearing a channel topic renders as empty quotes. The relay reports a clear as a topic_changed event carrying an empty string — there's no separate "cleared" event type. So the timeline printed:

Alice
changed the topic to “”

which reads as if the topic were set to two quote marks. Same for purpose.

The membership caption reads like a headline, not a metadata line. title and action render on separate lines — the member's name sits in the header row with the avatar and timestamp, and the caption sits beneath it. So the caption was "was added by Alice Chen" standing alone under a name, while its siblings on that same line are "joined the channel" and "left the channel".

"You" was capitalized two different ways on one row. An agent's metadata reads "managed by you" while the activity line directly under it read "added by You" — the same word, two capitalizations, a few pixels apart.

What

  • Blank, missing, or whitespace-only topic/purpose now reads "cleared the channel topic" / "cleared the channel purpose".
  • Membership captions drop "was": "added by Alice Chen", matching "joined the channel" and "left the channel". Rationale below.
  • Self-references inside a caption are lowercase: "added by you", "removed you from the channel", "along with you". The row title still says "You" — that's a name standing on its own next to the avatar, same as any other person's.
  • The wording moves to lib/systemEventCopy.ts as pure functions, so it's assertable in a unit test instead of only reachable through the DOM. That also removes two JSX fragments from SystemMessageRow.tsx.

Why "was" is gone rather than dynamic

Restoring "was" means agreeing with the subject, and the subject can be "You" — "You was added by Alice" is broken. label === "You" ? "were" : "was" is a one-line fix, so this was a judgment call, not a cost one. Three reasons it went the other way:

The verb would be agreeing with a subject on a different line. The row is flex-col: the name sits in the header with the avatar, the timestamp, a middot, and sometimes a "managed by" chip; the caption is a separate <p> beneath it. "was added by Alice Chen" is a sentence fragment reaching back across a metadata row for its subject. The UI isn't presenting it as a sentence, so it shouldn't depend on being read as one.

Agreement keys on user-controlled data, and fails loudly. The rule can only special-case the strings it knows. A display name like "Design Team" or any user-set plural reads wrong, and every future passive line ("blocked by", "invited by", "removed by") has to re-derive the rule. When capitalization is slightly off it's a polish nit; when agreement is off the user sees visibly broken grammar.

Consistent active voice isn't reachable without a design change. 10 of the 12 cases are already active. The 2 passive ones are exactly members_added and member_joined where actor ≠ target — and those rows are deliberately titled with the person who arrived, not the person who added them (it's why they get the dual avatar with the new member's face first). Self-joins in the same slot read active only because actor and target are the same person. So the passive voice is a consequence of the title choice, not a copy slip.

Removing the passive voice properly means re-titling those rows with the actor and going active — "Alice added Marcia to the channel, along with Peter, Jordan, and 2 others." That matches member_removed exactly and is what Slack does, but it changes whose avatar leads the row. Filed as a follow-up on #2216 rather than smuggled into a copy PR.

Two E2E assertions this exposed

Both were measuring something other than what they claimed, and the copy change tipped them over. Neither is a product bug, but both would have failed the next person too.

  1. mentions.spec.ts:1245 asserted a button was un-underlined while the mouse was still parked from a previous hover(). Any reflow — new rows, scroll-to-bottom, a different text wrap — can slide that button under the stationary pointer, so the assertion measured where the mouse happened to be rather than the resting style. Dropping four characters changed the text wrap, changed the row height, changed the scroll offset, and the pointer landed on it. Now parks the pointer off-target first.
  2. mentions.spec.ts:1253 used a bare role=tooltip lookup. Once the first tooltip animates out while the second opens, two elements match and strict mode trips. Now scopes to the open tooltip via :not([data-state="closed"]).

Deliberately out of scope

  • Timestamps. The day divider, per-message clock times, the Inbox thread pane, and the inbox list have three divergent date implementations and none fully match the writing standard's Today/Yesterday/weekday/date progression. That's its own slice of Desktop content polish (tracking) #2216.
  • Whose avatar and name lead the row. See the active-voice discussion above — it's a design question, not copy.
  • "Added by You" in the persona catalog. PersonaAddedBy.tsx has the same capitalization mismatch, but it's the agents feature rather than channel system messages.
  • the channel vs this channel. joined/left/removed say "the channel"; created/archived/unarchived say "this channel". Worth normalizing, but it touches lines this PR otherwise leaves alone.

Validation

  • pnpm check, pnpm typecheck — clean
  • Unit: 3784/3784, including 6 tests covering set/blank/undefined/null/whitespace for both fields, a guard that no variant can emit empty quotes, and 3 for the inline-name rule (including that "Youssef" and "You Know Who" are left alone)
  • CI green on all 20 checks, including all 4 Smoke E2E shards and both Desktop E2E Integration shards
  • The previously fragile assertions run locally with --repeat-each=5: 5/5

I could not produce a system row with the current user as actor in mock data, so the lowercase-"you" path is covered by unit tests and typecheck rather than a screenshot.

Clearing a channel topic arrives as a topic_changed event carrying an
empty string, not as its own event type, so the timeline rendered
`changed the topic to ""` — which reads as if the topic were set to two
quote marks. Same for purpose. Both now read "cleared the channel topic"
/ "cleared the channel purpose", and whitespace-only values count as
cleared for the same reason.

The wording moves to lib/systemEventCopy.ts as a pure function so it can
be asserted directly; that also drops two JSX fragments from
SystemMessageRow, which shrinks 911 -> 900 lines.

Membership captions lose "was": the caption renders on its own line under
the member's name, so "added by Alice" reads as the metadata line it is
and matches its siblings "joined the channel" and "left the channel".

Two E2E assertions in mentions.spec.ts were measuring the wrong thing and
this copy change exposed both:

- The un-hovered style check ran with the mouse still parked from the
  previous hover. Any reflow — new rows, scroll-to-bottom, a different
  text wrap — could slide the button under that fixed pointer, so it
  asserted where the mouse happened to be rather than the resting style.
  It now parks the pointer off-target first.
- A bare role=tooltip lookup matched two elements once the first tooltip
  began animating out while the second opened. It now scopes to the open
  tooltip.

Part of #2216.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Clay Delk <clay.delk@gmail.com>
@delkc
delkc requested a review from a team as a code owner July 29, 2026 23:19
@delkc delkc mentioned this pull request Jul 30, 2026
6 tasks
@delkc

delkc commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

This also fixes that You was added bug.
Screenshot 2026-07-23 at 9 34 54 AM

@delkc delkc changed the title fix(desktop): say when a channel topic or purpose is cleared fix(desktop): channel topic and membership metadata cleanup Jul 30, 2026
@delkc
delkc enabled auto-merge (squash) July 30, 2026 13:18
An agent's metadata reads "managed by you" while the activity line right
under it read "added by You" — the same word, two capitalizations, on the
same row.

`resolveUserLabel` returns "You", which is right where it stands alone as
a name (the row title) and wrong once it lands mid-phrase. `toInlineName`
adjusts it for the sentence positions: "added by you", "removed you from
the channel", "along with you". Every other name is a proper noun and is
returned untouched, matched exactly so "Youssef" is unaffected.

This is the rule `formatOwnerLabel` already applies from the other side —
it returns lowercase because it is only ever read as "managed by you".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Clay Delk <clay.delk@gmail.com>
@tellaho

tellaho commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

🤖 Nice, focused improvement overall. The pure copy extraction is a good shape, the empty-topic guard is well tested, and the two E2E adjustments address the underlying tooltip flake mechanics cleanly.

One blocking fix

Could we make the inline self-label depend on identity rather than the resolved display string? As written, toInlineName(label === "You") would also lowercase another user whose display name is literally You. Comparing the relevant pubkey with currentPubkey using the existing normalization pattern would keep the behavior precise; the pure helper could take an isSelf boolean if useful.

Non-blocking follow-ups

  • Mobile still shows the empty-topic and was added by variants, so tracking desktop/mobile copy parity on Desktop content polish (tracking) #2216 would be worthwhile.
  • The copy varies between topic / channel topic and the channel / this channel; those nouns could be normalized in a follow-up.
  • An earlier E2E assertion still uses an unscoped getByRole("tooltip"). It is safe with the current flow, but may be worth tightening if that test gains another hover.

With the identity-based self-label fix, this looks good to me.

delkc and others added 2 commits July 30, 2026 13:15
…string

Review catch on #3642: `toInlineName` compared the resolved label against
"You", so a different person whose display name is literally "You" would
have been rewritten as if they were the reader. The label is user-controlled;
identity is not. The helper now takes an `isSelf` boolean the caller derives
by comparing pubkeys through the existing `normalizePubkey` path.

Also normalizes the noun: "cleared the channel topic" against "changed the
topic to …" used two different nouns for the same field. Both now say "the
topic" / "the purpose". This row only renders in a channel timeline, under
that channel's own header, so naming the channel again is redundant.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Clay Delk <clay.delk@gmail.com>
Two of the three desktop fixes in this PR apply to the Flutter timeline.

**Cleared topic rendered as empty quotes.** `SystemEvent.describe` printed
`changed the topic to ""` when the relay reported a clear, same as desktop
did — a clear arrives as a `topic_changed` event carrying an empty string,
not as its own event type. Now reads "cleared the topic" / "cleared the
purpose", and the value is trimmed before it goes inside the quotes.

**"was added by" in the two-line membership layout.** Every `member_joined`
event renders through `_MembershipSystemMessageContent`, which puts the
member's name and timestamp on one line via `MessageAuthorMeta` and the
caption beneath — the same structure as desktop's `SystemMessageRow`. So the
caption drops "was" for the same reason.

`SystemEvent.describe` keeps "$target was added by $actor". That path builds
subject and predicate into a single string, so dropping "was" there would
read "Bob added by Alice". It is also only a fallback: the membership layout
handles every well-formed member_joined event.

Left alone: mobile's date formatters have the same divergence desktop just
resolved (#3769) — `formatDayHeading` has no weekday band and always prints
the year, and `formatThreadSummaryLastReplyTime` still emits ordinals like
"on May 19th". Tracked separately rather than folded into a copy change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Clay Delk <clay.delk@gmail.com>
@delkc
delkc merged commit 9e8fcfd into main Jul 30, 2026
68 of 72 checks passed
@delkc
delkc deleted the claydelk/system-message-copy branch July 30, 2026 22:53
brow pushed a commit that referenced this pull request Jul 31, 2026
* origin/main: (70 commits)
  fix(catalog): update Amp tagline (#3806)
  fix(desktop): channel topic and membership metadata cleanup (#3642)
  fix(desktop): align data deletion labels (#2230)
  fix(relay): align NIP-11 max_limit with REQ ceiling (#3635)
  fix(desktop): allow linux-only media items as dead code off-linux (#3811)
  fix(desktop): report authenticated relay recovery (#3812)
  fix(desktop): don't gate hover affordances on the hover media query (#3657)
  feat(relay): gate kind 30178 team-catalog reads behind the shared tag (#3358)
  test(desktop): click visible thread collapse guide (#3800)
  feat(desktop): raise the install ceiling and make installs observable (#3368)
  fix(db): isolate usage metrics advisory-lock test on scratch DB (#3670)
  Add Devin as a preset ACP harness (#3225)
  feat(desktop): improve agent activity header ui (#3321)
  perf(presence): reduce heartbeat frequency (#3783)
  Tighten continuation message rows (#3724)
  Fix video reviews in thread replies (#3719)
  feat(release): make desktop releases immutable (#3568)
  Make relay reconnect backoff authoritative (#3774)
  feat(desktop): add password-protected backups in settings (#3701)
  fix(desktop): reuse profiles when joining communities (#2155)
  ...

Signed-off-by: npub15w828kxsxu2684ynste0uah2jwkgatd99flt7ds4523hzm8ju6cshdr8hh <a38ea3d8d03715a3d49382f2fe76ea93ac8eada52a7ebf3615a2a3716cf2e6b1@buzz.block.builderlab.xyz>
wpfleger96 pushed a commit that referenced this pull request Jul 31, 2026
…evert-fix

* origin/main:
  fix(desktop): open profiles from avatars (#3751)
  refactor(voice): extract reusable Pocket primitives + Pocket voice settings (relands #2467 + #3208) (#3910)
  docs: add VISION_REMOTE_AGENTS.md (#3924)
  feat(desktop): auto-enable huddle transcription for agents (#3180)
  feat(agent): optional reply guard reminds a silent turn to publish (#3763)
  feat(desktop): upgrade Pocket TTS model (#3266)
  feat(desktop): delete a message by clearing its edit to empty (#3813)
  feat(relay): raise hosted community limit to five (#3829)
  feat(desktop): locally stored NIP-49 encrypted key backup (#2937)
  fix(catalog): update Amp tagline (#3806)
  fix(desktop): channel topic and membership metadata cleanup (#3642)
  fix(desktop): align data deletion labels (#2230)
  fix(relay): align NIP-11 max_limit with REQ ceiling (#3635)

Signed-off-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
wpfleger96 pushed a commit that referenced this pull request Jul 31, 2026
…chive

* origin/main: (25 commits)
  feat(desktop): import local Pocket voices (#3259)
  fix(desktop): open profiles from avatars (#3751)
  refactor(voice): extract reusable Pocket primitives + Pocket voice settings (relands #2467 + #3208) (#3910)
  docs: add VISION_REMOTE_AGENTS.md (#3924)
  feat(desktop): auto-enable huddle transcription for agents (#3180)
  feat(agent): optional reply guard reminds a silent turn to publish (#3763)
  feat(desktop): upgrade Pocket TTS model (#3266)
  feat(desktop): delete a message by clearing its edit to empty (#3813)
  feat(relay): raise hosted community limit to five (#3829)
  feat(desktop): locally stored NIP-49 encrypted key backup (#2937)
  fix(catalog): update Amp tagline (#3806)
  fix(desktop): channel topic and membership metadata cleanup (#3642)
  fix(desktop): align data deletion labels (#2230)
  fix(relay): align NIP-11 max_limit with REQ ceiling (#3635)
  fix(desktop): allow linux-only media items as dead code off-linux (#3811)
  fix(desktop): report authenticated relay recovery (#3812)
  fix(desktop): don't gate hover affordances on the hover media query (#3657)
  feat(relay): gate kind 30178 team-catalog reads behind the shared tag (#3358)
  test(desktop): click visible thread collapse guide (#3800)
  feat(desktop): raise the install ceiling and make installs observable (#3368)
  ...

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>

# Conflicts:
#	desktop/src/testing/e2eBridge.ts
#	desktop/tests/helpers/bridge.ts
wpfleger96 pushed a commit that referenced this pull request Jul 31, 2026
…chive

* origin/main: (25 commits)
  feat(desktop): import local Pocket voices (#3259)
  fix(desktop): open profiles from avatars (#3751)
  refactor(voice): extract reusable Pocket primitives + Pocket voice settings (relands #2467 + #3208) (#3910)
  docs: add VISION_REMOTE_AGENTS.md (#3924)
  feat(desktop): auto-enable huddle transcription for agents (#3180)
  feat(agent): optional reply guard reminds a silent turn to publish (#3763)
  feat(desktop): upgrade Pocket TTS model (#3266)
  feat(desktop): delete a message by clearing its edit to empty (#3813)
  feat(relay): raise hosted community limit to five (#3829)
  feat(desktop): locally stored NIP-49 encrypted key backup (#2937)
  fix(catalog): update Amp tagline (#3806)
  fix(desktop): channel topic and membership metadata cleanup (#3642)
  fix(desktop): align data deletion labels (#2230)
  fix(relay): align NIP-11 max_limit with REQ ceiling (#3635)
  fix(desktop): allow linux-only media items as dead code off-linux (#3811)
  fix(desktop): report authenticated relay recovery (#3812)
  fix(desktop): don't gate hover affordances on the hover media query (#3657)
  feat(relay): gate kind 30178 team-catalog reads behind the shared tag (#3358)
  test(desktop): click visible thread collapse guide (#3800)
  feat(desktop): raise the install ceiling and make installs observable (#3368)
  ...

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>

# Conflicts:
#	desktop/src/testing/e2eBridge.ts
#	desktop/tests/helpers/bridge.ts
joahg added a commit to joahg/buzz-dev-mode that referenced this pull request Jul 31, 2026
…-style

* origin/main: (22 commits)
  feat(desktop): import local Pocket voices (block#3259)
  fix(desktop): open profiles from avatars (block#3751)
  refactor(voice): extract reusable Pocket primitives + Pocket voice settings (relands block#2467 + block#3208) (block#3910)
  docs: add VISION_REMOTE_AGENTS.md (block#3924)
  feat(desktop): auto-enable huddle transcription for agents (block#3180)
  feat(agent): optional reply guard reminds a silent turn to publish (block#3763)
  feat(desktop): upgrade Pocket TTS model (block#3266)
  feat(desktop): delete a message by clearing its edit to empty (block#3813)
  feat(relay): raise hosted community limit to five (block#3829)
  feat(desktop): locally stored NIP-49 encrypted key backup (block#2937)
  fix(catalog): update Amp tagline (block#3806)
  fix(desktop): channel topic and membership metadata cleanup (block#3642)
  fix(desktop): align data deletion labels (block#2230)
  fix(relay): align NIP-11 max_limit with REQ ceiling (block#3635)
  fix(desktop): allow linux-only media items as dead code off-linux (block#3811)
  fix(desktop): report authenticated relay recovery (block#3812)
  fix(desktop): don't gate hover affordances on the hover media query (block#3657)
  feat(relay): gate kind 30178 team-catalog reads behind the shared tag (block#3358)
  test(desktop): click visible thread collapse guide (block#3800)
  feat(desktop): raise the install ceiling and make installs observable (block#3368)
  ...

Amp-Thread-ID: https://ampcode.com/threads/T-019fb8e1-6ece-72a7-8808-9b12e0f7e833
Co-authored-by: Amp <amp@ampcode.com>
Signed-off-by: Joah Gerstenberg <joah@squareup.com>

# Conflicts:
#	desktop/src-tauri/src/linux_media.rs
#	desktop/src/app/AppShell.tsx
wpfleger96 pushed a commit that referenced this pull request Jul 31, 2026
…-phase2-integration

* origin/main: (38 commits)
  fix(release): preserve main in desktop PR body (#3979)
  chore(release): release Buzz Desktop version 0.5.3 (#3972)
  fix(release): require exact-head approval for desktop tags (#3973)
  fix(release): make desktop tagging squash-safe (#3965)
  Revert "chore(release): release Buzz Desktop version 0.5.3" (#3960)
  docs(nips): add single-coordinate manual-unread override layer and verification model to NIP-RS (#2864)
  chore(release): release Buzz Desktop version 0.5.3
  fix(release): make immutable desktop release operable (#3943)
  feat(desktop): import local Pocket voices (#3259)
  fix(desktop): open profiles from avatars (#3751)
  refactor(voice): extract reusable Pocket primitives + Pocket voice settings (relands #2467 + #3208) (#3910)
  docs: add VISION_REMOTE_AGENTS.md (#3924)
  feat(desktop): auto-enable huddle transcription for agents (#3180)
  feat(agent): optional reply guard reminds a silent turn to publish (#3763)
  feat(desktop): upgrade Pocket TTS model (#3266)
  feat(desktop): delete a message by clearing its edit to empty (#3813)
  feat(relay): raise hosted community limit to five (#3829)
  feat(desktop): locally stored NIP-49 encrypted key backup (#2937)
  fix(catalog): update Amp tagline (#3806)
  fix(desktop): channel topic and membership metadata cleanup (#3642)
  ...

Signed-off-by: npub1g8493u0xfsjrvflg4n08ezd7vec99mnwzlv0qgwpr9d7gvjwhuzqx59rhw <41ea58f1e64c243627e8acde7c89be667052ee6e17d8f021c1195be4324ebf04@buzz.block.builderlab.xyz>
calvadev pushed a commit to shopstr-eng/buzz that referenced this pull request Aug 3, 2026
First slice of block#2216, scoped to the system/status lines in the chat
timeline.

## Why

Two problems on the same surface.

**Clearing a channel topic renders as empty quotes.** The relay reports
a clear as a `topic_changed` event carrying an empty string — there's no
separate "cleared" event type. So the timeline printed:

> Alice
> changed the topic to “”

which reads as if the topic were *set to* two quote marks. Same for
purpose.

**The membership caption reads like a headline, not a metadata line.**
`title` and `action` render on separate lines — the member's name sits
in the header row with the avatar and timestamp, and the caption sits
beneath it. So the caption was "was added by Alice Chen" standing alone
under a name, while its siblings on that same line are "joined the
channel" and "left the channel".

## What

- Blank, missing, or whitespace-only topic/purpose now reads **"cleared
the channel topic"** / **"cleared the channel purpose"**.
- Membership captions drop "was": **"added by Alice Chen"**, matching
"joined the channel" and "left the channel".
- The wording moves to `lib/systemEventCopy.ts` as a pure function, so
it's assertable in a unit test instead of only reachable through the
DOM. That also removes two JSX fragments from `SystemMessageRow.tsx`,
taking it 911 → 900 lines.

## Two E2E assertions this exposed

Both were measuring something other than what they claimed, and the copy
change tipped them over. Neither is a product bug, but both would have
failed the next person too.

1. **`mentions.spec.ts:1245`** asserted a button was un-underlined while
the mouse was still parked from a previous `hover()`. Any reflow — new
rows, scroll-to-bottom, a different text wrap — can slide that button
under the stationary pointer, so the assertion measured *where the mouse
happened to be* rather than the resting style. Dropping four characters
changed the text wrap, changed the row height, changed the scroll
offset, and the pointer landed on it. Now parks the pointer off-target
first.
2. **`mentions.spec.ts:1253`** used a bare `role=tooltip` lookup. Once
the first tooltip animates out while the second opens, two elements
match and strict mode trips. Now scopes to the open tooltip via
`:not([data-state="closed"])`.

## Deliberately out of scope

- **Timestamps.** The day divider, per-message clock times, the Inbox
thread pane, and the inbox list have three divergent date
implementations and none fully match the writing standard's
Today/Yesterday/weekday/date progression. That's its own slice of block#2216.
- **Whose avatar shows.** An addition puts the *added* member in the
header; a removal puts the *remover* there. Possibly intentional, but
it's a design question, not copy.
- **`the channel` vs `this channel`.** joined/left/removed say "the
channel"; created/archived/unarchived say "this channel". Worth
normalizing, but it touches lines this PR otherwise leaves alone.

## Validation

- `pnpm check`, `pnpm typecheck` — clean
- Unit: **3781/3781**, including 6 new tests in
`systemEventCopy.test.mjs` covering set/blank/undefined/null/whitespace
for both fields, plus a guard that no variant can emit empty quotes
- Smoke E2E `mentions` + `messaging`: **85/85**
- The previously fragile test run with `--repeat-each=5`: **5/5**

Signed-off-by: Clay Delk <clay.delk@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
delkc added a commit that referenced this pull request Aug 14, 2026
)

Slice 6 of #2216. Independent of #3642 — cut from `main`, no shared
files in conflict.

## Why

Five surfaces formatted the same thing five ways, and none of them
matched the writing standard's Today / Yesterday / weekday / date
progression.

| Surface | Before |
|---|---|
| Chat day divider | `Monday, March 31st` — ordinal suffix, which the
standard says to avoid |
| Inbox section header | `Yesterday`, but never `Today`; always printed
the year |
| Inbox list row | A third implementation |
| Inbox thread pane header | `Jul 8, 2026, 2:34 PM` — always absolute,
always with the year, never relative at any distance |
| Channel message header | `9:05 AM` — a bare clock, so a message from
last week has nothing to anchor it once its day divider scrolls away |

There were three separate date implementations doing this, which is the
symptom worth naming: **two different jobs were being solved ad hoc at
each call site.** A header that labels a *group* of items needs a
different label than an individual item's own timestamp.

## What

`shared/lib/datetime.ts` owns both ladders:

```
formatDayGroupLabel          formatItemTimestamp
(day divider, section header) (list row, message header)

Today       → Today           withTime:false   withTime:true
Yesterday   → Yesterday       2:34 PM          2:34 PM
2–6 days    → Monday          Yesterday        Yesterday at 2:34 PM
this year   → June 20         Monday           Monday at 2:34 PM
older       → June 20, 2025   Jun 20           Jun 20 at 2:34 PM
                              Jun 20, 2025     Jun 20, 2025 at 2:34 PM
```

## Two deliberate deviations from the standard

Both are documented at the definition, not just here.

**The oldest band keeps the day.** The standard collapses anything over
ten months to month-and-year (`Aug 2022`). A group label has to
*identify* its day — collapsing would give every day in a month the same
divider, so scrolling old history would show a run of identical headers
with no way to tell one day from the next. Only the year is conditional.
There's a test asserting three consecutive 2022 dates produce three
distinct labels.

**Roomy surfaces keep the time of day at every band.** `Yesterday at
9:05 AM`, not `Yesterday`. This is a chat and collaboration workspace
rather than a transactional product — where you read conversation, the
time is content, not chrome. Narrow list rows still drop it (`withTime:
false`) and rely on the existing hover tooltip, which stays the absolute
value. `withTime` is a surface decision, not a preference.

Today needs no date word in either mode: a bare clock already reads as
today, and "Today at 2:34 PM" is longer without saying more.

## Derived rather than captured

`MessageTimestamp` now takes only `createdAt` and derives both of its
labels, instead of receiving a pre-formatted `time` string. A relative
label captured when the message list was formatted would be frozen at
that wording; deriving it means each render recomputes.

This does not make it live — `MessageRow` is memoized, so a row already
on screen when the clock passes midnight keeps saying "Today" until
something re-renders it. The day divider above it has always had the
same property, and both correct themselves on the next message, scroll,
or navigation. Called out in the component doc so the next person
doesn't read "derived" as "reactive".

The memo comparator moved from `message.time` to `message.createdAt`.
Behavior-identical — `time` was a pure function of `createdAt` — but it
now names the prop the row actually reads.

The 36px continuation hover gutter stays clock-only. A relative label
doesn't fit in `w-9`.

## Middot between metadata segments

`managed by you 9:53 AM` ran two unrelated facts together as if they
were one phrase. Now `managed by you · 9:53 AM`.

- `aria-hidden` — punctuation for the eye only. The header already reads
as separate nodes to a screen reader, and `MessageAgentOwner` supplies
its own "Agent managed by" label.
- Grouped with the segment it precedes, so it can't wrap to the start of
a line on its own — as loose siblings in a `flex-wrap` row, an orphaned
divider is exactly what happens.
- No margin; spacing comes from the container gap.
- **No separator after the author name.** "Alice 9:53 AM" already reads
as a name followed by a time. Dividers go between metadata segments
only.

Middot is already the app's separator for this —
`MessageThreadSummaryRow`, the mention list, project rows, 46 files in
total.

Applied to the channel message header, channel system rows, and the
Inbox thread pane. Left-side Inbox activity rows deliberately unchanged.

## Verified

Screenshots taken through `just desktop-screenshot`:

- `#agents` — `nadia 🤖 managed by you · 10:20 AM`, and the `Today`
divider with clock-only rows
- Inbox thread pane — `alice 🤖 owner unavailable · 12:00 PM`

**Gap worth naming:** every mock channel message is same-day, so the
past-day labels (`Yesterday at 9:05 AM`, `Jun 20 at 2:34 PM`) are
covered by unit tests rather than by a rendered screenshot. Happy to add
a spec that seeds an older `created_at` if a reviewer wants to see them.

## Validation

- `pnpm check`, `pnpm typecheck` — clean
- Unit: **3800/3800**, including 17 new tests in
`shared/lib/datetime.test.mjs` and 4 in
`messageTimestampContract.test.mjs`

The datetime tests pin the things that are easy to regress:
Today/Yesterday as *calendar* boundaries rather than 24-hour windows (a
message 15 hours old across midnight is "Yesterday"; one 22 hours old on
the same day is "Today"), the weekday band bounded at both ends so a
future timestamp from clock skew never gets labelled with a past
weekday, no ordinals across all the tricky days
(1/2/3/11/12/13/21/22/23/31), the year omitted within the current year,
and compact labels staying ≤12 chars for a narrow row.

- Smoke E2E: **783 passed, 2 failed, 1 skipped**

Both failures are pre-existing and unrelated, confirmed by re-running
each against a clean tree:

1. `video-attachment.spec.ts:223` — fails deterministically on clean
`main`
2. `community-rail.spec.ts:797` (keyboard drag-and-drop reorder) — flaky
on clean `main`: 2/5 failures there vs 3/5 with this branch, i.e. noise

## Mobile

Mobile had the same divergence, so it moves with desktop rather than
drifting until the next pass.
`mobile/lib/features/channels/date_formatters.dart`:

| Before | After |
|---|---|
| `formatDayHeading` → Today / Yesterday / `Tuesday, March 31, 2026` |
Today / Yesterday / `Tuesday` / `March 31` / `March 31, 2025` |
| `formatThreadSummaryLastReplyTime` → `on May 19th` | `on May 19` |

Same two departures from the standard as desktop, documented at the
definition and cross-referenced to `datetime.ts` so the next person
editing one finds the other. Day comparison also moved to a rounded
start-of-day difference, so a DST transition counts as one calendar day
rather than zero — Dart's `Duration.inDays` truncates.

**Message timestamps stay clock-only on mobile.** Desktop message
headers now read `Yesterday at 9:05 AM`; mobile keeps `9:05 AM` at every
band. That's the compact side of the same surface split the desktop
change makes — a mobile timestamp sits inside a chat bubble on a narrow
screen with the day divider a short scroll away, where a date word costs
width it doesn't earn. Recorded as a decision at `formatMessageTime` so
it doesn't read as an oversight.

Mobile needs no middot work: message headers have no "managed by"
segment, and the mention suggestion list already uses `\u00b7`.

Validation: `dart format` clean, `flutter analyze` no issues, `flutter
test` **911 passed, 1 skipped** — 8 new day-heading tests covering the
weekday band, the year boundary, ordinals across
1/2/3/11/12/13/21/22/23/31, distinct labels for consecutive days in the
oldest band, and calendar-day rather than 24-hour bands.

## Out of scope

- **Search results.** `SearchResultItem.tsx` and `TopbarSearch.tsx`
hand-roll a `5m ago` elapsed format. That's a third *kind* of label —
elapsed rather than relative-calendar — and deciding whether search
should switch is a separate call.
- **`formatThreadSummaryLastReplyTime`** keeps its own "3 hours ago"
elapsed scale on both platforms; only its old-reply fallback lost the
ordinal (`on May 19th` → `on May 19`).
- **Mobile search.** `relativeTime` returns `7/31/2026` past a week,
matching the desktop search format that's also out of scope above. Both
should change together or not at all.

---------

Signed-off-by: Clay Delk <clay.delk@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants