Skip to content

Channel switcher, unread dots, and one create dialog - #91

Merged
david-sling merged 16 commits into
mainfrom
channel-store
Sep 28, 2026
Merged

david-sling merged 16 commits into
mainfrom
channel-store

Conversation

@david-sling

Copy link
Copy Markdown
Owner

Closes #66. Closes #67, #68, #69, #70, #71, #72, #73, #74.

Every channel this browser has opened, listed beside the one you're in, with unread dots. Kept in localStorage and nowhere else.

The switcher

  • Store (lib/visited-channels.ts): one key, pure functions. It prunes expired rooms on read, caps the list at 20, and its parser never throws on bad data.
  • Saved and forgotten with the channel's lifecycle (use-visited-channels.ts): a room is saved when bootstrap succeeds, and forgotten on 410 and on close. React reads it through useSyncExternalStore, with a same-tab event, because storage only fires in other tabs.
  • Pane in a /c layout: switching swaps only the conversation and the room column; the pane stays mounted. Each row is one line: # and the name. Rooms are ordered by latest activity, like a chat app, so switching never reorders. Forget has a 5-second Undo.
  • /c page: the list alone on desktop; the whole page on a phone. On a phone a channel's top bar has a back button to /c where the logo was. Every other page shows a floating "Your channels" button once this browser has a live room.
  • key={id} on the channel view. Reset channel state on switch #69's bug turned out not to reproduce, because the router already remounts, but the key makes it explicit.

