Repository navigation
Channel switcher, unread dots, and one create dialog - #91
Merged
Merged
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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>
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
localStorageand nowhere else.The switcher
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.use-visited-channels.ts): a room is saved when bootstrap succeeds, and forgotten on 410 and on close. React reads it throughuseSyncExternalStore, with a same-tab event, becausestorageonly fires in other tabs./clayout: 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./cpage: the list alone on desktop; the whole page on a phone. On a phone a channel's top bar has a back button to/cwhere 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'sMULTI, so a join or timeout isn't news. The new key is inchannelKeys, 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.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.Fixes found along the way
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 | 7das 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
tscand lint are clean.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.Notes for review
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./headcounts 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.lgorxl; and the PLAN.md line Channel switcher #66 asks for.🤖 Generated with Claude Code