# Discord Chat Client Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. --- ## ▶ Continuation State (last updated 2026-04-28) Pick up here if resuming on a new device or session. ### Task Status | # | Task | Status | Notes | |---|------|--------|-------| | 1 | API utilities (`http.js` + `ws.js`) | ✅ Done | Error logging added, empty-token guard added | | 2 | AuthPage real API wiring | ✅ Done | `doLogin` helper extracted, loading guard on Enter key | | 3 | AppContext global state + WS events | ✅ Done | Race condition fix in `selectConnection`, `sendMessage` rollback added | | 4 | MainApp two-column layout | ✅ Done | | | 5 | HubRow + UserBar | ✅ Done | `colorToCss` extracted to `src/utils/color.js` (shared), stub buttons `disabled` | | 6 | ConnectionItem + ConnectionList + Sidebar | ✅ Done | `h-full` on Sidebar, search fallback for unresolved users, `min-w-0` truncate fix | | 7 | MessageItem + MessageList + MessageInput + ChatArea | 🔄 Created, review **interrupted** — needs spec + quality review before proceeding | | 8 | End-to-end verification | ⬜ Pending | Run `pnpm dev` in `client/`, manual browser smoke test (see task for steps) | ### Extra file created (not in original plan) - `client/src/utils/color.js` — shared `colorToCss([r,g,b,a])` utility used by UserBar, ConnectionItem, MessageItem, ChatArea ### How to resume **Option A — continue subagent-driven development (recommended):** 1. Open project in Claude Code: `cd /home/ffus/Projects/go-socket` 2. Use skill `superpowers:subagent-driven-development` 3. Start with Task 7 review: dispatch spec reviewer then code quality reviewer for the four files listed under Task 7 4. Then dispatch Task 8 (verification) — requires running `pnpm dev` and testing in browser **Option B — inline continuation:** 1. Open project in Claude Code 2. Say: "Continue the plan at `docs/superpowers/plans/2026-04-28-discord-client.md`. Tasks 1–6 done, Task 7 files created but review was interrupted. Resume from Task 7 spec review." ### Key decisions made during implementation (not in plan) - `colorToCss` shared in `src/utils/color.js` — do NOT redefine it per-component - `sendMessage` reads `userId` fresh from localStorage at call time (not via closure) to avoid stale capture - `selectConnection` clears messages immediately before fetch and guards result with `selectedIdRef` - WS_DM handler compares `event.receiver` (connection UUID) to `selectedIdRef.current` — this is **correct**, `message.Receiver = conn.Id` in server code - Stub buttons (HubRow `+`, UserBar gear) use `disabled` attribute - `state.connections` is always initialized as `[]` in context — no null guard needed on `.filter()` --- **Goal:** Build a Discord-inspired DM chat UI in the React client, wired to the go-socket backend via REST and WebSocket. **Architecture:** Two-column layout (280px sidebar + flex chat). Global state in a React Context + useReducer. Thin fetch wrapper auto-injects the `token` header. WebSocket is a singleton module with per-event-type handlers. No test framework is installed — each task verifies in the browser via `pnpm dev`. **Tech Stack:** React 19, React Router DOM 7, Tailwind CSS 4, Vite 8. Backend at `http://localhost:8080`. --- ## File Map **Created:** - `client/src/api/http.js` — fetch wrapper, auto-injects token - `client/src/api/ws.js` — WebSocket singleton, event dispatch - `client/src/context/AppContext.jsx` — global state, data loading, WS wiring - `client/src/components/HubRow.jsx` — stub hub icons row - `client/src/components/UserBar.jsx` — bottom current-user bar - `client/src/components/ConnectionItem.jsx` — single DM contact row - `client/src/components/ConnectionList.jsx` — filtered list + search input - `client/src/components/Sidebar.jsx` — assembles sidebar panels - `client/src/components/MessageItem.jsx` — single chat message - `client/src/components/MessageList.jsx` — scrollable message history - `client/src/components/MessageInput.jsx` — text input + send - `client/src/components/ChatArea.jsx` — assembles chat panel **Modified:** - `client/src/components/AuthPage.jsx` — wire up real login/register API - `client/src/components/MainApp.jsx` — replace stub with full layout --- ## Key API Facts - All REST requests send token as a plain header named `token` (not Bearer) - `POST /token` body (form-encoded): `username`, `password` → `{ token, userId }` - `POST /user` body: `username`, `password` → 201 Created - `GET /connections` → `Connection[]` - `GET /user?targetid=` → `{ name, pronouns, description, avatarType, profileBackgroundType, createdAt, color: [r,g,b,a] }` - `GET /connections/unreadmessages?connections=uuid,uuid` → `uint32[]` (same order) - `GET /connection/messages?connectionid=` → `Message[]` (oldest first) - `POST /connection/message` body: `connectionid`, `msgContent` → 202 (no body) - WebSocket: connect to `ws://localhost:8080/ws`, on open send `{"token":"..."}`, server replies `{"type":0,"event":{"success":true,"error":""}}` ## Key Data Shapes ```js // Connection { id, createdAt, requestorId, recipientId, userWantingToElevate, state } // state: 0 = Stranger, 1 = Friend // Message { id, content, attachedFile, createdAt, sender /* user UUID */, receiver /* connection UUID */ } // User (from GET /user) { name, pronouns, description, avatarType, profileBackgroundType, createdAt, color: [r,g,b,a] } // WS envelope { type: 0-9, event: any } // type 1 (DirectMessage): event = Message // type 2 (ConnectionCreated): event = Connection // type 3 (ConnectionDeleted): event = "uuid-string" // type 4 (ConnectionElevated): event = { id, newState: 1 } // type 5 (ConnectionDeElevated): event = { id, newState: 0 } ``` --- ## Task 1: API Utilities **Files:** - Create: `client/src/api/http.js` - Create: `client/src/api/ws.js` - [ ] **Step 1: Create `client/src/api/http.js`** ```js const BASE = 'http://localhost:8080' export async function apiFetch(method, path, { body, query } = {}) { const token = localStorage.getItem('token') || '' let url = BASE + path if (query) url += '?' + new URLSearchParams(query) const opts = { method, headers: { token } } if (body) opts.body = new URLSearchParams(body) const res = await fetch(url, opts) if (!res.ok) throw new Error(`${method} ${path} → ${res.status}`) const text = await res.text() try { return JSON.parse(text) } catch { return text } } ``` - [ ] **Step 2: Create `client/src/api/ws.js`** ```js let socket = null const handlers = {} export function wsOnEvent(type, fn) { handlers[type] = fn } export function wsConnect() { if (socket) return const token = localStorage.getItem('token') || '' socket = new WebSocket('ws://localhost:8080/ws') socket.onopen = () => socket.send(JSON.stringify({ token })) socket.onmessage = (e) => { try { const msg = JSON.parse(e.data) handlers[msg.type]?.(msg.event) } catch {} } socket.onclose = () => { socket = null } } export function wsDisconnect() { socket?.close() socket = null } ``` --- ## Task 2: AuthPage — Wire Up Real API **Files:** - Modify: `client/src/components/AuthPage.jsx` The login response is `{ token, userId }`. Both must be saved to localStorage — `userId` is needed to identify "my" messages and to fetch own profile. - [ ] **Step 1: Replace `client/src/components/AuthPage.jsx` with wired version** ```jsx import { useState } from 'react' import { useNavigate } from 'react-router-dom' const BASE = 'http://localhost:8080' export default function AuthPage() { const navigate = useNavigate() const [username, setUsername] = useState('') const [password, setPassword] = useState('') const [error, setError] = useState('') const [loading, setLoading] = useState(false) async function handleLogin() { setError('') setLoading(true) try { const res = await fetch(`${BASE}/token`, { method: 'POST', body: new URLSearchParams({ username, password }), }) if (!res.ok) { setError('Invalid credentials'); return } const { token, userId } = await res.json() localStorage.setItem('token', token) localStorage.setItem('userId', userId) navigate('/') } catch { setError('Could not reach server') } finally { setLoading(false) } } async function handleRegister() { setError('') setLoading(true) try { const res = await fetch(`${BASE}/user`, { method: 'POST', body: new URLSearchParams({ username, password }), }) if (!res.ok) { setError('Registration failed'); return } await handleLogin() } catch { setError('Could not reach server') } finally { setLoading(false) } } function onKey(e) { if (e.key === 'Enter') handleLogin() } return (

