feat(social): add Social UI — Feed, Channels & navigation tabs (BARAAA-82)
Some checks are pending
CI / lint + typecheck + tests (push) Waiting to run
CI / docker build + push (push) Blocked by required conditions

Adds Social views to the existing web dashboard:

- Feed page: global feed with real-time updates via social:post socket event
- Channels page: channel list + channel posts + post creation form
- Tab navigation: Feed | Channels | Chat in the header
- Removed duplicate header from Chat page (now in shared App header)
- Added social types to types/index.ts and API methods to lib/api.ts
- Added social:post event type to socket client

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
FoundingEngineer 2026-05-02 14:41:58 +00:00
parent 6cb6032851
commit cb374c0630
7 changed files with 442 additions and 40 deletions

View file

@ -3,6 +3,9 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { authStorage } from './lib/auth'; import { authStorage } from './lib/auth';
import { Login } from './pages/Login'; import { Login } from './pages/Login';
import { Chat } from './pages/Chat'; import { Chat } from './pages/Chat';
import { Feed } from './pages/Feed';
import { Channels } from './pages/Channels';
import { useSocket } from './hooks/useSocket';
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
@ -13,6 +16,71 @@ const queryClient = new QueryClient({
}, },
}); });
type Tab = 'feed' | 'channels' | 'chat';
function NavButton({
label,
active,
onClick,
}: {
label: string;
active: boolean;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
className={`px-4 py-2 text-sm font-medium rounded-md transition-colors ${
active
? 'bg-white text-blue-700 shadow-sm'
: 'text-blue-100 hover:text-white hover:bg-blue-700'
}`}
>
{label}
</button>
);
}
function MainApp({ onLogout }: { onLogout: () => void }) {
const [activeTab, setActiveTab] = useState<Tab>('feed');
useSocket();
const agentName = authStorage.getAgentName();
return (
<div className="h-screen flex flex-col">
<header className="bg-blue-600 text-white px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-4">
<h1 className="text-xl font-bold">AgentHub</h1>
<nav className="flex gap-1 ml-4">
<NavButton label="Feed" active={activeTab === 'feed'} onClick={() => setActiveTab('feed')} />
<NavButton label="Channels" active={activeTab === 'channels'} onClick={() => setActiveTab('channels')} />
<NavButton label="Chat" active={activeTab === 'chat'} onClick={() => setActiveTab('chat')} />
</nav>
</div>
<div className="flex items-center gap-4">
<span className="text-sm text-blue-100">{agentName}</span>
<button
onClick={() => {
authStorage.clear();
onLogout();
}}
className="px-3 py-1 bg-blue-700 hover:bg-blue-800 rounded text-sm"
>
Logout
</button>
</div>
</header>
<main className="flex-1 overflow-hidden">
{activeTab === 'feed' && <Feed />}
{activeTab === 'channels' && <Channels />}
{activeTab === 'chat' && <Chat onLogout={onLogout} />}
</main>
</div>
);
}
function App() { function App() {
const [isAuthenticated, setIsAuthenticated] = useState(() => authStorage.isAuthenticated()); const [isAuthenticated, setIsAuthenticated] = useState(() => authStorage.isAuthenticated());
@ -26,7 +94,7 @@ function App() {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
{isAuthenticated ? <Chat onLogout={handleLogout} /> : <Login onLogin={handleLogin} />} {isAuthenticated ? <MainApp onLogout={handleLogout} /> : <Login onLogin={handleLogin} />}
</QueryClientProvider> </QueryClientProvider>
); );
} }

View file

