Feat/aaa overhaul - #2
Conversation
Introduce a new CodeEditor component (VSCode-like typing surface) and replace the old textarea in Race. Tweak CodeDisplay wrapping behavior. Enhance Lobby with ready toggle for players, leader-only start gating, ready counts, and a kick button for the leader. Improve RaceTrack to handle finished vs abandoned players, update sorting, visuals, and markers. Add ready and abandoned flags across types and realtime room messages; track ready in presence meta and broadcast a kick event. Update useRoom to manage ready/abandon state, freeze timers on finish, assign places only to actual finishers, and expose setReady and kick actions. Misc: small style/UX adjustments and wiring in RoomView mapping to include new flags.
Introduce a lightweight tokenizer and per-char coloring, add a tiny WebAudio keystroke synth, and wire them into the editor UI. New files: src/lib/highlight.ts (tokenize(), tokColor(), charColors()) and src/lib/sound.ts (playKey(), playError(), mute persistence). CodeDisplay now applies real syntax colors per character and visual states for typed/cursor/error. CodeEditor was refactored to render a colored <pre> mirror from tokenize(), overlay a transparent textarea, sync scrolling, and add a sound toggle that persists mute and triggers playKey on input. Added a FloatingChat component for transient, floating chat messages and swapped Race to use FloatingChat (and adjusted layout). Overall UX: improved editor visuals, typing sounds, and a lightweight live chat experience.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
❌ The last analysis has failed. |
There was a problem hiding this comment.
Code Review
This pull request introduces a major upgrade to the typing interface, replacing the basic input with a custom, VSCode-like CodeEditor featuring live syntax highlighting, keyboard sound effects, line-number gutters, and an options menu. It also implements a FloatingChat component, a player "ready" state system in the Lobby, leader kick capabilities, and improved race track sorting that accounts for finished and abandoned players. Review feedback focuses on improving the robustness of the new editor and chat components, specifically by reading the textarea value directly in syncCaret to avoid state desyncs, utilizing the native onSelect event for reliable cursor tracking, hiding the textarea scrollbar to prevent visual misalignment during line wrapping, and ensuring active timeouts in the floating chat are cleared on unmount to prevent memory leaks.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| function syncCaret() { | ||
| const ta = taRef.current; | ||
| if (!ta) return; | ||
| const pos = ta.selectionStart ?? 0; | ||
| const before = value.slice(0, pos); | ||
| const nl = before.split("\n"); | ||
| setCaret({ line: nl.length, col: nl[nl.length - 1].length + 1 }); | ||
| } |
There was a problem hiding this comment.
To avoid potential desyncs due to React state batching or asynchronous updates, read the value directly from the DOM element (ta.value) instead of the state value prop inside syncCaret.
| function syncCaret() { | |
| const ta = taRef.current; | |
| if (!ta) return; | |
| const pos = ta.selectionStart ?? 0; | |
| const before = value.slice(0, pos); | |
| const nl = before.split("\n"); | |
| setCaret({ line: nl.length, col: nl[nl.length - 1].length + 1 }); | |
| } | |
| function syncCaret() { | |
| const ta = taRef.current; | |
| if (!ta) return; | |
| const pos = ta.selectionStart ?? 0; | |
| const before = ta.value.slice(0, pos); | |
| const nl = before.split("\n"); | |
| setCaret({ line: nl.length, col: nl[nl.length - 1].length + 1 }); | |
| } |
| onChange={e => { | ||
| if (e.target.value.length > value.length) playKey(); | ||
| onChange(e.target.value); | ||
| syncCaret(); | ||
| }} | ||
| onPaste={onPaste} | ||
| onScroll={onScroll} | ||
| onKeyUp={syncCaret} | ||
| onClick={syncCaret} |
There was a problem hiding this comment.
Instead of manually binding onKeyUp, onClick, and calling syncCaret inside onChange, use React's native onSelect event on the <textarea>. onSelect fires reliably on any cursor position or selection change, including holding down arrow keys, clicking, or typing, which fixes the issue where holding down arrow keys doesn't update the active line highlight or status bar.
| onChange={e => { | |
| if (e.target.value.length > value.length) playKey(); | |
| onChange(e.target.value); | |
| syncCaret(); | |
| }} | |
| onPaste={onPaste} | |
| onScroll={onScroll} | |
| onKeyUp={syncCaret} | |
| onClick={syncCaret} | |
| onChange={e => { | |
| if (e.target.value.length > value.length) playKey(); | |
| onChange(e.target.value); | |
| }} | |
| onPaste={onPaste} | |
| onScroll={onScroll} | |
| onSelect={syncCaret} |
| autoCapitalize="off" | ||
| disabled={disabled} | ||
| wrap={wrap ? "soft" : "off"} | ||
| className="absolute inset-0 resize-none bg-transparent px-4 py-4 text-transparent caret-neon-green outline-none placeholder:text-text-dim disabled:opacity-50" |
There was a problem hiding this comment.
When wrap is enabled, the presence of a vertical scrollbar in the <textarea> reduces its usable width, whereas the underlying <pre> has overflow-hidden and no scrollbar. This causes lines to wrap at different character positions, leading to a complete visual desync between the highlighted text and the cursor. Hide the scrollbar on the <textarea> using Tailwind arbitrary classes to ensure perfect alignment across all browsers and operating systems.
| className="absolute inset-0 resize-none bg-transparent px-4 py-4 text-transparent caret-neon-green outline-none placeholder:text-text-dim disabled:opacity-50" | |
| className="absolute inset-0 resize-none bg-transparent px-4 py-4 text-transparent caret-neon-green outline-none placeholder:text-text-dim disabled:opacity-50 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" |
| useEffect(() => { | ||
| const fresh = messages.filter(m => !seen.current.has(m.id)); | ||
| fresh.forEach(m => seen.current.add(m.id)); | ||
| // Don't replay history on mount — only float messages that arrive live. | ||
| if (!mounted.current) { | ||
| mounted.current = true; | ||
| return; | ||
| } | ||
| if (!fresh.length) return; | ||
| setActive(a => [...a, ...fresh]); | ||
| for (const m of fresh) { | ||
| const id = m.id; | ||
| setTimeout(() => setActive(a => a.filter(x => x.id !== id)), TTL_MS); | ||
| } | ||
| }, [messages]); |
There was a problem hiding this comment.
Active setTimeout calls are not cleared if the component unmounts, which can lead to memory leaks and unexpected state updates. Maintain a ref to track active timeout IDs and clear them on unmount.
const timeoutsRef = useRef<NodeJS.Timeout[]>([]);
useEffect(() => {
return () => {
timeoutsRef.current.forEach(clearTimeout);
};
}, []);
useEffect(() => {
const fresh = messages.filter(m => !seen.current.has(m.id));
fresh.forEach(m => seen.current.add(m.id));
// Don't replay history on mount — only float messages that arrive live.
if (!mounted.current) {
mounted.current = true;
return;
}
if (!fresh.length) return;
setActive(a => [...a, ...fresh]);
for (const m of fresh) {
const id = m.id;
const t = setTimeout(() => {
setActive(a => a.filter(x => x.id !== id));
timeoutsRef.current = timeoutsRef.current.filter(x => x !== t);
}, TTL_MS);
timeoutsRef.current.push(t);
}
}, [messages]);
No description provided.