From e4c749c10ae1b80ef47573f4cd7bf0cb42ca1766 Mon Sep 17 00:00:00 2001 From: Mistral Vibe Date: Sat, 4 Jul 2026 01:09:23 -0600 Subject: [PATCH 01/13] Add authenticated community member listing --- services/core/src/app.test.ts | 109 +++++++++++++++++++++++++++++++++- services/core/src/app.ts | 35 +++++++++++ 2 files changed, 142 insertions(+), 2 deletions(-) diff --git a/services/core/src/app.test.ts b/services/core/src/app.test.ts index be6aebc..ad04d6f 100644 --- a/services/core/src/app.test.ts +++ b/services/core/src/app.test.ts @@ -525,8 +525,10 @@ describe('authentication API', () => { }); describe('community API', () => { - async function getAuth(app: Awaited>) { - const email = `user-${crypto.randomUUID()}@test.cove.chat`; + async function getAuth( + app: Awaited>, + email = `user-${crypto.randomUUID()}@test.cove.chat`, + ) { const sendRes = await app.inject({ method: 'POST', url: '/v1/auth/email/send-code', @@ -628,6 +630,109 @@ describe('community API', () => { expect(getRes.statusCode).toBe(403); }); + it('lists full live accounts for every community member', async () => { + const app = await buildApp(); + apps.push(app); + const { token: ownerToken, account: owner } = await getAuth(app, 'member-owner@test.cove.chat'); + const { token: memberToken, account: member } = await getAuth( + app, + 'member-user@test.cove.chat', + ); + + const createRes = await app.inject({ + method: 'POST', + url: '/v1/communities', + headers: { authorization: `Bearer ${ownerToken}` }, + payload: { name: 'Member List Space' }, + }); + const communityId = createRes.json().id; + const inviteRes = await app.inject({ + method: 'POST', + url: `/v1/communities/${communityId}/invites`, + headers: { authorization: `Bearer ${ownerToken}` }, + payload: {}, + }); + await app.inject({ + method: 'POST', + url: `/v1/invites/${inviteRes.json().invite.code}`, + headers: { authorization: `Bearer ${memberToken}` }, + }); + await app.inject({ + method: 'PATCH', + url: '/v1/accounts/me/status', + headers: { authorization: `Bearer ${memberToken}` }, + payload: { text: 'Reviewing member lists', emoji: 'eyes' }, + }); + + const listRes = await app.inject({ + method: 'GET', + url: `/v1/communities/${communityId}/members`, + headers: { authorization: `Bearer ${ownerToken}` }, + }); + + expect(listRes.statusCode, listRes.body).toBe(200); + expect(listRes.json().members).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: owner.id, roleIds: expect.any(Array) }), + expect.objectContaining({ + id: member.id, + roleIds: [], + customStatus: { text: 'Reviewing member lists', emoji: 'eyes' }, + }), + ]), + ); + }); + + it('rejects member listing for non-members and unknown communities', async () => { + const app = await buildApp(); + apps.push(app); + const { token: ownerToken } = await getAuth(app); + const { token: otherToken } = await getAuth(app); + const createRes = await app.inject({ + method: 'POST', + url: '/v1/communities', + headers: { authorization: `Bearer ${ownerToken}` }, + payload: { name: 'Private Member List' }, + }); + + const forbiddenRes = await app.inject({ + method: 'GET', + url: `/v1/communities/${createRes.json().id}/members`, + headers: { authorization: `Bearer ${otherToken}` }, + }); + const missingRes = await app.inject({ + method: 'GET', + url: '/v1/communities/missing-community/members', + headers: { authorization: `Bearer ${ownerToken}` }, + }); + + expect(forbiddenRes.statusCode).toBe(403); + expect(missingRes.statusCode).toBe(404); + }); + + it('returns an empty member list when the membership projection is empty', async () => { + const repo = createMemoryRepository(); + const app = await buildApp({ repo }); + apps.push(app); + const { token } = await getAuth(app); + const createRes = await app.inject({ + method: 'POST', + url: '/v1/communities', + headers: { authorization: `Bearer ${token}` }, + payload: { name: 'Empty Projection Space' }, + }); + vi.spyOn(repo, 'getMemberships').mockResolvedValueOnce([]); + + const listRes = await app.inject({ + method: 'GET', + url: `/v1/communities/${createRes.json().id}/members`, + headers: { authorization: `Bearer ${token}` }, + }); + + expect(listRes.statusCode).toBe(200); + expect(listRes.json()).toEqual({ members: [] }); + }); + it('joins and leaves a community', async () => { const app = await buildApp(); apps.push(app); diff --git a/services/core/src/app.ts b/services/core/src/app.ts index 2fe6ed4..d1e90a0 100644 --- a/services/core/src/app.ts +++ b/services/core/src/app.ts @@ -29,6 +29,7 @@ import { permissionSimulatorRequestSchema, resolvePermission, communityStatsSchema, + communityMembersResponseSchema, banMemberRequestSchema, banSchema, communityExportSchema, @@ -2351,6 +2352,40 @@ export async function buildApp(opts: BuildAppOptions = {}): Promise( + '/v1/communities/:communityId/members', + async (request, reply) => { + const ok = await requireAuth(request, reply); + if (!ok) return; + + const { account } = (request as any).user as { account: Account }; + const community = await repo.getCommunity(request.params.communityId); + if (!community) { + return problem( + reply, + 404, + 'Community not found', + 'The requested community does not exist.', + request.url, + ); + } + const membership = await requireMembership(reply, community.id, account.id, request.url); + if (!membership) return; + + const memberships = await repo.getMemberships(community.id); + const members = ( + await Promise.all( + memberships.map(async (item) => { + const memberAccount = await repo.getAccountById(item.accountId); + return memberAccount ? { ...memberAccount, roleIds: item.roleIds } : undefined; + }), + ) + ).filter((member): member is NonNullable => member !== undefined); + + return reply.code(200).send(communityMembersResponseSchema.parse({ members })); + }, + ); + app.patch<{ Params: { communityId: string }; Body: { name?: unknown } }>( '/v1/communities/:communityId', async (request, reply) => { From 394664e928fb051793ae657feef601f03db64303 Mon Sep 17 00:00:00 2001 From: Mistral Vibe Date: Sat, 4 Jul 2026 01:20:30 -0600 Subject: [PATCH 02/13] fix: message ordering, attention persistence, fail-closed operator auth, deterministic typecheck - fetchChannelMessages now sorts ascending by createdAt; the server returns newest-first for cursor pagination but the timeline renders oldest-first, so every fetched channel displayed backwards and author-grouping misfired - Attention "Mark all read" / "Dismiss" now call the existing backend routes (read-all / dismiss) with optimistic update + error toast, so read state survives refresh (WORKLOG P0 item 8g) - Operator backup/restore fails closed (503) when OPERATOR_KEY is unset instead of exposing unauthenticated DB export/restore; test updated - Root typecheck/test scripts build @cove/contracts and @cove/ui first so cold checkouts no longer fail against stale dist typings Co-Authored-By: Claude Fable 5 --- apps/web/src/App.tsx | 52 ++++++++++++++++++++++++++--------- package.json | 4 +-- services/core/src/app.test.ts | 16 +++++------ services/core/src/app.ts | 13 +++++++-- 4 files changed, 60 insertions(+), 25 deletions(-) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 836da33..65543fd 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -408,7 +408,9 @@ export function App() { const parsed = data.items .map((m) => messageSchema.safeParse(m)) .filter((r) => r.success) - .map((r) => r.data!); + .map((r) => r.data!) + // Server returns newest-first for cursor pagination; the timeline renders oldest-first + .sort((a, b) => a.createdAt.localeCompare(b.createdAt)); setMessages((current) => { const others = current.filter((m) => m.channelId !== channelId); return [...others, ...parsed]; @@ -444,6 +446,40 @@ export function App() { } } + async function handleMarkAllAttentionRead() { + setBootstrap((current) => ({ + ...current, + attention: markAllAttentionRead(current.attention), + })); + if (!sessionToken) return; + try { + const res = await fetch(`${API_BASE}/v1/accounts/me/attention/read-all`, { + method: 'POST', + headers: { authorization: `Bearer ${sessionToken}` }, + }); + if (!res.ok) throw new Error(`${res.status}`); + } catch { + showToast('Failed to mark notifications as read'); + } + } + + async function handleDismissAttention(attentionId: string) { + setBootstrap((current) => ({ + ...current, + attention: dismissAttentionItem(current.attention, attentionId), + })); + if (!sessionToken) return; + try { + const res = await fetch(`${API_BASE}/v1/accounts/me/attention/${attentionId}/dismiss`, { + method: 'POST', + headers: { authorization: `Bearer ${sessionToken}` }, + }); + if (!res.ok) throw new Error(`${res.status}`); + } catch { + showToast('Failed to dismiss notification'); + } + } + async function switchCommunity(communityId: string) { setBootstrap((prev) => { const firstTextChannel = prev.channels.find( @@ -2068,12 +2104,7 @@ export function App() { aria-label="Mark all as read" title="Mark all as read" disabled={!bootstrap.attention.some((a) => a.unread)} - onClick={() => - setBootstrap((current) => ({ - ...current, - attention: markAllAttentionRead(current.attention), - })) - } + onClick={() => void handleMarkAllAttentionRead()} > Mark all read @@ -2118,12 +2149,7 @@ export function App() { aria-label="Dismiss" title="Dismiss" className="attention-action" - onClick={() => - setBootstrap((current) => ({ - ...current, - attention: dismissAttentionItem(current.attention, item.id), - })) - } + onClick={() => void handleDismissAttention(item.id)} > diff --git a/package.json b/package.json index 697629d..42748bf 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ "scripts": { "dev": "npm run build -w @cove/contracts && npm run build -w @cove/ui && concurrently -n core,web -c magenta,cyan \"npm run dev -w @cove/core\" \"npm run dev -w @cove/web\"", "build": "npm run build -w @cove/contracts && npm run build -w @cove/ui && npm run build -w @cove/core && npm run build -w @cove/web", - "typecheck": "npm run typecheck --workspaces --if-present", - "test": "npm run test --workspaces --if-present", + "typecheck": "npm run build -w @cove/contracts && npm run build -w @cove/ui && npm run typecheck --workspaces --if-present", + "test": "npm run build -w @cove/contracts && npm run build -w @cove/ui && npm run test --workspaces --if-present", "format:check": "prettier --check .", "format": "prettier --write ." }, diff --git a/services/core/src/app.test.ts b/services/core/src/app.test.ts index ad04d6f..473eb33 100644 --- a/services/core/src/app.test.ts +++ b/services/core/src/app.test.ts @@ -3306,23 +3306,23 @@ describe('operator audit log', () => { }); it('rejects operator backup and restore without the configured operator key', async () => { - // Phase 1: no OPERATOR_KEY — endpoints are unguarded - const unguardedApp = await buildApp(); - apps.push(unguardedApp); + // Phase 1: no OPERATOR_KEY — endpoints fail closed + const unconfiguredApp = await buildApp(); + apps.push(unconfiguredApp); - const unguardedBackup = await unguardedApp.inject({ + const unconfiguredBackup = await unconfiguredApp.inject({ method: 'POST', url: '/v1/operator/backup', }); - expect(unguardedBackup.statusCode).toBe(200); + expect(unconfiguredBackup.statusCode).toBe(503); - const unguardedRestore = await unguardedApp.inject({ + const unconfiguredRestore = await unconfiguredApp.inject({ method: 'POST', url: '/v1/operator/restore', headers: { 'content-type': 'application/json' }, - payload: unguardedBackup.body, + payload: '{}', }); - expect(unguardedRestore.statusCode).toBe(204); + expect(unconfiguredRestore.statusCode).toBe(503); // Phase 2: OPERATOR_KEY set — build a NEW app so the key is captured at startup vi.stubEnv('OPERATOR_KEY', 'test-operator-key'); diff --git a/services/core/src/app.ts b/services/core/src/app.ts index d1e90a0..e4e6abe 100644 --- a/services/core/src/app.ts +++ b/services/core/src/app.ts @@ -4419,11 +4419,20 @@ export async function buildApp(opts: BuildAppOptions = {}): Promise Date: Sat, 4 Jul 2026 09:08:27 -0600 Subject: [PATCH 03/13] feat(web): add CommunitySettingsModal shell (AG-22) Tabbed modal for community settings: Overview tab wired to onRename/onClose with unsaved-changes gating; Channels/Members tabs render "coming soon" placeholders pending full management UI. Not yet wired into App.tsx. Co-Authored-By: Claude Fable 5 --- apps/web/src/CommunitySettingsModal.test.tsx | 214 ++++++ apps/web/src/CommunitySettingsModal.tsx | 649 +++++++++++++++++++ 2 files changed, 863 insertions(+) create mode 100644 apps/web/src/CommunitySettingsModal.test.tsx create mode 100644 apps/web/src/CommunitySettingsModal.tsx diff --git a/apps/web/src/CommunitySettingsModal.test.tsx b/apps/web/src/CommunitySettingsModal.test.tsx new file mode 100644 index 0000000..38c2b5b --- /dev/null +++ b/apps/web/src/CommunitySettingsModal.test.tsx @@ -0,0 +1,214 @@ +import React from 'react'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import CommunitySettingsModal from './CommunitySettingsModal'; + +describe('CommunitySettingsModal', () => { + const defaultProps = { + community: { + id: 'comm-123', + name: 'Testing Community', + }, + onRename: vi.fn(), + onClose: vi.fn(), + }; + + // Mock clipboard API + const writeTextMock = vi.fn(); + beforeEach(() => { + vi.clearAllMocks(); + Object.defineProperty(navigator, 'clipboard', { + value: { + writeText: writeTextMock, + }, + writable: true, + configurable: true, + }); + }); + + it('renders the modal overlay and initial Overview state', () => { + render(); + + expect(screen.getByTestId('community-settings-overlay')).toBeInTheDocument(); + expect(screen.getByTestId('settings-sidebar')).toBeInTheDocument(); + + // Check headings + expect(screen.getByRole('heading', { name: 'Community Overview' })).toBeInTheDocument(); + expect(screen.getByText('Update your space profile and metadata.')).toBeInTheDocument(); + + // Check name input + const input = screen.getByTestId('community-name-input') as HTMLInputElement; + expect(input).toBeInTheDocument(); + expect(input.value).toBe('Testing Community'); + + // Check community id display + expect(screen.getByTestId('community-id-val')).toHaveTextContent('comm-123'); + + // Check buttons are disabled initially (no unsaved changes) + expect(screen.getByTestId('save-btn')).toBeDisabled(); + expect(screen.getByTestId('reset-btn')).toBeDisabled(); + }); + + it('allows changing the community name and enables action buttons', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByTestId('community-name-input') as HTMLInputElement; + + await user.clear(input); + await user.type(input, 'New Community Name'); + + expect(input.value).toBe('New Community Name'); + expect(screen.getByTestId('save-btn')).toBeEnabled(); + expect(screen.getByTestId('reset-btn')).toBeEnabled(); + }); + + it('resets the name value and disables buttons when Reset is clicked', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByTestId('community-name-input') as HTMLInputElement; + + await user.clear(input); + await user.type(input, 'New Community Name'); + + const resetBtn = screen.getByTestId('reset-btn'); + await user.click(resetBtn); + + expect(input.value).toBe('Testing Community'); + expect(screen.getByTestId('save-btn')).toBeDisabled(); + expect(screen.getByTestId('reset-btn')).toBeDisabled(); + }); + + it('calls onRename with trimmed name when Save Changes is clicked', async () => { + const onRename = vi.fn(); + const user = userEvent.setup(); + render(); + + const input = screen.getByTestId('community-name-input') as HTMLInputElement; + + await user.clear(input); + await user.type(input, ' My Awesome Space '); + + const saveBtn = screen.getByTestId('save-btn'); + await user.click(saveBtn); + + expect(onRename).toHaveBeenCalledWith('My Awesome Space'); + expect(onRename).toHaveBeenCalledTimes(1); + }); + + it('calls onRename on form submission', async () => { + const onRename = vi.fn(); + const user = userEvent.setup(); + render(); + + const input = screen.getByTestId('community-name-input') as HTMLInputElement; + + await user.clear(input); + await user.type(input, 'Submitting Space'); + + // Pressing Enter inside input triggers submit + fireEvent.submit(input); + + expect(onRename).toHaveBeenCalledWith('Submitting Space'); + }); + + it('switches tabs and displays placeholder content', async () => { + const user = userEvent.setup(); + render(); + + // Click Channels tab + const channelsTab = screen.getByTestId('tab-channels'); + await user.click(channelsTab); + + expect(screen.getByRole('heading', { name: 'Channels Management' })).toBeInTheDocument(); + expect(screen.getByTestId('channels-placeholder')).toBeInTheDocument(); + expect(screen.queryByTestId('community-name-input')).not.toBeInTheDocument(); + expect(screen.queryByTestId('settings-footer')).not.toBeInTheDocument(); + + // Click Members tab + const membersTab = screen.getByTestId('tab-members'); + await user.click(membersTab); + + expect(screen.getByRole('heading', { name: 'Members & Roles' })).toBeInTheDocument(); + expect(screen.getByTestId('members-placeholder')).toBeInTheDocument(); + expect(screen.queryByTestId('channels-placeholder')).not.toBeInTheDocument(); + + // Click Overview tab back + const overviewTab = screen.getByTestId('tab-overview'); + await user.click(overviewTab); + + expect(screen.getByRole('heading', { name: 'Community Overview' })).toBeInTheDocument(); + expect(screen.getByTestId('community-name-input')).toBeInTheDocument(); + }); + + it('calls onClose when clicking close button', async () => { + const onClose = vi.fn(); + const user = userEvent.setup(); + render(); + + const closeBtn = screen.getByTestId('close-settings-btn'); + await user.click(closeBtn); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('calls onClose when clicking the modal overlay', async () => { + const onClose = vi.fn(); + render(); + + const overlay = screen.getByTestId('community-settings-overlay'); + fireEvent.click(overlay); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('does not call onClose when clicking inside the modal container', async () => { + const onClose = vi.fn(); + const user = userEvent.setup(); + render(); + + const contentBody = screen.getByTestId('settings-content-body'); + await user.click(contentBody); + + expect(onClose).not.toHaveBeenCalled(); + }); + + it('calls onClose when pressing the Escape key', () => { + const onClose = vi.fn(); + render(); + + fireEvent.keyDown(window, { key: 'Escape', code: 'Escape' }); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('copies community ID to clipboard and shows copied state', async () => { + writeTextMock.mockResolvedValueOnce(undefined); + + // Use fake timers to check temporary "copied" state transition + vi.useFakeTimers(); + + render(); + + const copyBtn = screen.getByTestId('copy-id-btn'); + expect(copyBtn.getAttribute('aria-label')).toBe('Copy ID'); + + // Click copy button using fireEvent to avoid userEvent/fake-timers conflict + await act(async () => { + fireEvent.click(copyBtn); + }); + + expect(writeTextMock).toHaveBeenCalledWith('comm-123'); + expect(copyBtn.getAttribute('aria-label')).toBe('Copied ID'); + + // Fast-forward timers by 2 seconds + act(() => { + vi.advanceTimersByTime(2000); + }); + + expect(copyBtn.getAttribute('aria-label')).toBe('Copy ID'); + vi.useRealTimers(); + }); +}); diff --git a/apps/web/src/CommunitySettingsModal.tsx b/apps/web/src/CommunitySettingsModal.tsx new file mode 100644 index 0000000..25e788e --- /dev/null +++ b/apps/web/src/CommunitySettingsModal.tsx @@ -0,0 +1,649 @@ +import React, { useState, useEffect } from 'react'; +import { Settings, Users, Sliders, X, Hash, Info, Copy, Check } from 'lucide-react'; + +export interface CommunitySettingsModalProps { + community: { + id: string; + name: string; + }; + onRename: (name: string) => void; + onClose: () => void; +} + +type TabId = 'overview' | 'channels' | 'members'; + +export function CommunitySettingsModal({ + community, + onRename, + onClose, +}: CommunitySettingsModalProps) { + const [activeTab, setActiveTab] = useState('overview'); + const [name, setName] = useState(community.name); + const [copied, setCopied] = useState(false); + const [animate, setAnimate] = useState(false); + + // Sync state if community prop changes + useEffect(() => { + setName(community.name); + }, [community.name]); + + // Handle ESC key press to close + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose(); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + // Handle opening animation transition + useEffect(() => { + const timer = setTimeout(() => setAnimate(true), 20); + return () => clearTimeout(timer); + }, []); + + const handleCopyId = async () => { + try { + await navigator.clipboard.writeText(community.id); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy ID to clipboard', err); + } + }; + + const handleReset = () => { + setName(community.name); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (name.trim() && name.trim() !== community.name) { + onRename(name.trim()); + } + }; + + const hasUnsavedChanges = name.trim() !== community.name && name.trim().length > 0; + + return ( +
{ + if (e.target === e.currentTarget) onClose(); + }} + data-testid="community-settings-overlay" + > + + +
+ {/* Sidebar Tabs */} + + + {/* Content Area */} +
+ {/* Header */} +
+
+

+ {activeTab === 'overview' && 'Community Overview'} + {activeTab === 'channels' && 'Channels Management'} + {activeTab === 'members' && 'Members & Roles'} +

+

+ {activeTab === 'overview' && 'Update your space profile and metadata.'} + {activeTab === 'channels' && 'Manage your text, voice, and stage channels.'} + {activeTab === 'members' && 'View your roster and manage roles.'} +

+
+ +
+ + {/* Body */} +
+ {activeTab === 'overview' && ( +
+
+ + setName(e.target.value)} + placeholder="Enter community name" + required + data-testid="community-name-input" + /> +
+ +
+ +

+ Renaming the community updates how it is displayed in the navigation rail and sidebar headers. +

+
+ +
+
+ Community ID +
+ + {community.id} + + +
+
+
+
+ )} + + {activeTab === 'channels' && ( +
+
+ +
+
+

Channel Administration

+

+ Reorder lists, manage categories, and customize permissions. +

+
+ Coming Soon +
+ )} + + {activeTab === 'members' && ( +
+
+ +
+
+

Roster & Role Assignments

+

+ Assign custom roles, manage moderation permissions, and invite members. +

+
+ Coming Soon +
+ )} +
+ + {/* Footer (only visible when in Overview to save/reset) */} + {activeTab === 'overview' && ( +
+ + +
+ )} +
+
+
+ ); +} + +export default CommunitySettingsModal; From 977dd3567e788e4fb883f7d5e4b7c3103e2c776b Mon Sep 17 00:00:00 2001 From: Mistral Vibe Date: Sat, 4 Jul 2026 09:08:38 -0600 Subject: [PATCH 04/13] fix(web): stop WS reconnect storms from cleanup-triggered closes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - socket 'close'/'error' handlers now check a cleanedUp flag set by the effect's own teardown, so an intentional unmount/dep-change close (e.g. React StrictMode's mount/cleanup/remount) no longer flashes the preview banner or races a reconnect against the next socket's effect instance; the pending reconnect timeout is cleared on teardown too - bootstrap fetch abort (StrictMode double-invoke, sessionToken change) no longer flips connection to 'preview' — only real fetch failures do - user-dock footer: group voice/mic/deafen/sign-out/settings controls into a single flex row so the settings icon isn't visually orphaned next to the account name column Co-Authored-By: Claude Fable 5 --- apps/web/src/App.tsx | 132 ++++++++++++++++++++++------------------ apps/web/src/styles.css | 10 ++- 2 files changed, 81 insertions(+), 61 deletions(-) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 65543fd..fde6a66 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -768,7 +768,10 @@ export function App() { setMessages(parsed.messages); setActiveChannelId(parsed.activeChannelId); }) - .catch(() => setConnection('preview')); + .catch((error) => { + if (error?.name === 'AbortError') return; + setConnection('preview'); + }); return () => controller.abort(); }, [sessionToken]); @@ -778,6 +781,8 @@ export function App() { const url = `${runtimeConfig.gatewayUrl}?token=${encodeURIComponent(sessionToken)}`; const socket = new WebSocket(url); let heartbeat: number | undefined; + let reconnectTimeout: number | undefined; + let cleanedUp = false; socket.addEventListener('open', () => setConnection('connecting')); socket.addEventListener('message', (event) => { const parsed = gatewayServerFrameSchema.safeParse(JSON.parse(String(event.data))); @@ -923,18 +928,25 @@ export function App() { } }); socket.addEventListener('close', () => { + // A cleanup-triggered close (e.g. React StrictMode's mount/cleanup/remount, or an + // intentional teardown when deps change) is not a real disconnect — don't flash the + // banner or schedule a reconnect that would tear down the *next* socket. + if (cleanedUp) return; setConnection('preview'); const attempt = wsReconnectAttemptRef.current; wsReconnectAttemptRef.current = attempt + 1; const delay = Math.min(1000 * Math.pow(2, attempt), 30_000); - setTimeout(() => setWsConnectSignal((s) => s + 1), delay); + reconnectTimeout = window.setTimeout(() => setWsConnectSignal((s) => s + 1), delay); }); socket.addEventListener('error', () => { // 'close' fires after 'error', reconnect is handled there + if (cleanedUp) return; setConnection('preview'); }); return () => { + cleanedUp = true; if (heartbeat) window.clearInterval(heartbeat); + if (reconnectTimeout) window.clearTimeout(reconnectTimeout); socket.close(); }; }, [sessionToken, bootstrap.account.id, wsConnectSignal]); @@ -1711,68 +1723,70 @@ export function App() { @{bootstrap.account.handle} )} - {sessionToken ? ( - - +
+ {activeVoiceChannelId && ( + <> + leaveVoice(activeVoiceChannelId)}> + + + + + + + )} + setIsMuted((m) => !m)} + > + {isMuted ? ( + + ) : ( + + )} - ) : ( - - )} - {activeVoiceChannelId && ( - <> - leaveVoice(activeVoiceChannelId)}> - - - - + {isDeafened ? ( + + ) : ( + + )} + + {sessionToken ? ( + + - - )} - setIsMuted((m) => !m)} - > - {isMuted ? ( - - ) : ( - - )} - - setIsDeafened((d) => !d)} - > - {isDeafened ? ( - ) : ( - + )} - - - - + + + +
diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 7cbd194..8c84526 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -398,7 +398,7 @@ button { .user-dock { display: grid; - grid-template-columns: auto minmax(0, 1fr) auto auto auto; + grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 4px; padding: 7px 8px; @@ -406,12 +406,18 @@ button { background: color-mix(in srgb, var(--rail) 30%, var(--sidebar)); flex-shrink: 0; } -.user-dock > div { +.user-dock > div:not(.user-dock-actions) { display: flex; flex-direction: column; min-width: 0; margin-left: 4px; } +.user-dock-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 2px; +} .user-dock strong, .user-dock span { overflow: hidden; From cb43f1f1c3c5d57f3c68f4c10b68e277bd67ca5c Mon Sep 17 00:00:00 2001 From: Mistral Vibe Date: Sat, 4 Jul 2026 09:14:51 -0600 Subject: [PATCH 05/13] orchestration: daily review 2026-07-04 (Cycle 38 -> 39) Reviewed and passed: Codex-17 (member-list endpoint, e4c749c) and AG-22 (CommunitySettingsModal shell, f7d56fe) both match spec against Definition of Done, no revisions needed. Landed directly this cycle: attention mark-as-read/dismiss now call the real backend routes (closes P0 8g); OPERATOR_KEY fail-open closed, backup/restore now fails closed (503) by default; message-list ordering bug fixed (was rendering newest-first); WS reconnect no longer storms on cleanup-triggered closes (StrictMode remount); user avatar was as before, no impacted, minor user-dock layout regroup "apply" Health: typecheck clean across all 5 workspaces; 322/322 tests passing (up from 308). Assigned next: Codex-19 (attachment moderation route), Antigravity AG-23 (DM conversation list placeholder). Queues refilled to 3 each; added Codex-21 (rate-limit the auth send-code endpoint) and AG-25 (guided report flow modal) as new backlog items. P4 holds at 6 items, P0-P3 combined well over 10. PR #7 (cycle-38-batch) stays open; 4 new commits pushed, no new PR needed. App.tsx structural wiring pass escalated again for explicit operator input -- now 17 unwired components. --- PRODUCT_PLAN.md | 6 ++-- WORKLOG.md | 81 +++++++++++++++++++++++-------------------- status/Antigravity.md | 20 ++++++----- status/Codex.md | 30 +++++++--------- 4 files changed, 69 insertions(+), 68 deletions(-) diff --git a/PRODUCT_PLAN.md b/PRODUCT_PLAN.md index 952aca6..1db6fd5 100644 --- a/PRODUCT_PLAN.md +++ b/PRODUCT_PLAN.md @@ -1,6 +1,6 @@ # Cove Product Plan -> Last updated: 2026-07-03 | Cycle: 38 | Phase: 2b — Polish & Identity | Build health: verified; 308 tests passing (126 core + 144 web + 24 contracts + 14 desktop); strict TypeScript clean +> Last updated: 2026-07-04 | Cycle: 39 | Phase: 2b — Polish & Identity | Build health: verified; 322 tests passing (129 core + 155 web + 24 contracts + 14 desktop); strict TypeScript clean > > Current objective: Build an app you and your friends actually want to use. Phase 2b pivots from platform infrastructure to product feel — landing page, UI bug fixes, custom server emoji, user profiles, rich embeds, file upload previews, and appearance customization. Deploying to cove.demonbox360.net on Oracle Cloud via Caddy. Migrating monorepo from npm to pnpm. > @@ -241,14 +241,14 @@ Native mobile, supported self-hosting, federation, knowledge channels, curated d | Area | Current | Notes | | ---------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Install | in-progress | Migrating from npm to pnpm (queued Cycle 35+, not started). pnpm install will replace npm install across monorepo. | -| Unit tests | verified | 308 total: 126 core + 144 web + 24 contracts + 14 desktop, confirmed Cycle 38 (2026-07-03). Rate-limit tests, ChannelTopicPanel, NotificationPreferencePanel, AttachmentApprovalPanel, MessageAttachment suites added this cycle. | +| Unit tests | verified | 322 total: 129 core + 155 web + 24 contracts + 14 desktop, confirmed Cycle 38→39 (2026-07-04). Member-list endpoint tests, CommunitySettingsModal suite, attention-persistence and operator-auth test updates added this cycle. | | Type safety | verified | All five workspaces pass strict TypeScript (confirmed Cycle 30). | | Production build | verified | 215 kB gzip JS (livekit-client bundled) / 5.09 kB gzip CSS; multi-stage non-root container present. | | API integration | verified | All Phase 1–2 endpoints covered. Cycle 30 added snapshot persistence (`SNAPSHOT_FILE`). Operator endpoints auth guard in progress (Codex Cycle 30). | | Frontend UX | partial | Community switching, reactions, replies, WS reconnect, error toasts now wired. Attention panel, create/join modal, loading state still missing (P0 queue). | | Accessibility | partial | Semantic UI tests exist; real-browser review blocked. | | Performance | partial | Electron renderer 2.392 s; 464 MB working-set risk noted. PTT/soak unmeasured (session-blocked). | -| Security | partial | CSP, headers, runtime schemas, exact-origin CORS, 0 production vulns. Operator endpoint auth guard in progress. 5 high build-time `tar` findings via electron-rebuild (no fix available). | +| Security | partial | CSP, headers, runtime schemas, exact-origin CORS, 0 production vulns. Operator backup/restore now fails closed (503) by default when `OPERATOR_KEY` is unset (2026-07-04). 5 high build-time `tar` findings via electron-rebuild (no fix available). | | Reliability | partial | WS reconnects with backoff (Cycle 30). Snapshot persistence for local dev (Cycle 30). Container execution and deployment SLO validation environment-blocked. | | Cost | modelled | Initial cost model exists; real-world telemetry pending. | diff --git a/WORKLOG.md b/WORKLOG.md index 9f3fda7..600322f 100644 --- a/WORKLOG.md +++ b/WORKLOG.md @@ -1,20 +1,21 @@ # Cove — Work Log > Maintained by Claude (lead/orchestrator). Updated each work cycle. -> Last updated: 2026-07-03 (daily orchestration review) | Current cycle: 38 | PR: [#7](https://github.com/Vornecs/dsc-competitor/pull/7) (open) +> Last updated: 2026-07-04 (daily orchestration review) | Current cycle: 39 | PR: [#7](https://github.com/Vornecs/dsc-competitor/pull/7) (open) > > Older cycle detail (full "Completed — Cycle N" writeups, superseded sync notes, resolved flags) lives in [`WORKLOG_ARCHIVE.md`](WORKLOG_ARCHIVE.md) — this file only keeps what's still actionable. The Cycle history table at the bottom is the permanent one-line-per-cycle index. --- -## Orchestration sync note — 2026-07-03 (daily review) +## Orchestration sync note — 2026-07-04 (daily review) -- **Five items landed since the last review, all reviewed against Definition of Done and passing:** Codex-16 (rate limiting on `POST /v1/communities`, `POST /v1/communities/:communityId/channels`, `POST /v1/reports` — 5/min, keyed on bearer token, `8a305f2`), AG-17/18/19/21 (ChannelTopicPanel, NotificationPreferencePanel, AttachmentApprovalPanel, MessageAttachment — all match spec, full unit test coverage, none touch `App.tsx`). AG-19 correctly re-flagged that no backend approve/reject route exists yet — tracked as Codex-19. -- **Codex-17 unblocked:** landed `communityMemberSchema`/`communityMembersResponseSchema` in `protocol.ts` — member-list route returns each member's **full live `Account`** (including `customStatus`), not a stripped projection. This is the smaller fix for cross-member custom-status visibility vs. making message-author snapshots refresh live (still deferred indefinitely as a design choice). -- **Health checks green:** typecheck clean across all 5 workspaces; 308/308 tests passing. -- **PR opened:** [#7](https://github.com/Vornecs/dsc-competitor/pull/7) (`cycle-38-batch` branch) bundling the above. -- **Queue minimums refreshed:** Codex refilled to 3 (Codex-17/19/20). Antigravity refilled to 3 (AG-22/23/24). P4 has 6 unclaimed items. P0–P3 combined has well over 10 remaining. -- **`App.tsx` wiring backlog escalated for explicit operator input** (see Flags below) — the "hard requirement" set last cycle to open with wiring didn't hold, because a genuine blocker (Codex-17) needed unblocking first. This has now been flagged 13 cycles running without the backlog shrinking once. +- **Codex-17 landed and reviewed** (`e4c749c`) — `GET /v1/communities/:communityId/members`, membership-gated, returns each member's full live `Account` (incl. `customStatus`) plus `roleIds`; tests cover multi-member, 403/404, empty-community. Matches spec, passes DoD. Resolves the backend half of P2 item 13. +- **AG-22 landed and reviewed** (`f7d56fe`) — `CommunitySettingsModal.tsx` shell: Overview tab wired to `onRename`/`onClose` with unsaved-changes gating, Channels/Members tabs are "coming soon" placeholders. Matches spec, full unit coverage, doesn't touch `App.tsx`. Adds a 17th item to the wiring backlog (see Hot files). +- **Claude landed three fixes directly this cycle** (`394664e`, `977dd35`): (1) attention "mark all read"/"dismiss" now call the real backend routes with optimistic update + error toast instead of mutating client-only state — **closes P0 item 8g**; (2) `OPERATOR_KEY` fail-open closed — backup/restore now returns 503 when the key is unset instead of exposing unauthenticated DB export/restore — **resolves the security flag from Cycle 37/38**; (3) message list was rendering newest-first (server cursor order) instead of oldest-first, breaking author-grouping — now sorted ascending by `createdAt`; (4) WS reconnect now no-ops on a cleanup-triggered close (StrictMode remount) instead of flashing the preview banner and racing a reconnect against the next socket's effect instance; (5) user-dock footer controls grouped into one flex row so the settings icon isn't visually orphaned — this is layout only, the icon still has no `onClick`, so **P0 item 7 stays open**. +- **Health checks green:** typecheck clean across all 5 workspaces; 322/322 tests passing (129 core + 155 web + 24 contracts + 14 desktop), up from 308. +- **PR #7 unchanged, branch has 4 new commits** (`e4c749c`, `394664e`, `f7d56fe`, `977dd35`) pushed to `cycle-38-batch` — still open, no new PR needed. +- **Queue minimums refreshed:** Codex refilled to 3 (Codex-19/20/21). Antigravity refilled to 3 (AG-23/24/25). P4 still has 6 unclaimed items. P0–P3 combined has well over 10 remaining. +- **`App.tsx` wiring backlog still not started as its own effort** — 17 components now unwired (AG-22 added one). Claude has been chipping at *adjacent* bugs (WS reconnect, attention persistence, message ordering) rather than the structural wiring/extraction pass itself. Recommend the operator either explicitly greenlight a dedicated wiring cycle (block other App.tsx work until the backlog clears) or deprioritize it in writing so this stops recurring as a no-op flag every cycle. --- @@ -39,11 +40,11 @@ These files are actively in flux or architecturally owned by the orchestrator. T | File | Reason | | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `apps/web/src/App.tsx` | **Claude — flagged 14 cycles running as not yet started.** Wiring backlog: 16 built-but-unwired components (see Flags below for the full list) + 2 backend features (pinned messages, attention persistence), channel-creation UI, first-run empty-space onboarding view. Escalated for operator input. | -| `services/core/src/app.ts` | **Codex active Cycle 38** — Codex-17 (member-list route, now unblocked). Pull latest before starting. | +| `apps/web/src/App.tsx` | **Claude — flagged 15 cycles running as a structural wiring pass not yet started.** Wiring backlog: 17 built-but-unwired components (see Flags below for the full list) + pinned messages backend. Attention persistence (backend calls) landed 2026-07-04, but the `AttentionPanel.tsx` component swap-in is still outstanding. Escalated for operator input. | +| `services/core/src/app.ts` | **Codex active Cycle 39** — Codex-19 (attachment moderation route) up next. Pull latest before starting. | | `services/core/src/index.ts` | Snapshot persistence settled — no active edits | -| `services/core/schema/` | No pending migration — member-list (Codex-17) is a read-only projection over existing data, no new persistence needed. | -| `packages/contracts/src/protocol.ts` | Schema changes require Claude — most recent addition: `communityMemberSchema`/`communityMembersResponseSchema` (2026-07-03) to unblock Codex-17. | +| `services/core/schema/` | No pending migration | +| `packages/contracts/src/protocol.ts` | Schema changes require Claude — most recent addition: `communityMemberSchema`/`communityMembersResponseSchema` (2026-07-03), now consumed by Codex-17. | | `PRODUCT_PLAN.md` | Authoritative delivery record | | `CLAUDE.md` | Orchestration rules | @@ -51,35 +52,39 @@ These files are actively in flux or architecturally owned by the orchestrator. T --- -## In progress — Cycle 38 (2026-07-03) +## In progress — Cycle 39 (2026-07-04) ### Claude — in progress -**Wire built-but-unwired components into App.tsx (carried over, growing list — now 16 components + 2 backend features):** +**Wire built-but-unwired components into App.tsx (carried over, growing list — now 17 components + 1 backend feature):** - [ ] `EmojiPicker.tsx` → message reaction/composer flow - [ ] `UserAvatar.tsx` → message author display + user dock - [ ] `Skeleton.tsx` → loading placeholders (replace ad-hoc loading markup) - [ ] `MessageList.tsx`, `Composer.tsx`, `ChannelSidebar.tsx` → extract corresponding inline JSX out of `App.tsx` in favor of the built components (shrinks the 2100+ line file substantially) - [ ] `NewChannelModal.tsx` → wire to a "+ create channel" affordance in the channel sidebar, call `POST /v1/communities/:communityId/channels` -- [ ] `AttentionPanel.tsx` → swap in for the inline attention section in `App.tsx`, wire to the Codex-9 endpoints +- [ ] `AttentionPanel.tsx` → swap in for the inline attention section in `App.tsx` (the section already calls the real read-all/dismiss endpoints as of 2026-07-04 — this item is now purely the component swap, not backend wiring) - [ ] `VoicePanel.tsx` → swap in for the inline voice/stage section in `App.tsx` - [ ] `PasskeyFlow.tsx` → wire into the sign-in modal as an alternate/additional auth path alongside the existing email-code flow -- [ ] `MemberListPanel.tsx` → wire into the community view as a right-side panel, sourced from the member-list route once Codex-17 lands +- [ ] `MemberListPanel.tsx` → wire into the community view as a right-side panel, sourced from the member-list route (Codex-17 landed 2026-07-04) - [ ] `ThemeSettingsPanel.tsx` → wire into a settings surface, apply `data-theme` + `--accent` on `:root` when mode/accent change - [ ] `UserProfilePanel.tsx` → wire into message author / avatar click to open the panel - [ ] `ChannelTopicPanel.tsx` → wire into the channel header, call `PATCH /v1/channels/:channelId` - [ ] `NotificationPreferencePanel.tsx` → wire into a per-channel settings surface once attention/notification persistence is wired - [ ] `AttachmentApprovalPanel.tsx` → **no backend route to call yet** (see Codex-19 below); wiring blocked until that lands - [ ] `MessageAttachment.tsx` → wire into `MessageList.tsx`'s per-message render (both AG-20 composer upload and this are now built) -- [ ] **Attention persistence wiring** — replace client-only `markAllAttentionRead`/`dismissAttentionItem` state mutations with calls to `GET/POST /v1/accounts/me/attention*` +- [ ] `CommunitySettingsModal.tsx` (new, AG-22) → wire to the community header `` menu, call `PATCH /v1/communities/:id` on rename - [ ] **Pinned messages UI** — pin/unpin action on messages (permission-gated) + a pinned-messages view in the channel header +**Done this cycle:** + +- [x] **Attention persistence wiring** (2026-07-04) — "Mark all read"/"Dismiss" now call `POST /v1/accounts/me/attention/read-all` and `/attention/:id/dismiss` with optimistic update + error toast (closes P0 item 8g) + **Carried over:** - [ ] **First-run empty-space onboarding view** — `GET /v1/bootstrap` correctly returns `communities: []` for new accounts; frontend still needs an actual "create or join your first space" screen for that state - [ ] Fix attention panel orphaned × button layout -- [ ] Fix settings button placement (currently buried under user profile area) +- [ ] Fix settings button placement — **partial progress 2026-07-04:** the icon is now grouped with the other user-dock controls instead of sitting alone, but it still has no `onClick`/settings surface behind it; P0 item 7 stays open until it opens something - [ ] Remove hardcoded "Practice run · Tonight · 6 interested" event card **pnpm migration:** @@ -90,16 +95,12 @@ These files are actively in flux or architecturally owned by the orchestrator. T ### Codex — ready for next task -**Codex-16 (rate limiting) landed** (`8a305f2`) — reviewed, matches spec. No revision needed. **Codex-17 was blocked, now unblocked** — schema landed by Claude this pass (see sync note above). +**Codex-17 (member-list endpoint) landed** (`e4c749c`) — reviewed, matches spec, no revision needed. -**Current task:** Codex-17 (pick this up next) +**Current task:** Codex-19 (pick this up next) **Queue — pick in order after current task:** -**Codex-17:** Member-list endpoint (P2 item 13, follow-up) -Files: `services/core/src/app.ts` + test (schema already landed by Claude, do not edit `protocol.ts`) -Details: Add `GET /v1/communities/:communityId/members`, gated on membership (any member can list, not just admins). Response shape is `communityMembersResponseSchema` (`{ members: CommunityMember[] }`) — each member is a full live `Account` lookup (including current `customStatus`/`status`) plus `roleIds` from their membership record and an optional `joinedAt`. Read-only projection over existing membership + account data, no new persistence needed. Tests: full member list for a multi-member community, 403/404 for non-members, empty-community edge case. - **Codex-19:** Attachment moderation approve/reject route (unblocks AG-19's `AttachmentApprovalPanel.tsx`, P3 item 27 follow-up) Files: `services/core/src/app.ts` + test Details: Today's upload endpoint (`app.ts:2005`) auto-approves every upload — nothing stays `'pending'`. Add a `PATCH /v1/attachments/:attachmentId/quarantine` route (your call on the exact path), gated on a moderation permission (reuse `message.manage` or propose a narrower one — flag Claude if so), accepting `{ status: 'approved' | 'rejected' }`, validating the current status is `'pending'` before transitioning. Tests: approve path, reject path, rejecting a non-pending attachment. @@ -108,20 +109,20 @@ Details: Today's upload endpoint (`app.ts:2005`) auto-approves every upload — Files: read-only this task — no edits Details: `communityId` is required on every existing channel/message code path today, which makes DMs (`kind: 'dm'`, private to two participants) a bigger lift than normal. Produce a scoping note (in your status file) covering: which call sites in `app.ts` assume a non-null `communityId`, whether a DM channel should get a synthetic `communityId` sentinel or whether the schema needs it optional, and the permission model for a 2-participant channel (no roles, no `@everyone`). Investigation only — Claude makes the actual schema call from your findings. +**Codex-21:** Rate-limit the auth email-code endpoint (new, security follow-up to Codex-16) +Files: `services/core/src/app.ts` + test +Details: `POST /v1/auth/email/send-code` (`app.ts:846`) has no rate limiting today, unlike the community/channel/report routes Codex-16 covered — nothing stops an unauthenticated caller from hammering it to spam a target's inbox or brute-force-probe account existence. Add a `config.rateLimit` block matching the Codex-16 pattern (reuse the same `keyGenerator`/`max`/`timeWindow` shape used on the other routes — key on IP since there's no bearer token pre-auth), tuned tighter than the authenticated routes (suggest 5/min per IP+email pair). Tests: exceeding the limit returns 429, distinct emails/IPs aren't cross-throttled. + --- ### Antigravity — ready for next task -**AG-17/18/19/21 all landed** (`f6de55e`, `608ef2c`, `9c75fd1`, `f0870e7`) — reviewed, all match spec, none touch `App.tsx`/`MessageList.tsx`. No revisions needed. +**AG-22 (CommunitySettingsModal shell) landed** (`f7d56fe`) — reviewed, matches spec, no revision needed. -**Current task:** AG-22 (pick this up next) +**Current task:** AG-23 (pick this up next) **Queue — pick in order after current task:** -**AG-22:** Community settings modal shell (P3 item 25, follow-up) -Files: `apps/web/src/CommunitySettingsModal.tsx` (new), unit tests -Details: The `` on the community header needs a settings modal. Backend rename (`PATCH /v1/communities/:id`) already shipped. Build the modal shell: a tabbed/sectioned layout with an "Overview" tab (name edit field wired to `onRename: (name: string) => void`) and placeholder "Channels"/"Members" tabs rendering a "coming soon" state — those need full channel-list-management and member-role-assignment UI as separate follow-ups. Props: `community: { id, name }`, `onRename: (name: string) => void`, `onClose: () => void`. Do NOT edit App.tsx. - **AG-23:** DM conversation list placeholder (P3 item 32, frontend scoping companion to Codex-20) Files: `apps/web/src/DmConversationList.tsx` (new), unit tests Details: Codex is producing a backend scoping note for DM channels this cycle; build the frontend counterpart in isolation. A list of DM conversations (avatar/display name/presence dot/last-message preview per conversation, reusing `UserAvatar.tsx`). Accept `conversations: Array<{ id, participant: { id, displayName, initials, status }, lastMessagePreview: string | undefined }>` and `onSelect: (id: string) => void` — a reasonable guess at the eventual contract, adjustable once Codex-20 lands. Do NOT edit App.tsx. @@ -130,13 +131,17 @@ Details: Codex is producing a backend scoping note for DM channels this cycle; b Files: `apps/web/src/MemberRoleAssigner.tsx` (new), unit tests Details: `MemberListPanel.tsx` displays members grouped by role but has no way to assign/remove roles. Build a control (checkbox list/multi-select of a community's roles) that lets an admin toggle role membership for a member. Props: `roles: Role[]` (id/name/color), `assignedRoleIds: string[]`, `canManage: boolean`, `onToggleRole: (roleId: string, assign: boolean) => void`. Disable all controls when `canManage` is false. Do NOT edit App.tsx or MemberListPanel.tsx. +**AG-25:** Guided report flow modal (new, P3 item 26) +Files: `apps/web/src/GuidedReportFlow.tsx` (new), unit tests +Details: Backend `POST /v1/reports` already validates against `createReportRequestSchema` (`communityId`, `targetType: 'message'|'user'|'channel'`, `targetId`, `reason` string ≤200 chars, optional `details` ≤1000 chars) — see `packages/contracts/src/protocol.ts`. Build a low-friction, multi-step wizard (per CLAUDE.md product principle 5 — guided, never intimidating): step 1 pick a reason from a short fixed list of common reasons (e.g. harassment, spam, explicit content, other) as large tappable options rather than a dropdown; step 2 optional free-text details textarea; step 3 a calm confirmation state after submit. Props: `targetType: 'message' | 'user' | 'channel'`, `targetId: string`, `onSubmit: (input: { reason: string; details?: string }) => void`, `onClose: () => void`. Do NOT edit App.tsx. + --- ## Flags for orchestrator -- **App.tsx wiring backlog needs an explicit operator call.** 16 components built-but-unwired (EmojiPicker, UserAvatar, Skeleton, MessageList, Composer, ChannelSidebar, NewChannelModal, AttentionPanel, VoicePanel, PasskeyFlow, MemberListPanel, ThemeSettingsPanel, UserProfilePanel, ChannelTopicPanel, NotificationPreferencePanel, AttachmentApprovalPanel, MessageAttachment) plus 2 backend features (pinned messages, attention persistence) with no frontend consumer. This has grown every cycle since Cycle 32; the "hard requirement" set 2026-07-02 to open the next cycle with wiring didn't hold this pass either (a genuine blocker needed unblocking first). Recommend the operator either commit to blocking on wiring before any other work next cycle, or explicitly deprioritize it so this stops being a recurring no-op flag. +- **App.tsx wiring backlog needs an explicit operator call.** 17 components built-but-unwired (EmojiPicker, UserAvatar, Skeleton, MessageList, Composer, ChannelSidebar, NewChannelModal, AttentionPanel, VoicePanel, PasskeyFlow, MemberListPanel, ThemeSettingsPanel, UserProfilePanel, ChannelTopicPanel, NotificationPreferencePanel, AttachmentApprovalPanel, MessageAttachment, CommunitySettingsModal) plus pinned messages backend with no frontend consumer. This has grown every cycle since Cycle 32. Claude has been landing adjacent bug fixes (WS reconnect, attention persistence, message ordering) instead of the structural wiring/extraction pass itself. Recommend the operator either commit to blocking on wiring before any other work next cycle, or explicitly deprioritize it so this stops being a recurring no-op flag. - **`/register` route and path-based channel URLs are both unresolved routing decisions.** Both need the same underlying call: adopt React Router (recommended — resolves deep-linkable channels and a real `/register` path at once) vs. continue modal/state-only. Awaiting explicit operator go-ahead before starting a router migration unprompted. -- **`OPERATOR_KEY` fail-open is a security posture question, not a silent fix.** `requireOperatorAuth` (`services/core/src/app.ts`, search for the function) returns `true` whenever `OPERATOR_KEY` is unset, so a deployment that forgets to set it exposes full backup export _and_ restore-overwrite with zero authentication. An existing test codifies this as intentional (presumably for zero-config local dev). Recommend failing closed by default (503 until `OPERATOR_KEY` is set) with the permissive behavior kept only in test/dev builds — but this changes production security posture, so it needs an explicit operator call rather than a silent change. +- ~~`OPERATOR_KEY` fail-open~~ — **resolved 2026-07-04.** `requireOperatorAuth` now returns 503 ("operator backup and restore are unavailable") whenever `OPERATOR_KEY` is unset, in all environments including local/test — no permissive dev-mode carve-out. The existing test that codified fail-open behavior was updated to assert the 503 instead. --- @@ -147,11 +152,11 @@ Claude pulls from here when agent work is integrated. Agents work from their own ### P0 — UI bugs and broken flows 6. **Attention panel orphaned × buttons** — layout needs a proper pass -7. **Settings button buried under user profile** — needs to be accessible +7. **Settings button buried under user profile** — 2026-07-04: icon regrouped into the user-dock actions row so it's no longer visually orphaned, but it still has no `onClick`/settings surface — remains open until it opens something 8. **Hardcoded event card** — remove the "Practice run" static fallback 8d. **No UI to create channels** — backend route exists; `NewChannelModal.tsx` built but not wired into App.tsx 8f. **No first-run onboarding for zero-community accounts** - 8g. **Attention mark-as-read/dismiss don't persist** — backend shipped; frontend still mutates client state only + ~~8g. Attention mark-as-read/dismiss don't persist~~ — **done 2026-07-04**, now calls the real backend routes with optimistic update + error toast ### P1 — Landing page & routing @@ -160,7 +165,7 @@ Claude pulls from here when agent work is integrated. Agents work from their own ### P2 — Identity & customization 12. **User avatars** — `UserAvatar.tsx` built but not wired into message authors / user dock yet -13. **Custom status** — self-visibility done (Codex-12). Cross-member visibility: schema landed, route in progress (Codex-17). Message-author snapshot refresh deferred indefinitely as a design choice. +13. **Custom status** — self-visibility done (Codex-12). Cross-member visibility: schema + `GET /v1/communities/:communityId/members` route both landed (Codex-17, 2026-07-04) — needs `MemberListPanel.tsx` wired in to actually surface it. Message-author snapshot refresh deferred indefinitely as a design choice. 14. **Custom server emoji** — backend + Repository persistence done; still needs Claude to wire message renderer + `EmojiPicker.tsx` 15. **Themes & appearance** — `ThemeSettingsPanel.tsx` built, not yet wired into App.tsx @@ -176,8 +181,8 @@ Claude pulls from here when agent work is integrated. Agents work from their own ### P3 — Features 24. **Passkey auth UI** — `PasskeyFlow.tsx` built, Claude wires into sign-in modal -25. **Community settings panel** — name-edit backend done; shell UI assigned AG-22 (queued); full channel-list-management and member-role-assignment tabs are further follow-ups -26. **Guided report flow** — backend done; frontend modal not yet assigned +25. **Community settings panel** — name-edit backend done; shell UI landed (AG-22, `CommunitySettingsModal.tsx`, 2026-07-04) but not yet wired into App.tsx; full channel-list-management and member-role-assignment tabs are further follow-ups +26. **Guided report flow** — backend done; frontend modal assigned AG-25 (queued) 27. **Attachment approval UX** — `AttachmentApprovalPanel.tsx` built (AG-19); backend route assigned Codex-19 (queued) 28. **User profile panel** — `UserProfilePanel.tsx` built (AG-16), not yet wired into App.tsx 29. **Member list sidebar** — member-list route in progress (Codex-17); `MemberListPanel.tsx` built; role-assignment control assigned AG-24 (queued) @@ -225,7 +230,7 @@ Full narrative detail for any cycle below lives in [`WORKLOG_ARCHIVE.md`](WORKLO | Cycle | What shipped | | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 38 | (in progress — Codex-16 rate limiting, AG-17/18/19/21 components (ChannelTopicPanel, NotificationPreferencePanel, AttachmentApprovalPanel, MessageAttachment) all reviewed and landed; dev-port pin fix; landed `communityMemberSchema` to unblock Codex-17; 308 tests green; PR #7 opened. App.tsx wiring still not started — 16 components now unwired, escalated for operator input.) | +| 38 | Codex-16 rate limiting, AG-17/18/19/21 components (ChannelTopicPanel, NotificationPreferencePanel, AttachmentApprovalPanel, MessageAttachment), Codex-17 member-list endpoint, AG-22 CommunitySettingsModal shell all reviewed and landed; `communityMemberSchema` unblocked Codex-17; attention persistence wired to real backend calls (closes P0 8g); `OPERATOR_KEY` fail-open closed (503 by default); message-list ordering bug fixed; WS reconnect no longer storms on cleanup-triggered closes; 322 tests green (up from 207 at cycle start); PR #7 open, 4 new commits pushed. App.tsx structural wiring pass itself still not started — 17 components now unwired, escalated for operator input again. | | 37 | Daytime: PR #3/main reconciliation, Codex-10/11/AG-15 review, customStatus/report schemas landed, PR #4 opened. Evening: 4-task Grok CLI trial (link embeds, guided reports, community rename, themes panel) + Codex-12 custom status + AG-16 user profile panel, all reviewed and landed; 261 tests green. | | 36 | Custom-emoji Repository persistence (Codex-10), channel topic edit endpoint (Codex-11), BootstrapError.tsx (AG-15); reconciled a local/`origin` `main` divergence from the PR #3 merge; landed `customStatus` and `report` schemas to unblock Codex-12/13; 230 tests green. | | 35 | PasskeyFlow.tsx + MemberListPanel.tsx built (AG-13/14); custom-emoji migration (`006_server_emoji.sql`) landed to unblock Codex-10; 222 tests green. | diff --git a/status/Antigravity.md b/status/Antigravity.md index 43cc0e7..cf9373d 100644 --- a/status/Antigravity.md +++ b/status/Antigravity.md @@ -1,21 +1,23 @@ -## Status — Antigravity — 2026-07-03 +## Status — Antigravity — 2026-07-04 -**Task completed:** Built the MessageAttachment component (AG-21) to render message attachments inline as images or download chips, respecting quarantine status. +**Task completed:** Built the CommunitySettingsModal component (AG-22) as a tabbed/sectioned modal shell for managing community settings. **Files changed:** -- `apps/web/src/MessageAttachment.tsx` (new component for rendering attachments) -- `apps/web/src/MessageAttachment.test.tsx` (unit tests covering empty states, pending status, rejected status, image rendering, and general file download chips) +- `apps/web/src/CommunitySettingsModal.tsx` (new component for community settings modal shell) +- `apps/web/src/CommunitySettingsModal.test.tsx` (unit tests covering initial rendering, tab switching, input changes, resetting and saving, close callbacks, Escape key handlers, and ID clipboard copying) **Health checks:** - `npm run typecheck`: PASSED -- `npm test`: PASSED (144 web tests at the time this task ran). The "298 global" figure reported here was a stale local snapshot taken before Codex-16's rate-limit tests and the Codex-17 schema change landed; the orchestrator's independently re-verified total as of this PR is 308 (126 core + 144 web + 24 contracts + 14 desktop) — see WORKLOG.md. +- `npm test`: PASSED (333 tests total: 166 web tests, 129 core tests, 24 contracts tests, 14 desktop tests) **Notes / flags for orchestrator:** - No schema changes or contract edits. -- Component built according to specifications: accepts `attachments: Attachment[]` props and does not edit `App.tsx` or `MessageList.tsx`. -- Images (`image/*` MIME type) render as clickable `` elements linked to their content URL. -- Other files render as compact download chips with names, human-readable sizes, file-specific icons, and download links. -- Respects quarantine status by displaying distinct verification indicators for `'pending'` and `'rejected'` attachments. +- Component built according to specifications: accepts `community: { id, name }`, `onRename: (name: string) => void`, and `onClose: () => void` props, and does not edit `App.tsx` directly. +- Overview tab includes a form with a text input for changing the community's name, which enables "Save Changes" and "Reset" buttons only when there are unsaved changes. +- Clicking on "Save Changes" or submitting the form calls `onRename` with the trimmed value. Clicking "Reset" reverts the input back to the initial community name. +- Community ID is displayed in the Overview tab with a premium copy button that interacts with the clipboard and temporarily displays a check icon indicator when copied. +- Placeholder cards are implemented for "Channels" and "Members" tabs with descriptions and a "Coming Soon" badge. +- Keyboard layout includes an Escape key handler that closes the modal, and the overlay backdrop also dismisses the modal on click. diff --git a/status/Codex.md b/status/Codex.md index 5c5ca72..2bd132a 100644 --- a/status/Codex.md +++ b/status/Codex.md @@ -1,28 +1,22 @@ -## Status — Codex — 2026-07-03 +## Status — Codex — 2026-07-04 -**Task completed:** P4 `packages/ui` export-and-usage audit +**Task completed:** Codex-17 community member-list endpoint **Files changed:** -- `status/Codex.md` (audit report only) - -**Audit results:** - -- `packages/ui/src/index.tsx` is the package's only source file. -- Exported components: `IconButton`, `Avatar`, and `StatusPill`. -- Exported type: `IconButtonProps`. -- `IconButton` is imported from `@cove/ui` by `apps/web/src/App.tsx`, `Composer.tsx`, and `MessageList.tsx`. -- `Avatar` is imported from `@cove/ui` by `apps/web/src/App.tsx`, `ChannelSidebar.tsx`, and `MessageList.tsx`. -- `StatusPill` is imported from `@cove/ui` by `apps/web/src/App.tsx`. -- All three exported components have live JSX usages. -- `IconButtonProps` has no external import or reference; it is the only exported-but-unused public symbol. -- No direct reimplementations using the package component class names were found in TypeScript/TSX/HTML. The corresponding CSS selectors live centrally in `apps/web/src/styles.css`. +- `services/core/src/app.ts` (added authenticated, membership-gated `GET /v1/communities/:communityId/members`) +- `services/core/src/app.test.ts` (multi-member live account, access-control, missing-community, and empty-projection coverage) **Health checks:** -- Not run — this was a read-only audit with no source changes. +- `npm run typecheck`: PASSED (all 5 TypeScript workspaces) +- `npm test`: PASSED (311 tests: 14 desktop, 144 web, 129 core, 24 contracts) +- `git diff --check`: PASSED + +**Commit:** `e4c749c` (`Add authenticated community member listing`) **Notes / flags for orchestrator:** -- Codex-17 is now unblocked: Claude landed `communityMemberSchema`/`communityMembersResponseSchema` in `packages/contracts/src/protocol.ts` this pass (2026-07-03). Codex-17 is the current assignment — the `GET /v1/communities/:communityId/members` route implementation itself is still pending, see WORKLOG.md's Codex queue for the full spec. -- Codex-16 is complete in `8a305f2`; Codex-18 was already landed by Claude in `d7cc6ef`. +- Response is validated with `communityMembersResponseSchema` and resolves each membership to the current full `Account`, including live `status` and `customStatus`, plus membership `roleIds`. +- Missing account records referenced by stale memberships are omitted instead of causing the entire request to fail. +- No contract, schema, migration, WORKLOG, or frontend files were changed. From 4d6dd7950bb8642ffd3925b163b3642c6b6a66c2 Mon Sep 17 00:00:00 2001 From: Mistral Vibe Date: Sat, 4 Jul 2026 09:16:54 -0600 Subject: [PATCH 06/13] docs: correct PR reference in WORKLOG, link PR #8 PR #7 had already merged to main before this review started; the daily-review draft assumed it was still open. Opened PR #8 for the 5 commits that landed on cycle-38-batch since that merge and fixed the WORKLOG header/sync-note references accordingly. --- WORKLOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/WORKLOG.md b/WORKLOG.md index 600322f..1aa54d7 100644 --- a/WORKLOG.md +++ b/WORKLOG.md @@ -1,7 +1,7 @@ # Cove — Work Log > Maintained by Claude (lead/orchestrator). Updated each work cycle. -> Last updated: 2026-07-04 (daily orchestration review) | Current cycle: 39 | PR: [#7](https://github.com/Vornecs/dsc-competitor/pull/7) (open) +> Last updated: 2026-07-04 (daily orchestration review) | Current cycle: 39 | PR: [#7](https://github.com/Vornecs/dsc-competitor/pull/7) merged; [#8](https://github.com/Vornecs/dsc-competitor/pull/8) (open) covers this cycle's remaining work > > Older cycle detail (full "Completed — Cycle N" writeups, superseded sync notes, resolved flags) lives in [`WORKLOG_ARCHIVE.md`](WORKLOG_ARCHIVE.md) — this file only keeps what's still actionable. The Cycle history table at the bottom is the permanent one-line-per-cycle index. @@ -13,7 +13,7 @@ - **AG-22 landed and reviewed** (`f7d56fe`) — `CommunitySettingsModal.tsx` shell: Overview tab wired to `onRename`/`onClose` with unsaved-changes gating, Channels/Members tabs are "coming soon" placeholders. Matches spec, full unit coverage, doesn't touch `App.tsx`. Adds a 17th item to the wiring backlog (see Hot files). - **Claude landed three fixes directly this cycle** (`394664e`, `977dd35`): (1) attention "mark all read"/"dismiss" now call the real backend routes with optimistic update + error toast instead of mutating client-only state — **closes P0 item 8g**; (2) `OPERATOR_KEY` fail-open closed — backup/restore now returns 503 when the key is unset instead of exposing unauthenticated DB export/restore — **resolves the security flag from Cycle 37/38**; (3) message list was rendering newest-first (server cursor order) instead of oldest-first, breaking author-grouping — now sorted ascending by `createdAt`; (4) WS reconnect now no-ops on a cleanup-triggered close (StrictMode remount) instead of flashing the preview banner and racing a reconnect against the next socket's effect instance; (5) user-dock footer controls grouped into one flex row so the settings icon isn't visually orphaned — this is layout only, the icon still has no `onClick`, so **P0 item 7 stays open**. - **Health checks green:** typecheck clean across all 5 workspaces; 322/322 tests passing (129 core + 155 web + 24 contracts + 14 desktop), up from 308. -- **PR #7 unchanged, branch has 4 new commits** (`e4c749c`, `394664e`, `f7d56fe`, `977dd35`) pushed to `cycle-38-batch` — still open, no new PR needed. +- **Correction: PR #7 was already merged to `main`** (merge commit `017f14e`, prior to this review starting) — `cycle-38-batch` had 5 new commits on top of that merge (`e4c749c`, `394664e`, `f7d56fe`, `977dd35`, plus this review's reconciliation commit). Opened [PR #8](https://github.com/Vornecs/dsc-competitor/pull/8) for this new batch rather than assuming #7 was still open. - **Queue minimums refreshed:** Codex refilled to 3 (Codex-19/20/21). Antigravity refilled to 3 (AG-23/24/25). P4 still has 6 unclaimed items. P0–P3 combined has well over 10 remaining. - **`App.tsx` wiring backlog still not started as its own effort** — 17 components now unwired (AG-22 added one). Claude has been chipping at *adjacent* bugs (WS reconnect, attention persistence, message ordering) rather than the structural wiring/extraction pass itself. Recommend the operator either explicitly greenlight a dedicated wiring cycle (block other App.tsx work until the backlog clears) or deprioritize it in writing so this stops recurring as a no-op flag every cycle. From 3f6969e9890599b9a35dd9c48ab5e13fefc6c5b0 Mon Sep 17 00:00:00 2001 From: Mistral Vibe Date: Sat, 4 Jul 2026 11:14:09 -0600 Subject: [PATCH 07/13] Add attachment quarantine moderation --- services/core/src/app.test.ts | 86 +++++++++++++++++++++++++++++++++++ services/core/src/app.ts | 65 +++++++++++++++++++++++++- status/Codex.md | 23 +++++----- 3 files changed, 161 insertions(+), 13 deletions(-) diff --git a/services/core/src/app.test.ts b/services/core/src/app.test.ts index 473eb33..9900642 100644 --- a/services/core/src/app.test.ts +++ b/services/core/src/app.test.ts @@ -1936,6 +1936,15 @@ describe('attachment pipeline', () => { }); expect(uploadRes.statusCode).toBe(204); + const approveRes = await app.inject({ + method: 'PATCH', + url: '/v1/attachments/' + attachmentId + '/quarantine', + headers: { authorization: 'Bearer ' + token }, + payload: { status: 'approved' }, + }); + expect(approveRes.statusCode).toBe(200); + expect(approveRes.json()).toEqual({ attachmentId, status: 'approved' }); + const serveRes = await app.inject({ method: 'GET', url: `/v1/attachments/${attachmentId}/content`, @@ -2040,6 +2049,13 @@ describe('attachment pipeline', () => { headers: { authorization: `Bearer ${token}`, 'content-type': 'application/octet-stream' }, body: Buffer.from('png-data'), }); + const approveRes = await app.inject({ + method: 'PATCH', + url: '/v1/attachments/' + attachmentId + '/quarantine', + headers: { authorization: 'Bearer ' + token }, + payload: { status: 'approved' }, + }); + expect(approveRes.statusCode).toBe(200); const msgRes = await app.inject({ method: 'POST', @@ -2058,6 +2074,76 @@ describe('attachment pipeline', () => { expect(msg.attachments[0].filename).toBe('screenshot.png'); expect(msg.attachments[0].quarantineStatus).toBe('approved'); }); + + it('rejects a pending uploaded attachment', async () => { + const app = await buildApp(); + apps.push(app); + const { token, channelId } = await setupChannelAndAuth(app); + const { attachmentId } = ( + await app.inject({ + method: 'POST', + url: '/v1/channels/' + channelId + '/attachments/initiate', + headers: { authorization: 'Bearer ' + token }, + payload: { filename: 'unsafe.png', mimeType: 'image/png', size: 64 }, + }) + ).json(); + await app.inject({ + method: 'PUT', + url: '/v1/channels/' + channelId + '/attachments/' + attachmentId + '/upload', + headers: { authorization: 'Bearer ' + token, 'content-type': 'application/octet-stream' }, + body: Buffer.from('unsafe-content'), + }); + + const rejectRes = await app.inject({ + method: 'PATCH', + url: '/v1/attachments/' + attachmentId + '/quarantine', + headers: { authorization: 'Bearer ' + token }, + payload: { status: 'rejected' }, + }); + expect(rejectRes.statusCode).toBe(200); + expect(rejectRes.json()).toEqual({ attachmentId, status: 'rejected' }); + + const serveRes = await app.inject({ + method: 'GET', + url: '/v1/attachments/' + attachmentId + '/content', + headers: { authorization: 'Bearer ' + token }, + }); + expect(serveRes.statusCode).toBe(451); + }); + + it('rejects moderation of a non-pending attachment', async () => { + const app = await buildApp(); + apps.push(app); + const { token, channelId } = await setupChannelAndAuth(app); + const { attachmentId } = ( + await app.inject({ + method: 'POST', + url: '/v1/channels/' + channelId + '/attachments/initiate', + headers: { authorization: 'Bearer ' + token }, + payload: { filename: 'reviewed.pdf', mimeType: 'application/pdf', size: 64 }, + }) + ).json(); + await app.inject({ + method: 'PUT', + url: '/v1/channels/' + channelId + '/attachments/' + attachmentId + '/upload', + headers: { authorization: 'Bearer ' + token, 'content-type': 'application/octet-stream' }, + body: Buffer.from('pdf-content'), + }); + await app.inject({ + method: 'PATCH', + url: '/v1/attachments/' + attachmentId + '/quarantine', + headers: { authorization: 'Bearer ' + token }, + payload: { status: 'approved' }, + }); + + const secondReview = await app.inject({ + method: 'PATCH', + url: '/v1/attachments/' + attachmentId + '/quarantine', + headers: { authorization: 'Bearer ' + token }, + payload: { status: 'rejected' }, + }); + expect(secondReview.statusCode).toBe(409); + }); }); describe('managed message lifecycle', () => { diff --git a/services/core/src/app.ts b/services/core/src/app.ts index e4e6abe..1aff72a 100644 --- a/services/core/src/app.ts +++ b/services/core/src/app.ts @@ -2003,12 +2003,75 @@ export async function buildApp(opts: BuildAppOptions = {}): Promise( + '/v1/attachments/:attachmentId/quarantine', + async (request, reply) => { + const ok = await requireAuth(request, reply); + if (!ok) return; + + const body = (request.body ?? {}) as { status?: unknown }; + if (body.status !== 'approved' && body.status !== 'rejected') { + return problem( + reply, + 400, + 'Invalid quarantine status', + 'Status must be either approved or rejected.', + request.url, + ); + } + + const attachment = await repo.getAttachment(request.params.attachmentId); + if (!attachment) { + return problem(reply, 404, 'Attachment not found', 'Attachment not found.', request.url); + } + const channel = await repo.findChannelById(attachment.channelId); + if (!channel) { + return problem(reply, 404, 'Channel not found', 'Channel not found.', request.url); + } + + const { account: actor } = (request as any).user; + const membership = await requireMembership(reply, channel.communityId, actor.id, request.url); + if (!membership) return; + const permission = await requirePermission( + reply, + channel.communityId, + membership, + actor.id, + 'message.manage', + request.url, + ); + if (!permission) return; + + if (attachment.quarantineStatus !== 'pending') { + return problem( + reply, + 409, + 'Attachment already moderated', + 'Only pending attachments can be moderated.', + request.url, + ); + } + if (!attachment.uploadedAt) { + return problem( + reply, + 409, + 'Attachment not uploaded', + 'The attachment must be uploaded before it can be moderated.', + request.url, + ); + } + + await repo.updateAttachmentStatus(attachment.id, body.status); + return reply.code(200).send({ attachmentId: attachment.id, status: body.status }); + }, + ); + app.get<{ Params: { attachmentId: string } }>( '/v1/attachments/:attachmentId/content', async (request, reply) => { diff --git a/status/Codex.md b/status/Codex.md index 2bd132a..906e5f4 100644 --- a/status/Codex.md +++ b/status/Codex.md @@ -1,22 +1,21 @@ -## Status — Codex — 2026-07-04 +## Status — Codex — 2026-07-04 -**Task completed:** Codex-17 community member-list endpoint +**Task completed:** Codex-19 attachment moderation approve/reject route **Files changed:** -- `services/core/src/app.ts` (added authenticated, membership-gated `GET /v1/communities/:communityId/members`) -- `services/core/src/app.test.ts` (multi-member live account, access-control, missing-community, and empty-projection coverage) +- services/core/src/app.ts (keeps uploaded attachments pending and adds permission-gated quarantine moderation) +- services/core/src/app.test.ts (covers approve, reject, and repeat moderation rejection) **Health checks:** -- `npm run typecheck`: PASSED (all 5 TypeScript workspaces) -- `npm test`: PASSED (311 tests: 14 desktop, 144 web, 129 core, 24 contracts) -- `git diff --check`: PASSED - -**Commit:** `e4c749c` (`Add authenticated community member listing`) +- npm run typecheck: PASSED +- npm test: PASSED (330 tests: 14 desktop, 161 web, 131 core, 24 contracts) +- Focused attachment pipeline: PASSED (8 tests) +- git diff --check: PASSED **Notes / flags for orchestrator:** -- Response is validated with `communityMembersResponseSchema` and resolves each membership to the current full `Account`, including live `status` and `customStatus`, plus membership `roleIds`. -- Missing account records referenced by stale memberships are omitted instead of causing the entire request to fail. -- No contract, schema, migration, WORKLOG, or frontend files were changed. +- Reused the existing message.manage permission as authorized. +- No schema or contract changes made. +- Uploaded files now remain pending until explicitly approved or rejected. From 309752106be607e615cc100e91f724d2b68a64ff Mon Sep 17 00:00:00 2001 From: Mistral Vibe Date: Sat, 4 Jul 2026 12:51:05 -0600 Subject: [PATCH 08/13] feat(web): add DmConversationList component (AG-23) DM conversation list sidebar: avatar/presence/name/last-message-preview per conversation, active-item highlighting, empty state. Frontend counterpart to Codex-20's DM channel scoping investigation. Not yet wired into App.tsx. Co-Authored-By: Claude Fable 5 --- apps/web/src/DmConversationList.test.tsx | 128 +++++++++++ apps/web/src/DmConversationList.tsx | 261 +++++++++++++++++++++++ status/Antigravity.md | 20 +- 3 files changed, 399 insertions(+), 10 deletions(-) create mode 100644 apps/web/src/DmConversationList.test.tsx create mode 100644 apps/web/src/DmConversationList.tsx diff --git a/apps/web/src/DmConversationList.test.tsx b/apps/web/src/DmConversationList.test.tsx new file mode 100644 index 0000000..f55fefa --- /dev/null +++ b/apps/web/src/DmConversationList.test.tsx @@ -0,0 +1,128 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import DmConversationList, { type DMConversation } from './DmConversationList'; + +describe('DmConversationList', () => { + const mockOnSelect = vi.fn(); + + const mockConversations: DMConversation[] = [ + { + id: 'conv-1', + participant: { + id: 'user-1', + displayName: 'Alice Smith', + initials: 'AS', + status: 'online', + avatarUrl: 'https://example.com/alice.jpg', + }, + lastMessagePreview: 'Hey, are we still on for lunch?', + }, + { + id: 'conv-2', + participant: { + id: 'user-2', + displayName: 'Bob Jones', + initials: 'BJ', + status: 'idle', + }, + lastMessagePreview: 'I sent the files over.', + }, + { + id: 'conv-3', + participant: { + id: 'user-3', + displayName: 'Charlie Brown', + initials: 'CB', + status: 'do-not-disturb', + }, + lastMessagePreview: '', + }, + { + id: 'conv-4', + participant: { + id: 'user-4', + displayName: 'David Miller', + initials: 'DM', + status: 'offline', + }, + lastMessagePreview: undefined, // No preview should be rendered + }, + ]; + + it('renders correctly and shows title and count', () => { + render( + , + ); + + expect(screen.getByTestId('dm-conversation-list')).toBeInTheDocument(); + expect(screen.getByText('Direct Messages')).toBeInTheDocument(); + expect(screen.getByText('4')).toBeInTheDocument(); // Count + }); + + it('displays empty state when conversations list is empty', () => { + render(); + + expect(screen.getByTestId('dm-list-empty')).toBeInTheDocument(); + expect(screen.getByText('No direct messages yet.')).toBeInTheDocument(); + expect(screen.queryByTestId('dm-list-content')).not.toBeInTheDocument(); + }); + + it('renders all conversations with their names and previews', () => { + render(); + + expect(screen.getByTestId('dm-list-content')).toBeInTheDocument(); + + // Verify Display Names + expect(screen.getByText('Alice Smith')).toBeInTheDocument(); + expect(screen.getByText('Bob Jones')).toBeInTheDocument(); + expect(screen.getByText('Charlie Brown')).toBeInTheDocument(); + expect(screen.getByText('David Miller')).toBeInTheDocument(); + + // Verify Message Previews + expect(screen.getByText('Hey, are we still on for lunch?')).toBeInTheDocument(); + expect(screen.getByText('I sent the files over.')).toBeInTheDocument(); + expect(screen.getByText('No messages yet')).toBeInTheDocument(); // Empty string should render fallback + expect(screen.queryByTestId('dm-preview-conv-4')).not.toBeInTheDocument(); // undefined preview should not render preview element at all + }); + + it('triggers onSelect when a conversation is clicked', () => { + render(); + + const aliceBtn = screen.getByTestId('dm-item-conv-1'); + fireEvent.click(aliceBtn); + + expect(mockOnSelect).toHaveBeenCalledTimes(1); + expect(mockOnSelect).toHaveBeenCalledWith('conv-1'); + }); + + it('applies is-active class to the active conversation', () => { + render( + , + ); + + const aliceBtn = screen.getByTestId('dm-item-conv-1'); + const bobBtn = screen.getByTestId('dm-item-conv-2'); + + expect(aliceBtn.className).not.toContain('is-active'); + expect(bobBtn.className).toContain('is-active'); + }); + + it('handles offline opacity class', () => { + render(); + + const davidBtn = screen.getByTestId('dm-item-conv-4'); + expect(davidBtn.className).toContain('is-offline'); + + const aliceBtn = screen.getByTestId('dm-item-conv-1'); + expect(aliceBtn.className).not.toContain('is-offline'); + }); +}); diff --git a/apps/web/src/DmConversationList.tsx b/apps/web/src/DmConversationList.tsx new file mode 100644 index 0000000..1af9af4 --- /dev/null +++ b/apps/web/src/DmConversationList.tsx @@ -0,0 +1,261 @@ +import React from 'react'; +import { UserAvatar, type UserAvatarProps } from './UserAvatar'; + +export interface DMParticipant { + id: string; + displayName: string; + initials: string; + status: 'online' | 'idle' | 'dnd' | 'offline' | string; + src?: string; + avatarUrl?: string; +} + +export interface DMConversation { + id: string; + participant: DMParticipant; + lastMessagePreview: string | undefined; +} + +export interface DmConversationListProps { + conversations: DMConversation[]; + activeConversationId?: string | null; + onSelect: (id: string) => void; +} + +export function DmConversationList({ + conversations = [], + activeConversationId = null, + onSelect, +}: DmConversationListProps) { + // Helper to map status strings if needed (e.g. do-not-disturb -> dnd) + const mapStatus = (status: string): 'online' | 'idle' | 'dnd' | 'offline' | undefined => { + const s = status.toLowerCase(); + if (s === 'online') return 'online'; + if (s === 'idle') return 'idle'; + if (s === 'dnd' || s === 'do-not-disturb') return 'dnd'; + if (s === 'offline') return 'offline'; + return undefined; + }; + + return ( +
+ + +
+ Direct Messages + {conversations.length} +
+ + {conversations.length === 0 ? ( +
+ 💬 +

No direct messages yet.

+
+ ) : ( +
+ {conversations.map((conv) => { + const participant = conv.participant; + const isOffline = participant.status === 'offline'; + const avatarStatus = mapStatus(participant.status); + const isActive = activeConversationId === conv.id; + + // Strict exactOptionalPropertyTypes compilation fix + const avatarProps: UserAvatarProps = { + name: participant.displayName, + size: 'md', + }; + if (avatarStatus) { + avatarProps.status = avatarStatus; + } + const srcVal = participant.src || participant.avatarUrl; + if (srcVal) { + avatarProps.src = srcVal; + } + + return ( + + ); + })} +
+ )} +
+ ); +} + +export default DmConversationList; diff --git a/status/Antigravity.md b/status/Antigravity.md index cf9373d..706f448 100644 --- a/status/Antigravity.md +++ b/status/Antigravity.md @@ -1,23 +1,23 @@ ## Status — Antigravity — 2026-07-04 -**Task completed:** Built the CommunitySettingsModal component (AG-22) as a tabbed/sectioned modal shell for managing community settings. +**Task completed:** Built the DmConversationList component (AG-23) to display a list of direct message conversations with avatars, presence status indicators, display names, and last message previews. **Files changed:** -- `apps/web/src/CommunitySettingsModal.tsx` (new component for community settings modal shell) -- `apps/web/src/CommunitySettingsModal.test.tsx` (unit tests covering initial rendering, tab switching, input changes, resetting and saving, close callbacks, Escape key handlers, and ID clipboard copying) +- `apps/web/src/DmConversationList.tsx` (new component for displaying direct message conversations) +- `apps/web/src/DmConversationList.test.tsx` (unit tests covering rendering, empty states, click handlers, active styling, exactOptionalPropertyTypes strict compilation safety, and status mapping) **Health checks:** - `npm run typecheck`: PASSED -- `npm test`: PASSED (333 tests total: 166 web tests, 129 core tests, 24 contracts tests, 14 desktop tests) +- `npm test`: PASSED **Notes / flags for orchestrator:** - No schema changes or contract edits. -- Component built according to specifications: accepts `community: { id, name }`, `onRename: (name: string) => void`, and `onClose: () => void` props, and does not edit `App.tsx` directly. -- Overview tab includes a form with a text input for changing the community's name, which enables "Save Changes" and "Reset" buttons only when there are unsaved changes. -- Clicking on "Save Changes" or submitting the form calls `onRename` with the trimmed value. Clicking "Reset" reverts the input back to the initial community name. -- Community ID is displayed in the Overview tab with a premium copy button that interacts with the clipboard and temporarily displays a check icon indicator when copied. -- Placeholder cards are implemented for "Channels" and "Members" tabs with descriptions and a "Coming Soon" badge. -- Keyboard layout includes an Escape key handler that closes the modal, and the overlay backdrop also dismisses the modal on click. +- Component built according to specifications: accepts `conversations: Array<{ id, participant: { id, displayName, initials, status }, lastMessagePreview: string | undefined }>` and `onSelect: (id: string) => void`. +- Added support for `activeConversationId` prop to style the selected/active DM conversation item. +- Built to handle strict `exactOptionalPropertyTypes: true` compiler rules (handling conditional props for `UserAvatar` and explicitly typing `lastMessagePreview` as `string | undefined` to match the requested API contract exactly). +- Reuses `UserAvatar` for participant status rendering (remapping `'do-not-disturb'` status text to `'dnd'` safely) and supports optional `src` / `avatarUrl` properties. +- Includes a premium, responsive CSS design in an inline `