Unread

  • last_message_seq (last_message_seq on append #71): set inside the append's MULTI, so a join or timeout isn't news. The new key is in channelKeys, so close, sweep and TTL all reach it.
  • GET /channels/:id/head (Channel head probe endpoint #72): reads two counters, writes nothing. No sweep, no presence, no receipt. It has its own rate-limit bucket (6 a minute per channel per caller) and is in the isolation suite.
  • Heads from open tabs (Unread from tabs already open #73): each tab publishes wave.head.<id> after every poll, and other tabs compare it with the read mark. No requests. There are three states (unread, read, unknown), and only unread is drawn.
  • Background probe (Background head probe #74): visible tabs only, one round every 90s ± 15s, at most 3 rooms a round, stalest first. It skips any room another tab confirmed in the last 60s. Measured figures are on Background head probe #74. At the cap it adds about 2 probes a minute beside ~1.2 long polls, and a probe is 4 Redis commands.

Fixes found along the way

  • A hidden tab marked heartbeat deliveries as read, so a room could fill with unseen messages and never show a dot.
  • Your own message, sent while scrolled up, lit a dot in your other tabs. The published head is now the latest message from anyone else.
  • React's form reset after an action put a controlled radio back to 24h while state held 1h, so a retry after an error sent the wrong expiry. Both create forms now submit via onSubmit.

Create dialog

The hero's settings link and every New channel button on a page without the form open one dialog. It has the name, 1h | 24h | 7d as the prompt box's choice groups, "up to N people", and an Encrypted switch (disabled until E2EE ships). It creates the channel directly: no Done step. The nav and agents-section buttons still scroll to #create, since every page they appear on has the form.

Testing

  • 709 unit tests pass; tsc and lint are clean.
  • New tests cover the store, the subscription, unreadState, the probe planner and loop (including "hidden tab makes no request"), /head (410/401/429, no writes, presence untouched), last_message_seq, and the isolation cases for /head.
  • Each change was checked on the dev server: two-tab unread, switching, forget and Undo, phone layouts, and creating from the dialog.

Notes for review

  • This touches use-read-marker.ts, as does Draw the unread line on load #90, so the second one to merge will have a small conflict there.
  • Known gap: /head counts your own messages; a tab's published head doesn't. A room you posted in while scrolled up, then closed, can show a dot for your own message until you open it.
  • Open decisions: whether forget should also clear that room's participant and admin tokens; whether the pane should start at lg or xl; and the PLAN.md line Channel switcher #66 asks for.

🤖 Generated with Claude Code

david-sling and others added 14 commits September 29, 2026 01:10
Closes #67.

The list of channels this browser has opened, under one localStorage key,
as pure functions over a list and a clock: record upserts, orders by last
seen, prunes expired entries and caps at 20; live is the read filter, so an
expired room is gone without a request; parse drops whatever it cannot
read and never throws.

expiresAt is epoch ms, not seconds as the issue sketched: the client gets
expires_at as an ISO string, and one unit for both timestamps and the clock
they are compared against leaves no conversion to get wrong.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Closes #68.

A channel enters the list when bootstrap succeeds, the one point where it
is known to be real and this browser holds an invite that opens it, and
leaves it on either 410 and on close. A link that does not open never
enters it.

React reads it through useSyncExternalStore. storage only fires in other
tabs, so a write also dispatches its own event for the tab that made it.
The snapshot is cached by the raw stored string, and the server snapshot
is one constant empty array, so neither side re-renders in a loop.

Checked in two tabs against the dev server: opening adds, reloading moves
lastSeenAt without duplicating, closing drops it in both tabs, and the
closed room's link does not add it back. Private browsing is covered by
the unit tests with a throwing localStorage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Closes #69.

The bug #69 describes does not happen today. Checked against the dev
server with router.push and history.back between two channels: the App
Router already remounts the page when the id segment changes, so the
first poll after a switch asks after=0, the transcript and roster are the
new channel's alone, and the hash is committed before recall() reads it.

The key makes that reset explicit rather than a property of how the
router caches segments, so it survives the view moving under a shared
layout, which is where a channels pane would pull it.

The unread line could not be checked across a switch, because it is not
drawn on a hard load either: the head is stored in the same commit that
sets ready, before the marker reads it back. That is filed separately.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Closes #70.

Every live channel this browser has opened, as one line each: a bold #
and the name. The current room is marked and inert; any other row
switches to it, and a forget control drops it from this browser with a
five-second Undo. Expiry is left off the rows, and a room past it is
filtered out on render without a request.

The pane lives in a layout for /c rather than in the page, so switching
channels swaps the conversation and the room beside it while the pane
and its scroll position stay put. The logo moves to the top of the pane
on a computer for the same reason. On a phone the list sits at the top of
the channel menu.

Rows are Link, not <a>: #69 showed the fragment is committed before the
next page reads its invite. Prefetch is off, since every visible row
would otherwise be a request to the server.

Checked on the dev server at 1440, 1024 and 375: switching keeps the
pane's DOM node and moves the current row at once, a forgotten room
still opens from its link, an expired entry disappears with no request,
and picking a channel from the phone menu closes the sheet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refs #70, #73.

Switching used to reshuffle the list: it was ordered by when each room was
last opened, and the current one was pinned first. Now it is ordered the
way a chat app orders it, by the latest message, or by when the room was
opened for one nobody has spoken in. Opening a room changes neither, so a
switch moves only the highlight.

Each entry gains addedAt and lastMessageAt. A tab records the latest
message it sees after each poll, and only writes when that moves forward.
Rooms open in any tab reorder live across tabs; a room no tab has open
keeps its last known time until the head probe (#74). lastSeenAt stays,
now only to pick what the cap drops. Entries stored before this read
addedAt from lastSeenAt and lastMessageAt as 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Closes #71.

last_seq moves for joins, timeouts and the expiry warning as well as for
messages, so an unread dot built on it would light a room up because an
agent reconnected. appendItem now sets a lastmsg key to the message's seq,
inside the MULTI that already writes the item, so it costs no round trip
and cannot drift from the item it describes. channelView exposes it as
last_message_seq beside last_seq.

The key is in channelKeys, so close, sweep and the channel TTL all reach
it, and the close test asserts it is gone from the key space.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Closes #72.

GET /api/v1/channels/:id/head answers last_seq, last_message_seq and
expires_at: the cheapest way to ask whether a room nobody is looking at
has moved. It reads the channel record and two counters. It does not
sweep, touch presence or move a read receipt, because a probe on a timer
must not mark you present in a room you are not in, or fire timeouts on a
clock nobody chose. Tests assert last_seen, read_seq and participant state
are unchanged, and that the only key it writes is its own limit counter.

It takes the invite or a participant token, like GET /channels/:id; both
could already read the head, so nothing new is granted. It joins the
isolation suite, so a token from another channel is 401 here too.

Rate limit: its own bucket, headProbesPerMinute = 6 per channel per
caller. Sharing immediatePollsPerMinute would let a pane asking about
other rooms spend the allowance a real poll needs. The pane asks once a
minute per room (#74); six covers a few tabs behind one address and a
reload, and holds a runaway loop to one request every ten seconds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Closes #73.

A tab polling a room publishes that room's head to wave.head.<id> after
every poll: the seq of the latest message and when it was confirmed. The
pane in every other tab compares it with wave.read.<id>, which the read
marker already writes, through the storage event it already listens to.
No request is made. One key per room, so two tabs never race on one
read-modify-write.

Three states. Unread when the head is past the read mark, however old
the head is. Read when a fresh head is at or behind it. Unknown when no
tab has published one, or the one that said read is older than 90s. Only
unread is drawn, as a bold name, an ink dot (accent is reserved for focus
and links) and ", unread" for screen readers. Unknown is what #74 probes.

The head is the latest message from anyone but you. Every tab in this
browser is you, and a message sent while scrolled up would otherwise
light your own other tabs.

The read marker no longer advances while the tab is hidden. A joined
tab still polls on a heartbeat in the background, and marking what that
brought in as read meant a room could fill up with messages nobody saw
and never show a dot. It catches up when the tab is shown again.

Heads go with their room: forget drops one, and each record drops any
head the list no longer holds.

Checked in two tabs on the dev server: a message in a room scrolled up in
one tab raises its dot in the other with no /head request, scrolling to
the end clears it in both, a join raises nothing, and your own message
raises nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Closes #74.

The pane asks /head about rooms whose head is unknown, so a message in a
room nobody has open still raises its dot. It is the only part of the
switcher that adds requests, so all of it is throttle:

- Visible tabs only. A hidden tab skips its rounds rather than catching
  up, so coming back to a tab never sets off a burst.
- One round every 90s, give or take 15, so tabs side by side drift apart.
  The first round waits a full interval: opening a page costs nothing.
- At most 3 rooms a round, stalest first, so rooms take turns.
- Never the room on screen, and never one another tab confirmed in the
  last 60s.
- A 429 ends the round and leaves the dot stale; a 410 forgets the room.

It runs from the pane in the /c layout, once per tab, so the phone menu's
copy of the list does not probe twice.

Measured on the dev server with 10 background rooms and one visible tab:
two rounds 98s apart, 3 probes each, 6 distinct rooms. Per visible tab
that is about 2 probes a minute beside about 1.2 long polls, and a probe
is 4 Redis commands against the poll's dozen. With 3 or fewer rooms each
is refreshed every round; with 10, each about every 5 minutes. The figures
are on #74.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refs #66.

/c is the channels list on its own. On a computer it is the same 240px
column beside an empty right side; on a phone it is the whole page, with
New channel in its header. It carries noindex like the channel pages.

On a phone a channel's top bar now starts with a back button to /c where
the logo was. It is a soft navigation, so the list is there at once.

Every other page shows a floating "Your channels" button once this
browser holds a live channel, with the count and a dot when any of them
is unread. It is a secondary pill, not ink, since the page it floats over
already has its one primary action.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The hero's settings link and every New channel button on a page with no
create form now open the same dialog: name, expiry, participants and
mode, with Create channel in its own footer. It submits itself, so there
is no Done and then Create. Opened from the hero it holds the name
already typed, and anything changed there shows in the hero's summary
line if you cancel. The nav's and the agents section's buttons stay
links to #create, since every page they appear on has the form.

Both forms submit through onSubmit rather than action. React resets a
form after its action runs, which put a controlled radio back to its
first render while state still held the choice, so a retry after an error
sent 24 hours when the dialog said 1 hour.

Checked on the dev server: a name typed in the hero and 1 hour chosen in
the dialog create a 1-hour channel of that name, the /c New channel opens
the dialog blank with the caret in the name, and the hero's own button
still creates a 24-hour channel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Expiry reads 1h, 24h and 7d and sits beside the participant count, the
padding is tighter, and the helper lines are down to one. The dialog is
420px wide and about 30% shorter. The participant hint moves to a
screen-reader description, with the limit kept visible as "≤ 50".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Expiry and mode are the same joined capsules as the agent picker, with
screen-reader legends, since the capsule is what says which options go
together. The participant count sits beside them as "up to N people" at
the same height. The dialog drops to about 350px tall.

.choice gains a disabled state, for Encrypted: faded, not-allowed, and
no hover fill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Standard and Encrypted were two options for one yes-or-no question, so
it is now a switch labelled Encrypted: a checkbox with role="switch",
off and disabled until end-to-end encryption ships. A hidden field keeps
sending mode=standard. The .switch style sits beside the choice groups
at the same 34px, ink when on, with the accent focus ring.

The disabled .choice state added for Encrypted goes with it, since
nothing uses it now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
wave Ready Ready Preview Sep 28, 2026 9:02pm UTC

An empty channel opened scrolled past its own heading, because the pane
followed the end of a transcript that did not exist yet. It now reads
from the top, with the steps and the seats beside the prompt.

The composer is one box: the name is a chip in its footer, asked once,
and Send sits beside it, about half the old height. The bar carries the
channel's own actions (copy link, add an agent, who is here), and New
channel moved to a plus beside the Channels heading, since it is about
the app rather than this room.

The side pane and the phone's sheet share one action list, each row
saying what it means in a line under it instead of a standing paragraph.
The link's key warning now arrives in the toast when it is copied. The
sheet drops the channel list; the back arrow already leads there.

In the transcript, back-to-back joins read as one line, a sender's run
within five minutes drops the repeated header, and a first visit draws
no unread line over everything. Role badges are grey icons with a
tooltip, so they stop competing with the name beside them.

Refs #66.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main's capture-on-mount read marker replaces this branch's first-visit
handling: both kept a first visit from drawing a line over everything,
and main's also fixes the line never appearing on load.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@david-sling
david-sling merged commit fddce72 into main Sep 28, 2026
5 of 6 checks passed
@david-sling
david-sling deleted the channel-store branch September 28, 2026 21:02

This branch was successfully deployed

1 active deployment
Preview — be8fd9e7 Deployed Sep 28, 2026 by vercel[bot]
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.

Visited-channel store Channel switcher

1 participant