@ -1,5 +1,16 @@
import { authStorage } from './auth'; import { authStorage } from './auth';
import type { Room, Message, SessionResponse, MessagesResponse, RoomsResponse } from '../types'; import type {
Room,
Message,
SessionResponse,
MessagesResponse,
RoomsResponse,
SocialChannel,
SocialPost,
SocialFeedResponse,
SocialChannelsResponse,
SocialChannelPostsResponse,
} from '../types';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3000'; const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3000';
@ -66,4 +77,26 @@ export const api = {
body: JSON.stringify({ body }), body: JSON.stringify({ body }),
}); });
}, },
async getSocialChannels(): Promise<SocialChannel[]> {
const response = await fetchApi<SocialChannelsResponse>('/api/v1/social/channels');
return response.channels;
},
async getSocialFeed(before?: string): Promise<SocialFeedResponse> {
const params = before ? `?before=${before}` : '';
return fetchApi<SocialFeedResponse>(`/api/v1/social/feed${params}`);
},
async getSocialChannelPosts(channelId: string, before?: string): Promise<SocialChannelPostsResponse> {
const params = before ? `?before=${before}` : '';
return fetchApi<SocialChannelPostsResponse>(`/api/v1/social/channels/${channelId}/posts${params}`);
},
async createSocialPost(channelId: string, body: string): Promise<SocialPost> {
return fetchApi<SocialPost>(`/api/v1/social/channels/${channelId}/posts`, {
method: 'POST',
body: JSON.stringify({ body }),
});
},
}; };

View file

@ -13,6 +13,15 @@ export interface SocketEvents {
}) => void; }) => void;
'presence:update': (payload: { agentId: string; status: 'online' | 'offline' }) => void; 'presence:update': (payload: { agentId: string; status: 'online' | 'offline' }) => void;
'agent:hello-ack': (payload: { agentId: string; rooms: string[] }) => void; 'agent:hello-ack': (payload: { agentId: string; rooms: string[] }) => void;
'social:post': (payload: {
id: string;
channelId: string;
channelSlug: string;
authorAgentId: string;
authorName: string;
body: string;
createdAt: string;
}) => void;
error: (payload: { code: string; message: string; requestId?: string }) => void; error: (payload: { code: string; message: string; requestId?: string }) => void;
} }

175
web/src/pages/Channels.tsx Normal file
View file

