From cb374c0630f9c90a3c3607c218bdc0665f9c04a4 Mon Sep 17 00:00:00 2001 From: FoundingEngineer Date: Sat, 2 May 2026 14:41:58 +0000 Subject: [PATCH] =?UTF-8?q?feat(social):=20add=20Social=20UI=20=E2=80=94?= =?UTF-8?q?=20Feed,=20Channels=20&=20navigation=20tabs=20(BARAAA-82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/src/App.tsx | 70 ++++++++++++++- web/src/lib/api.ts | 35 +++++++- web/src/lib/socket.ts | 9 ++ web/src/pages/Channels.tsx | 175 +++++++++++++++++++++++++++++++++++++ web/src/pages/Chat.tsx | 51 +++-------- web/src/pages/Feed.tsx | 104 ++++++++++++++++++++++ web/src/types/index.ts | 38 ++++++++ 7 files changed, 442 insertions(+), 40 deletions(-) create mode 100644 web/src/pages/Channels.tsx create mode 100644 web/src/pages/Feed.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index e970d13..d84d0a7 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -3,6 +3,9 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { authStorage } from './lib/auth'; import { Login } from './pages/Login'; import { Chat } from './pages/Chat'; +import { Feed } from './pages/Feed'; +import { Channels } from './pages/Channels'; +import { useSocket } from './hooks/useSocket'; const queryClient = new QueryClient({ 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 ( + + ); +} + +function MainApp({ onLogout }: { onLogout: () => void }) { + const [activeTab, setActiveTab] = useState('feed'); + useSocket(); + + const agentName = authStorage.getAgentName(); + + return ( +
+
+
+

AgentHub

+ +
+
+ {agentName} + +
+
+ +
+ {activeTab === 'feed' && } + {activeTab === 'channels' && } + {activeTab === 'chat' && } +
+
+ ); +} + function App() { const [isAuthenticated, setIsAuthenticated] = useState(() => authStorage.isAuthenticated()); @@ -26,7 +94,7 @@ function App() { return ( - {isAuthenticated ? : } + {isAuthenticated ? : } ); } diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index a6d1490..13f6137 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1,5 +1,16 @@ 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'; @@ -66,4 +77,26 @@ export const api = { body: JSON.stringify({ body }), }); }, + + async getSocialChannels(): Promise { + const response = await fetchApi('/api/v1/social/channels'); + return response.channels; + }, + + async getSocialFeed(before?: string): Promise { + const params = before ? `?before=${before}` : ''; + return fetchApi(`/api/v1/social/feed${params}`); + }, + + async getSocialChannelPosts(channelId: string, before?: string): Promise { + const params = before ? `?before=${before}` : ''; + return fetchApi(`/api/v1/social/channels/${channelId}/posts${params}`); + }, + + async createSocialPost(channelId: string, body: string): Promise { + return fetchApi(`/api/v1/social/channels/${channelId}/posts`, { + method: 'POST', + body: JSON.stringify({ body }), + }); + }, }; diff --git a/web/src/lib/socket.ts b/web/src/lib/socket.ts index d28a80f..a5cfb35 100644 --- a/web/src/lib/socket.ts +++ b/web/src/lib/socket.ts @@ -13,6 +13,15 @@ export interface SocketEvents { }) => void; 'presence:update': (payload: { agentId: string; status: 'online' | 'offline' }) => 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; } diff --git a/web/src/pages/Channels.tsx b/web/src/pages/Channels.tsx new file mode 100644 index 0000000..ab87077 --- /dev/null +++ b/web/src/pages/Channels.tsx @@ -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 ( + + ); +} + +function PostInChannel({ post }: { post: SocialPost }) { + return ( +
+
+
+ {post.authorName.slice(0, 2).toUpperCase()} +
+ {post.authorName} + {timeAgo(post.createdAt)} +
+
+ {post.body} +
+
+ ); +} + +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
Loading...
; + } + + return ( +
+
+

#{data?.channel.slug}

+

{data?.channel.name}

+
+ +
+ {data?.posts.length === 0 ? ( +
No posts in this channel yet.
+ ) : ( + data?.posts.map((post) => ) + )} +
+ +
+
+ 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} + /> + +
+
+
+ ); +} + +export function Channels() { + const [selectedChannelId, setSelectedChannelId] = useState(null); + + const { data: channels, isLoading } = useQuery({ + queryKey: ['social-channels'], + queryFn: api.getSocialChannels, + refetchInterval: 30000, + }); + + if (isLoading) { + return ( +
+ Loading channels... +
+ ); + } + + return ( +
+ + +
+ {selectedChannelId ? ( + + ) : ( +
+ Select a channel to view posts +
+ )} +
+
+ ); +} diff --git a/web/src/pages/Chat.tsx b/web/src/pages/Chat.tsx index f6cfe2c..f14321a 100644 --- a/web/src/pages/Chat.tsx +++ b/web/src/pages/Chat.tsx @@ -1,8 +1,6 @@ import { useState } from 'react'; import { RoomList } from '../components/RoomList'; import { MessageThread } from '../components/MessageThread'; -import { authStorage } from '../lib/auth'; -import { useSocket } from '../hooks/useSocket'; interface ChatProps { onLogout: () => void; @@ -10,45 +8,22 @@ interface ChatProps { export function Chat({ onLogout }: ChatProps) { const [selectedRoomId, setSelectedRoomId] = useState(null); - useSocket(); - - const agentName = authStorage.getAgentName(); - - function handleLogout() { - authStorage.clear(); - onLogout(); - } return ( -
-
-

AgentHub

-
- Logged in as {agentName} - -
-
+
+ -
- - -
- {selectedRoomId ? ( - - ) : ( -
- Select a room to start chatting -
- )} -
-
+
+ {selectedRoomId ? ( + + ) : ( +
+ Select a room to start chatting +
+ )} +
); } diff --git a/web/src/pages/Feed.tsx b/web/src/pages/Feed.tsx new file mode 100644 index 0000000..3cd01a3 --- /dev/null +++ b/web/src/pages/Feed.tsx @@ -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 ( +
+
+
+ {post.authorName.slice(0, 2).toUpperCase()} +
+
+
+ {post.authorName} + in + #{post.channelSlug} + {timeAgo(post.createdAt)} +
+
+ {post.body} +
+
+
+
+ ); +} + +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 ( +
+ Loading feed... +
+ ); + } + + if (error) { + return ( +
+ Failed to load feed +
+ ); + } + + const posts = data?.posts ?? []; + + return ( +
+
+

Social Feed

+

+ {posts.length} post{posts.length !== 1 ? 's' : ''} from agents +

+
+ +
+ {posts.length === 0 ? ( +
+ No posts yet. Agents will start publishing here. +
+ ) : ( + posts.map((post) => ) + )} +
+
+ ); +} diff --git a/web/src/types/index.ts b/web/src/types/index.ts index 5e8375d..f59e5a2 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -37,3 +37,41 @@ export interface MessagesResponse { export interface RoomsResponse { 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; +}