go-socket

setUsername(e.target.value)} onKeyDown={onKey} autoComplete="username" /> setPassword(e.target.value)} onKeyDown={onKey} autoComplete="current-password" /> {error &&

{error}

}
) } ``` - [ ] **Step 2: Verify in browser** Run `pnpm dev` in `client/`. Navigate to `http://localhost:5173/auth`. - Enter valid credentials → should redirect to `/` (currently shows "Main App") - Enter wrong credentials → should show "Invalid credentials" - Click Register with a new username → should create account and redirect --- ## Task 3: AppContext — Global State + Data Loading + WS Events **Files:** - Create: `client/src/context/AppContext.jsx` `userMap` is a `{ [userId]: userDetails }` dict populated by fetching `GET /user` for each connection's "other" participant. The `selectedIdRef` keeps the WS handler in sync with the current selection without stale closures. - [ ] **Step 1: Create `client/src/context/AppContext.jsx`** ```jsx import { createContext, useContext, useReducer, useEffect, useRef, useCallback } from 'react' import { apiFetch } from '../api/http' import { wsConnect, wsDisconnect, wsOnEvent } from '../api/ws' const Ctx = createContext(null) const WS_AUTH = 0 const WS_DM = 1 const WS_CONN_CREATED = 2 const WS_CONN_DELETED = 3 const WS_CONN_ELEVATED = 4 const WS_CONN_DEELEVATED = 5 const WS_USER_PROFILE = 6 const initial = { connections: [], userMap: {}, unread: {}, selectedId: null, messages: [], currentUser: null, } function reducer(state, action) { switch (action.type) { case 'SET_CONNECTIONS': return { ...state, connections: action.payload } case 'SET_USER_MAP': return { ...state, userMap: { ...state.userMap, ...action.payload } } case 'SET_UNREAD': return { ...state, unread: action.payload } case 'SET_CURRENT_USER': return { ...state, currentUser: action.payload } case 'SELECT_CONNECTION': return { ...state, selectedId: action.id, unread: { ...state.unread, [action.id]: 0 }, } case 'SET_MESSAGES': return { ...state, messages: action.payload } case 'APPEND_MESSAGE': return { ...state, messages: [...state.messages, action.payload] } case 'BUMP_UNREAD': return { ...state, unread: { ...state.unread, [action.id]: (state.unread[action.id] || 0) + 1 }, } case 'ADD_CONNECTION': return { ...state, connections: [action.payload, ...state.connections] } case 'REMOVE_CONNECTION': return { ...state, connections: state.connections.filter(c => c.id !== action.id), selectedId: state.selectedId === action.id ? null : state.selectedId, messages: state.selectedId === action.id ? [] : state.messages, } case 'UPDATE_CONN_STATE': return { ...state, connections: state.connections.map(c => c.id === action.id ? { ...c, state: action.newState } : c ), } case 'PATCH_USER': return { ...state, userMap: { ...state.userMap, [action.userId]: { ...state.userMap[action.userId], ...action.changes }, }, } default: return state } } export function AppProvider({ children }) { const [state, dispatch] = useReducer(reducer, initial) const selectedIdRef = useRef(null) const userId = localStorage.getItem('userId') useEffect(() => { selectedIdRef.current = state.selectedId }, [state.selectedId]) useEffect(() => { async function init() { const me = await apiFetch('GET', '/user', { query: { targetid: userId } }) dispatch({ type: 'SET_CURRENT_USER', payload: { ...me, id: userId } }) const conns = await apiFetch('GET', '/connections') dispatch({ type: 'SET_CONNECTIONS', payload: conns }) const otherIds = [...new Set( conns.map(c => c.requestorId === userId ? c.recipientId : c.requestorId) )] const entries = await Promise.all( otherIds.map(async id => { const u = await apiFetch('GET', '/user', { query: { targetid: id } }) return [id, u] }) ) dispatch({ type: 'SET_USER_MAP', payload: Object.fromEntries(entries) }) if (conns.length > 0) { const ids = conns.map(c => c.id).join(',') const counts = await apiFetch('GET', '/connections/unreadmessages', { query: { connections: ids } }) const unreadMap = {} conns.forEach((c, i) => { unreadMap[c.id] = counts[i] || 0 }) dispatch({ type: 'SET_UNREAD', payload: unreadMap }) } wsOnEvent(WS_DM, (event) => { if (event.receiver === selectedIdRef.current) { dispatch({ type: 'APPEND_MESSAGE', payload: event }) } else { dispatch({ type: 'BUMP_UNREAD', id: event.receiver }) } }) wsOnEvent(WS_CONN_CREATED, async (event) => { dispatch({ type: 'ADD_CONNECTION', payload: event }) const otherId = event.requestorId === userId ? event.recipientId : event.requestorId const u = await apiFetch('GET', '/user', { query: { targetid: otherId } }) dispatch({ type: 'SET_USER_MAP', payload: { [otherId]: u } }) }) wsOnEvent(WS_CONN_DELETED, (id) => { dispatch({ type: 'REMOVE_CONNECTION', id }) }) wsOnEvent(WS_CONN_ELEVATED, ({ id, newState }) => { dispatch({ type: 'UPDATE_CONN_STATE', id, newState }) }) wsOnEvent(WS_CONN_DEELEVATED, ({ id, newState }) => { dispatch({ type: 'UPDATE_CONN_STATE', id, newState }) }) // event = { userId, profileChangeList: { pronouns?, description?, color? } } wsOnEvent(WS_USER_PROFILE, ({ userId: uid, profileChangeList }) => { dispatch({ type: 'PATCH_USER', userId: uid, changes: profileChangeList }) }) // types 7 (avatar) and 8 (profilebg) don't affect our color-circle display — ignore wsConnect() } init() return () => wsDisconnect() }, []) const selectConnection = useCallback(async (id) => { dispatch({ type: 'SELECT_CONNECTION', id }) const msgs = await apiFetch('GET', '/connection/messages', { query: { connectionid: id } }) dispatch({ type: 'SET_MESSAGES', payload: msgs }) }, []) const sendMessage = useCallback(async (connectionId, content) => { const optimistic = { id: crypto.randomUUID(), content, attachedFile: '', createdAt: new Date().toISOString(), sender: userId, receiver: connectionId, } dispatch({ type: 'APPEND_MESSAGE', payload: optimistic }) await apiFetch('POST', '/connection/message', { body: { connectionid: connectionId, msgContent: content } }) }, [userId]) return ( {children} ) } export function useApp() { return useContext(Ctx) } ``` --- ## Task 4: MainApp — Two-Column Layout **Files:** - Modify: `client/src/components/MainApp.jsx` - [ ] **Step 1: Replace `client/src/components/MainApp.jsx`** ```jsx import { AppProvider } from '../context/AppContext' import Sidebar from './Sidebar' import ChatArea from './ChatArea' export default function MainApp() { return (
) } ``` --- ## Task 5: HubRow and UserBar **Files:** - Create: `client/src/components/HubRow.jsx` - Create: `client/src/components/UserBar.jsx` - [ ] **Step 1: Create `client/src/components/HubRow.jsx`** ```jsx export default function HubRow() { return (
) } ``` - [ ] **Step 2: Create `client/src/components/UserBar.jsx`** ```jsx import { useApp } from '../context/AppContext' function colorToCss(color) { if (!color) return '#4b5563' return `rgb(${color[0]},${color[1]},${color[2]})` } export default function UserBar() { const { state } = useApp() const user = state.currentUser return (
{user?.name?.[0]?.toUpperCase() ?? '?'}
{user?.name ?? '…'}
) } ``` --- ## Task 6: ConnectionItem, ConnectionList, Sidebar **Files:** - Create: `client/src/components/ConnectionItem.jsx` - Create: `client/src/components/ConnectionList.jsx` - Create: `client/src/components/Sidebar.jsx` - [ ] **Step 1: Create `client/src/components/ConnectionItem.jsx`** The "other user" is whichever of `requestorId`/`recipientId` isn't our `userId`. ```jsx import { useApp } from '../context/AppContext' function colorToCss(color) { if (!color) return '#4b5563' return `rgb(${color[0]},${color[1]},${color[2]})` } export default function ConnectionItem({ conn }) { const { state, selectConnection, userId } = useApp() const otherId = conn.requestorId === userId ? conn.recipientId : conn.requestorId const other = state.userMap[otherId] const unread = state.unread[conn.id] || 0 const isSelected = state.selectedId === conn.id const isFriend = conn.state === 1 return ( ) } ``` - [ ] **Step 2: Create `client/src/components/ConnectionList.jsx`** ```jsx import { useState } from 'react' import { useApp } from '../context/AppContext' import ConnectionItem from './ConnectionItem' export default function ConnectionList() { const { state, userId } = useApp() const [search, setSearch] = useState('') const filtered = state.connections.filter(conn => { if (!search) return true const otherId = conn.requestorId === userId ? conn.recipientId : conn.requestorId const other = state.userMap[otherId] return other?.name?.toLowerCase().includes(search.toLowerCase()) }) return (
setSearch(e.target.value)} />
{filtered.length === 0 && (

No contacts

)} {filtered.map(conn => ( ))}
) } ``` - [ ] **Step 3: Create `client/src/components/Sidebar.jsx`** ```jsx import HubRow from './HubRow' import ConnectionList from './ConnectionList' import UserBar from './UserBar' export default function Sidebar() { return (
) } ``` --- ## Task 7: MessageItem, MessageList, MessageInput, ChatArea **Files:** - Create: `client/src/components/MessageItem.jsx` - Create: `client/src/components/MessageList.jsx` - Create: `client/src/components/MessageInput.jsx` - Create: `client/src/components/ChatArea.jsx` - [ ] **Step 1: Create `client/src/components/MessageItem.jsx`** Messages are displayed in Discord style: avatar + sender name on first message of a group, compact rows for consecutive same-sender messages. ```jsx import { useApp } from '../context/AppContext' function colorToCss(color) { if (!color) return '#4b5563' return `rgb(${color[0]},${color[1]},${color[2]})` } function formatTime(iso) { const d = new Date(iso) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) } export default function MessageItem({ msg, showHeader }) { const { state, userId } = useApp() const isMe = msg.sender === userId const sender = isMe ? state.currentUser : state.userMap[msg.sender] return (
{showHeader ? (
{sender?.name?.[0]?.toUpperCase() ?? '?'}
) : (
)}
{showHeader && (
{sender?.name ?? msg.sender.slice(0, 8)} {formatTime(msg.createdAt)}
)}

{msg.content}

{formatTime(msg.createdAt)}
) } ``` - [ ] **Step 2: Create `client/src/components/MessageList.jsx`** Groups consecutive messages from the same sender so that only the first shows the header (avatar + name). ```jsx import { useEffect, useRef } from 'react' import { useApp } from '../context/AppContext' import MessageItem from './MessageItem' export default function MessageList() { const { state } = useApp() const bottomRef = useRef(null) useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [state.messages]) return (
{state.messages.map((msg, i) => { const prev = state.messages[i - 1] const showHeader = !prev || prev.sender !== msg.sender return })}
) } ``` - [ ] **Step 3: Create `client/src/components/MessageInput.jsx`** ```jsx import { useState, useRef } from 'react' import { useApp } from '../context/AppContext' export default function MessageInput() { const { state, sendMessage } = useApp() const [text, setText] = useState('') const textareaRef = useRef(null) async function handleSend() { const trimmed = text.trim() if (!trimmed || !state.selectedId) return setText('') await sendMessage(state.selectedId, trimmed) textareaRef.current?.focus() } function onKeyDown(e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() handleSend() } } if (!state.selectedId) return null return (