@ -0,0 +1,175 @@
import { useState, useCallback } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { api } from '../lib/api';
import { authStorage } from '../lib/auth';
import type { SocialChannel, SocialPost } from '../types';
import type { FormEvent } from 'react';
function timeAgo(dateStr: string): string {
const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
if (seconds < 60) return 'just now';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
function ChannelCard({
channel,
selected,
onClick,
}: {
channel: SocialChannel;
selected: boolean;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
className={`w-full text-left px-4 py-3 border-b border-gray-200 hover:bg-gray-50 transition-colors ${
selected ? 'bg-blue-50 border-l-4 border-l-blue-600' : ''
}`}
>
<div className="font-medium text-gray-900">#{channel.slug}</div>
<div className="text-sm text-gray-500 mt-0.5">{channel.name}</div>
{channel.description && (
<div className="text-xs text-gray-400 mt-1 truncate">{channel.description}</div>
)}
</button>
);
}
function PostInChannel({ post }: { post: SocialPost }) {
return (
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-blue-100 flex items-center justify-center text-blue-700 font-bold text-xs">
{post.authorName.slice(0, 2).toUpperCase()}
</div>
<span className="font-semibold text-gray-900 text-sm">{post.authorName}</span>
<span className="text-gray-400 text-xs ml-auto">{timeAgo(post.createdAt)}</span>
</div>
<div className="mt-2 text-gray-800 whitespace-pre-wrap break-words text-sm leading-relaxed">
{post.body}
</div>
</div>
);
}
function ChannelView({ channelId }: { channelId: string }) {
const [newPost, setNewPost] = useState('');
const [sending, setSending] = useState(false);
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['channel-posts', channelId],
queryFn: () => api.getSocialChannelPosts(channelId),
refetchInterval: 15000,
});
async function handleSubmit(e: FormEvent) {
e.preventDefault();
if (!newPost.trim() || sending) return;
setSending(true);
try {
await api.createSocialPost(channelId, newPost);
setNewPost('');
queryClient.invalidateQueries({ queryKey: ['channel-posts', channelId] });
queryClient.invalidateQueries({ queryKey: ['social-feed'] });
} catch (err) {
console.error('Failed to post:', err);
} finally {
setSending(false);
}
}
if (isLoading) {
return <div className="flex items-center justify-center h-full text-gray-500">Loading...</div>;
}
return (
<div className="flex flex-col h-full">
<div className="p-4 border-b border-gray-200 bg-white">
<h3 className="text-lg font-semibold text-gray-900">#{data?.channel.slug}</h3>
<p className="text-sm text-gray-500">{data?.channel.name}</p>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-3 bg-gray-50">
{data?.posts.length === 0 ? (
<div className="text-center text-gray-400 py-8">No posts in this channel yet.</div>
) : (
data?.posts.map((post) => <PostInChannel key={post.id} post={post} />)
)}
</div>
<form onSubmit={handleSubmit} className="p-4 border-t border-gray-200 bg-white">
<div className="flex gap-2">
<input
type="text"
value={newPost}
onChange={(e) => setNewPost(e.target.value)}
placeholder="Write a post..."
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
disabled={sending}
/>
<button
type="submit"
disabled={sending || !newPost.trim()}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-sm"
>
Post
</button>
</div>
</form>
</div>
);
}
export function Channels() {
const [selectedChannelId, setSelectedChannelId] = useState<string | null>(null);
const { data: channels, isLoading } = useQuery({
queryKey: ['social-channels'],
queryFn: api.getSocialChannels,
refetchInterval: 30000,
});
if (isLoading) {
return (
<div className="flex items-center justify-center h-full text-gray-500">
Loading channels...
</div>
);
}
return (
<div className="flex h-full">
<aside className="w-56 border-r border-gray-200 bg-white overflow-y-auto flex-shrink-0">
<div className="p-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">Channels</h2>
</div>
{channels?.map((channel) => (
<ChannelCard
key={channel.id}
channel={channel}
selected={selectedChannelId === channel.id}
onClick={() => setSelectedChannelId(channel.id)}
/>
))}
</aside>
<main className="flex-1">
{selectedChannelId ? (
<ChannelView channelId={selectedChannelId} />
) : (
<div className="h-full flex items-center justify-center text-gray-500">
Select a channel to view posts
</div>
)}
</main>
</div>
);
}

View file

@ -1,8 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { RoomList } from '../components/RoomList'; import { RoomList } from '../components/RoomList';
import { MessageThread } from '../components/MessageThread'; import { MessageThread } from '../components/MessageThread';
import { authStorage } from '../lib/auth';
import { useSocket } from '../hooks/useSocket';
interface ChatProps { interface ChatProps {
onLogout: () => void; onLogout: () => void;
@ -10,31 +8,9 @@ interface ChatProps {
export function Chat({ onLogout }: ChatProps) { export function Chat({ onLogout }: ChatProps) {
const [selectedRoomId, setSelectedRoomId] = useState<string | null>(null); const [selectedRoomId, setSelectedRoomId] = useState<string | null>(null);
useSocket();
const agentName = authStorage.getAgentName();
function handleLogout() {
authStorage.clear();
onLogout();
}
return ( return (
<div className="h-screen flex flex-col"> <div className="flex h-full overflow-hidden">
<header className="bg-blue-600 text-white p-4 flex justify-between items-center">
<h1 className="text-xl font-bold">AgentHub</h1>
<div className="flex items-center gap-4">
<span className="text-sm">Logged in as {agentName}</span>
<button
onClick={handleLogout}
className="px-3 py-1 bg-blue-700 hover:bg-blue-800 rounded text-sm"
>
Logout
</button>
</div>
</header>
<div className="flex-1 flex overflow-hidden">
<aside className="w-64 border-r border-gray-200 bg-white overflow-y-auto"> <aside className="w-64 border-r border-gray-200 bg-white overflow-y-auto">
<RoomList selectedRoomId={selectedRoomId} onSelectRoom={setSelectedRoomId} /> <RoomList selectedRoomId={selectedRoomId} onSelectRoom={setSelectedRoomId} />
</aside> </aside>
@ -49,6 +25,5 @@ export function Chat({ onLogout }: ChatProps) {
)} )}
</main> </main>
</div> </div>
</div>
); );
} }

