Conversation
The collapsed header drew its own outline Badge alongside StageRail's active pill. Deleting the Badge outright would have left nothing between sm and md, where the rail's <ol> is hidden, so the rail now renders at every width and hides its non-active steps below md instead. One component owns the status indicator and it cannot duplicate itself. NoteDialog never reset, and its only reset was guarded on `content`, which is undefined when creating — so a new note opened holding the last one's text. Reset on open, the way ColumnDialog already does. The field also accepted 255 characters in a 36px single-line Input. AutoTextarea grows to its cap instead, the dialog widens to hold it, and Cmd/Ctrl+Enter saves now that Enter inserts a newline. TaskDialog had the same reset bug and gets the same fix. ConnectionIndicator moves into the always-visible row: it sat inside CollapsibleContent, which Radix unmounts, so collapsing the header made presence disappear. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every note kept an always-mounted opacity-0 action toolbar carrying backdrop-blur-sm, so a board of 50 notes held 50 backdrop-filter layers that reinvalidated whenever anything under them repainted. The background was already near-opaque; making it opaque loses nothing. Its transition-all also swept ring, shadow and background for what is only ever an opacity change. The board header stacked a second sticky backdrop-blur-xl directly under Nav's, so both re-rasterised on every scroll frame. Nav keeps its blur; the header goes opaque. The header title also had transition-all across a font-size change, which animates layout on every frame of the collapse. The hero's three blur-3xl blobs ran infinite motion loops for an 18-26s drift nobody notices, keeping a rAF alive on the home page forever. They stay as static decoration. Tooltip mounted its own TooltipProvider per instance, which in the group stage is one state machine per note. One provider at the app root instead, which also lets the shared delay work between tooltips. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
RetroContext carried the whole useWebSocket return in an inline object
literal, so its identity changed on every inbound frame and re-rendered
every consumer — including each note on the board, where a layoutId
turns a re-render into a layout projection pass. react-use-websocket
also writes lastMessage inside flushSync, so that render was synchronous
and unbatched, blocking the socket handler.
SocketProvider now owns the connection and fans frames out to a ref'd
listener set. onMessage runs before filter, and filter gates only the
lastMessage write, so filter: () => false plus onMessage delivers every
frame with no render at all from the hook. readyState gets its own
context so a reconnect touches only ConnectionIndicator.
RetroContext is down to { retro, setRetro }, memoised on retro, and each
useEffect on lastJsonMessage becomes useSocketEvent with the same body.
Consumers still re-render when a frame is theirs; they no longer
re-render when it is not.
The notes reducer is untouched, so its tests are too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each stage component called useNotes, and useNotes owned a reducer and a mount fetch, so AnimatePresence mode="wait" unmounting the outgoing stage threw the notes away: every stage change reset to empty, flashed skeletons, refetched, and dropped the rollback map for any optimistic edit still in flight. One NotesProvider above the board instead. That remount was also an accidental resync — it papered over whatever the socket missed while it was down. Replaced with a deliberate one on reconnect, so it is the same request count per connection but now it actually fires when a connection drops rather than when someone happens to click to the next stage. The panel AnimatePresence goes with it: mode="wait" made the incoming stage wait out the outgoing one's exit, and every alternative keeps both mounted, which would put each note's layoutId on screen twice. notesByColumn replaces a notes.filter per column in Brainstorm, which was O(columns x notes) and handed AnimatePresence a new array each render. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
With no profile images the circle is a missing-image fallback, and on a note there is already room for the whole name next to it. A small dot in the same per-person colour carries the same at-a-glance grouping without pretending to be an avatar. The presence stack keeps its initials, where overlapping circles are doing real work compressing several people into a corner of the header. The optimistic note also had no created_by_name, so the author line popped in when the echo landed. The name is stamped onto the local dispatch only, not the frame sent to the server, which already knows who is writing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Voting awaited the POST before anything moved, so the button label, the group's ring and the votes-left counter all sat still for a round trip. The response also replaced the list wholesale, so two quick clicks could race and the slower reply would win. useVotes applies the click locally and keeps the previous list to put back if the request fails, the same shape use-notes already uses. Each group carries a ticket so a late reply to an older click cannot undo a newer one, and only the first stash is kept per group, so a rollback lands on what the server last confirmed rather than on another pending click. It stays vote-stage only on purpose: in discuss the same endpoint returns everyone's votes with counts, which is a different shape. Worth knowing: votes are REST-only and never published to event.Broker, so other people's votes still do not appear live during the vote stage. That needs a server-side event and is not this change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The board opened on a chain: load the retro, mount, then ask for the notes, showing skeletons in between. Both requests only need the id from the path, so they go in the loader together. With defaultPreload set to "intent", hovering a card on the home page now warms the notes too, and the board can paint populated on click. NotesProvider seeds from that data through the reducer's own note_index path, so loaded starts true and the skeletons never appear on a cold open. The mount fetch is gone; the reconnect resync stays and is now the only place that refetches. Votes and tasks stay where they are. They depend on the stage, so loading them here would either add back a waterfall or fetch them on every visit to a board that never reaches those stages. Also a pendingComponent, the first in the app: with two requests in the loader a cold visit otherwise holds the previous screen with no feedback until both land. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Board holds connectionInfo and votesRemaining, so someone joining,
leaving or voting re-rendered the entire stage and every note under it.
Memoising BoardForStatus confines those to the header: measured with a
render counter, a connection_info frame now causes zero note renders,
where before it caused one each.
Discuss recomputed the board-wide vote total once per group, allocated a
Set of authors per group, and re-sorted the tasks inline on every
render. Those move into memos keyed on votes and tasks.
Tried and reverted: React.memo on Note and DraggableNote, with the
action props taking a note id so the callbacks could be stable. It makes
no difference, because AnimatePresence deliberately rebuilds its context
value when presenceAffectsLayout is on ("we want to make a new context
value to ensure they get re-rendered", PresenceChild.mjs), and a context
read is not something memo can block. Adding one note still re-renders
each sibling the same number of times either way, so the API churn
bought nothing. Memoising above an AnimatePresence works; below it does
not.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
NoteFromModel resolves created_by_name from the user it is handed, and both socket broadcasts passed nil, so it fell through to the "unknown" default. The REST index passes a real user, which is why notes looked right on load and then flipped the moment anyone moved, grouped or edited one — visible on every note in the group and vote stages. Creates can pass the acting user, who is the author. Updates cannot: moving and grouping other people's notes is allowed, so the author is looked up by note.UserID. Found while checking drag-and-drop still worked after the frontend changes; the bug predates them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cmd+Enter saves a note, but nothing on screen said so. Added shadcn's kbd component and put the combo on the Save button itself, where it sits next to the action it triggers. The tag input was the other undiscoverable one: Enter adds a tag and the arrows move through suggestions, with only a comma in the placeholder to hint at any of it. The suggestion popover now carries a footer, which appears exactly while someone is typing a tag. The modifier is resolved per platform so the hint reads Ctrl off Apple devices, rather than showing a key that isn't on the keyboard. Only shortcuts that already exist are labelled. Enter already submits the single-field dialogs natively and Escape already closes them; those need no decoration. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Leaving a retro and coming back showed the stage you left rather than the one it is on, until a hard refresh. The route loader's result is cached by the router and survives navigating away. While the board is open the socket, not the loader, is what moves the retro forward, so the cached copy silently goes out of date. Coming back mounts the board and seeds it from that copy, and the seed only happens once, so a later revalidation never reaches the screen. Invalidating on retro_updated and status_updated keeps the cache honest. While the board is open this costs a loader re-run whose result is discarded — the socket has already applied it — so nothing on screen moves, and stage changes still keep their notes with no skeleton. Predates the work on this branch: verified by running main against the same backend, where advancing a stage and returning shows the old stage in exactly the same way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Shift+Enter still inserts a newline; plain Enter now submits, matching the chat-input convention most people already expect there. The Cmd/Ctrl+Enter path is gone rather than kept as an alt, so there's only one thing to learn. lib/keys.ts (the platform-aware ⌘/Ctrl label) was added for this hint and has no other caller, so it goes with it rather than sitting unused. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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.
Two reported bugs turned into a look at why the board feels heavy in general. Nine commits, each independently reviewable, ordered so the riskiest change sits in the middle with verification either side.
The two reported bugs
Duplicate status pill when the header is collapsed. Deleting the badge outright would have left a gap — between
smandmdthe stage rail's<ol>is hidden, so the badge was the only stage label in that range. Instead the rail renders at every width and hides its non-active steps belowmd. One component owns the status indicator and can't duplicate itself.New-note dialog kept the last note's text, in a tiny box. It never called
form.reset(), and its only reset was guarded oncontent, which isundefinedwhen creating. Now resets on open, the waycolumn-dialog.tsxalready did. The 36px single-lineInputfor a 255-character field becomes theAutoTextareathat already existed in the repo, and the dialog widens to hold it. Enter saves (Shift+Enter for a newline) — the chat-input convention, and simpler than the Cmd/Ctrl+Enter this went through first.task-dialog.tsxhad the identical reset bug and gets the same fix.Why it felt slow
RetroContextcarried the wholeuseWebSocketreturn in an inline object literal, so its identity changed on every inbound frame and re-rendered every consumer — including each note, where alayoutIdturns a re-render into a layout projection pass.react-use-websocketalso writeslastMessageinsideflushSync, so that render was synchronous and unbatched, blocking the socket handler.Frames now fan out to a ref'd listener set.
onMessageruns beforefilter, andfiltergates only thelastMessagewrite, sofilter: () => falseplusonMessagedelivers every frame with no render from the hook at all (verified againstattach-shared-listeners.js).readyStategets its own context so a reconnect touches onlyConnectionIndicator.Other things that were costing time:
useNotes, which owned a reducer and a mount fetch, somode="wait"unmounting the outgoing stage reset to empty, flashed skeletons, refetched, and dropped the rollback map for any optimistic edit in flight. OneNotesProviderabove the board instead.backdrop-blur-smtoolbar per note, a second stickybackdrop-blur-xlstacked under the nav's,transition-allacross a font-size change, three infiniteblur-3xlloops on the home page, and aTooltipProviderper tooltip instance.Measured
Verified in the running app with a render counter and the resource timing API:
connection_infoframeA stage change does still re-run the route loader, deliberately: see the
cache-invalidation commit below. That result is discarded, so the notes state
and the absence of a skeleton are unaffected — what changed is that the board
no longer throws its notes away and refetches them behind a skeleton.
Rollback verified by stopping the backend mid-session: the vote reverts and toasts. Reconnect resync verified the same way — fires exactly once, notes intact.
Bug found while testing
NoteFromModelresolvescreated_by_namefrom the user it's handed, and both socket broadcasts passednil, falling through to an"unknown"default. The REST index passes a real user, which is why notes looked right on load and then flipped the moment anyone moved, grouped or edited one — visible on every note in the group and vote stages. Predates this work; found while checking drag-and-drop still worked.Creates pass the acting user, who is the author. Updates can't — moving and grouping other people's notes is allowed — so the author is looked up by
note.UserID.Second bug found, also pre-existing
Leaving a retro and coming back showed the stage you left rather than the one it
is on, until a hard refresh. The route loader's result is cached by the router
and survives navigating away, but while the board is open it is the socket, not
the loader, that moves the retro forward — so the cached copy silently goes
stale. Returning mounts the board and seeds it from that copy, and the seed
happens once, so a later revalidation never reaches the screen.
Fixed by invalidating on
retro_updatedandstatus_updated. Confirmedpre-existing by running
mainagainst the same backend and reproducing itidentically.
Tried and reverted
React.memoonNoteandDraggableNote, with the action props taking a note id so callbacks could be stable. It made no measurable difference:AnimatePresencedeliberately rebuilds its context value whenpresenceAffectsLayoutis on ("we want to make a new context value to ensure they get re-rendered" —PresenceChild.mjs), and a context read isn't somethingmemocan block. Adding one note re-rendered each sibling the same number of times either way, so the API churn across four files bought nothing. Memoising above anAnimatePresenceworks — that's whyBoardForStatuspays off — below it does not.Product decisions taken
Letter avatars come off notes and stay in the presence stack. With no profile images the circle on a note is a missing-image fallback sitting next to the full name it stands for; a dot in the same per-person colour carries the grouping without pretending to be an avatar. The overlapping stack in the header keeps its initials, where compressing several people into a corner is real work.
ConnectionIndicatoralso moves into the always-visible header row — it lived insideCollapsibleContent, which Radix unmounts, so collapsing the header made presence vanish.Reviewer notes
.claude/launch.jsonis included — a dev-server config added for tooling during this work, swept in bygit add -A. Happy to drop it if unwanted.event.Broker, so other people's votes still don't appear live during the vote stage. That needs a server event and isn't this change.useVotesis deliberately vote-stage only — in discuss the same endpoint returns everyone's votes with counts, a different shape.use-notes.test.tskeeps passing untouched apart from one added case;use-votes.test.tsis new (7 cases). Everything else — the provider split, the memoisation, the stage transitions — was verified by driving the real app, not by tests.backdrop-filterbehaves on sticky elements. I couldn't capture frame timings with the tooling available.Full gate green:
go build,go vet,go test,pnpm lint,tsc -b,pnpm test(27 passing),pnpm build.🤖 Generated with Claude Code