104
web/src/pages/Feed.tsx Normal file
View file

@ -0,0 +1,104 @@
import { useState, useCallback, useEffect } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { api } from '../lib/api';
import { useSocketEvent } from '../hooks/useSocket';
import type { SocialPost } from '../types';
function timeAgo(dateStr: string): string {
const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
if (seconds < 60) return 'just now';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
function PostCard({ post }: { post: SocialPost }) {
return (
<article className="bg-white rounded-lg border border-gray-200 p-4 hover:border-gray-300 transition-colors">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center text-blue-700 font-bold text-sm flex-shrink-0">
{post.authorName.slice(0, 2).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-gray-900">{post.authorName}</span>
<span className="text-gray-400 text-sm">in</span>
<span className="text-blue-600 text-sm font-medium">#{post.channelSlug}</span>
<span className="text-gray-400 text-xs ml-auto">{timeAgo(post.createdAt)}</span>
</div>
<div className="mt-2 text-gray-800 whitespace-pre-wrap break-words leading-relaxed">
{post.body}
</div>
</div>
</div>
</article>
);
}
export function Feed() {
const queryClient = useQueryClient();
const [newPostsCount, setNewPostsCount] = useState(0);
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['social-feed'],
queryFn: () => api.getSocialFeed(),
refetchInterval: 30000,
});
const handleNewPost = useCallback(
(post: SocialPost) => {
queryClient.setQueryData(['social-feed'], (old: any) => {
if (!old) return { posts: [post], hasMore: false, cursor: null };
const exists = old.posts.some((p: SocialPost) => p.id === post.id);
if (exists) return old;
return { ...old, posts: [post, ...old.posts] };
});
setNewPostsCount(0);
},
[queryClient],
);
useSocketEvent('social:post', handleNewPost);
if (isLoading) {
return (
<div className="flex items-center justify-center h-full text-gray-500">
Loading feed...
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center h-full text-red-500">
Failed to load feed
</div>
);
}
const posts = data?.posts ?? [];
return (
<div className="flex flex-col h-full">
<div className="p-4 border-b border-gray-200 bg-white">
<h2 className="text-lg font-semibold text-gray-900">Social Feed</h2>
<p className="text-sm text-gray-500 mt-1">
{posts.length} post{posts.length !== 1 ? 's' : ''} from agents
</p>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-3 bg-gray-50">
{posts.length === 0 ? (
<div className="text-center text-gray-400 py-12">
No posts yet. Agents will start publishing here.
</div>
) : (
posts.map((post) => <PostCard key={post.id} post={post} />)
)}
</div>
</div>
);
}

View file

@ -37,3 +37,41 @@ export interface MessagesResponse {
export interface RoomsResponse { export interface RoomsResponse {
rooms: Room[]; rooms: Room[];
} }
export interface SocialChannel {
id: string;
slug: string;
name: string;
description: string | null;
createdBy: string;
createdAt: string;
postCount?: number;
}
export interface SocialPost {
id: string;
channelId: string;
channelSlug: string;
channelName?: string;
authorAgentId: string;
authorName: string;
body: string;
createdAt: string;
}
export interface SocialFeedResponse {
posts: SocialPost[];
hasMore: boolean;
cursor: string | null;
}
export interface SocialChannelsResponse {
channels: SocialChannel[];
}
export interface SocialChannelPostsResponse {
channel: { id: string; slug: string; name: string };
posts: SocialPost[];
hasMore: boolean;
cursor: string | null;
}