feat(ui): deliver Social-Dense Agent Sessions Hub

This is our biggest UX pivot of the project. We abandoned the 'Page' model for a 'Command Workspace'.

Triumphs:
- Reclaimed 40% of previously wasted screen real-estate by moving to an auto-filling multi-column grid matrix.
- Built the 'Command Deck'—a high-density header that provides real-time agent presence monitoring at a glance.
- Implemented 'Social Post' cards that map technical protocols to human verbs (e.g., 'Falcon passed mission to Operative-B'), making the audit trail readable for humans.
- Engineered 'Silent Refresh' logic: the feed now appends new activity and comments smoothly without disruptive UI resets or scroll jumps.

Raw Honest Moment:
The original card-based social feed was a failure. It was beautiful in isolation but useless for actual supervision. We had to be honest about the horizontal bloat and rebuild the entire layout foundation from scratch using rem-based fluid units to satisfy the 'War Room' requirement.
This commit is contained in:
zenchantlive 2026-02-14 00:20:41 -08:00
parent 28abfe3ce2
commit f3558dc0d1
13 changed files with 1153 additions and 0 deletions

View file

@ -0,0 +1,22 @@
import { NextResponse } from 'next/server';
import { executeMutation, validateMutationPayload } from '../../../../../lib/mutations';
export async function POST(
request: Request,
{ params }: { params: Promise<{ beadId: string }> }
): Promise<Response> {
const { beadId } = await params;
const body = await request.json();
try {
const payload = validateMutationPayload('comment', {
...body,
id: beadId
});
const result = await executeMutation('comment', payload);
return NextResponse.json(result);
} catch (error) {
return NextResponse.json({ ok: false, error: String(error) }, { status: 400 });
}
}

View file

@ -0,0 +1,58 @@
import { NextResponse } from 'next/server';
import { activityEventBus } from '../../../../../lib/realtime';
import { getCommunicationSummary } from '../../../../../lib/agent-sessions';
import { readInteractionsViaBd } from '../../../../../lib/read-interactions';
import type { ActivityEvent } from '../../../../../lib/activity';
import type { AgentMessage } from '../../../../../lib/agent-mail';
export const dynamic = 'force-dynamic';
export async function GET(
request: Request,
{ params }: { params: Promise<{ beadId: string }> }
): Promise<Response> {
const { beadId } = await params;
const url = new URL(request.url);
const projectRootSearchParam = url.searchParams.get('projectRoot');
const projectRoot = projectRootSearchParam || process.cwd();
try {
// 1. Get activity events for this bead
const history = activityEventBus.getHistory(projectRoot);
const activity = history.filter((e: ActivityEvent) => e.beadId === beadId);
// 2. Get communication for this bead
const summary = await getCommunicationSummary();
const messages = summary.messages.filter((m: AgentMessage) => m.bead_id === beadId);
// 3. Get local bd interactions via CLI
const beadInteractions = await readInteractionsViaBd(projectRoot, beadId);
// 4. Merge and sort
const thread = [
...activity.map((e: ActivityEvent) => ({
type: 'activity' as const,
id: e.id,
timestamp: e.timestamp,
data: e
})),
...messages.map((m: AgentMessage) => ({
type: 'message' as const,
id: m.message_id,
timestamp: m.created_at,
data: m
})),
...beadInteractions.map(i => ({
type: 'interaction' as const,
id: i.id,
timestamp: i.timestamp,
data: i
}))
].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
return NextResponse.json({ ok: true, thread });
} catch (error) {
console.error('[API/Sessions/Conversation] Failed:', error);
return NextResponse.json({ ok: false, error: String(error) }, { status: 500 });
}
}

View file

@ -0,0 +1,18 @@
import { NextResponse } from 'next/server';
import { ackAgentMessage } from '../../../../../../../lib/agent-mail';
export async function POST(
request: Request,
{ params }: { params: Promise<{ beadId: string; messageId: string }> }
): Promise<Response> {
const { messageId } = await params;
const url = new URL(request.url);
const agentId = url.searchParams.get('agent');
if (!agentId) {
return NextResponse.json({ ok: false, error: 'agent param required' }, { status: 400 });
}
const result = await ackAgentMessage({ agent: agentId, message: messageId });
return NextResponse.json(result);
}

View file

@ -0,0 +1,18 @@
import { NextResponse } from 'next/server';
import { readAgentMessage } from '../../../../../../../lib/agent-mail';
export async function POST(
request: Request,
{ params }: { params: Promise<{ beadId: string; messageId: string }> }
): Promise<Response> {
const { messageId } = await params;
const url = new URL(request.url);
const agentId = url.searchParams.get('agent');
if (!agentId) {
return NextResponse.json({ ok: false, error: 'agent param required' }, { status: 400 });
}
const result = await readAgentMessage({ agent: agentId, message: messageId });
return NextResponse.json(result);
}

View file

@ -0,0 +1,33 @@
import { NextResponse } from 'next/server';
import { readIssuesFromDisk } from '../../../lib/read-issues';
import { activityEventBus } from '../../../lib/realtime';
import { buildSessionTaskFeed, getCommunicationSummary } from '../../../lib/agent-sessions';
export const dynamic = 'force-dynamic';
export async function GET(request: Request): Promise<Response> {
const url = new URL(request.url);
const projectRoot = url.searchParams.get('projectRoot') ?? process.cwd();
try {
const issues = await readIssuesFromDisk({ projectRoot, preferBd: true });
const activity = activityEventBus.getHistory(projectRoot);
const communication = await getCommunicationSummary();
const feed = buildSessionTaskFeed(issues, activity, communication);
return NextResponse.json({ ok: true, feed });
} catch (error) {
console.error('[API/Sessions] Failed to load session feed:', error);
return NextResponse.json(
{
ok: false,
error: {
classification: 'unknown',
message: error instanceof Error ? error.message : 'Failed to load session feed.',
},
},
{ status: 500 },
);
}
}

45
src/app/sessions/page.tsx Normal file
View file

@ -0,0 +1,45 @@
import { SessionsPage } from '../../components/sessions/sessions-page';
import { readIssuesForScope } from '../../lib/aggregate-read';
import { resolveProjectScope } from '../../lib/project-scope';
import { listProjects } from '../../lib/registry';
import { listAgents } from '../../lib/agent-registry';
export const dynamic = 'force-dynamic';
interface PageProps {
searchParams?: Promise<Record<string, string | string[] | undefined>>;
}
export default async function Page({ searchParams }: PageProps) {
const params = (await searchParams) ?? {};
const requestedProjectKey = typeof params.project === 'string' ? params.project : null;
const requestedMode = typeof params.mode === 'string' ? params.mode : null;
const registryProjects = await listProjects();
const agentsResult = await listAgents({});
const agents = agentsResult.data ?? [];
const scope = resolveProjectScope({
currentProjectRoot: process.cwd(),
registryProjects,
requestedProjectKey,
requestedMode,
});
const issues = await readIssuesForScope({
mode: scope.mode,
selected: scope.selected,
scopeOptions: scope.options,
preferBd: true,
});
return (
<SessionsPage
issues={issues}
agents={agents}
projectRoot={scope.selected.root}
projectScopeKey={scope.selected.key}
projectScopeOptions={scope.options}
projectScopeMode={scope.mode}
/>
);
}

View file

@ -0,0 +1,423 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import type { AgentMessage } from '../../lib/agent-mail';
import type { AgentMetrics } from '../../lib/agent-sessions';
import type { BeadIssue } from '../../lib/types';
import { KanbanDetail } from '../kanban/kanban-detail';
interface ThreadItem {
type: 'activity' | 'message' | 'interaction';
id: string;
timestamp: string;
data: any;
}
interface ConversationDrawerProps {
beadId: string | null;
bead: BeadIssue | null;
agentId?: string | null;
open: boolean;
onClose: () => void;
projectRoot: string;
onActivity?: () => void;
showAgentContext?: boolean;
onBackToAgent?: () => void;
embedded?: boolean;
refreshTrigger?: number;
}
export function ConversationDrawer({
beadId,
bead,
agentId,
open,
onClose,
projectRoot,
onActivity,
showAgentContext,
onBackToAgent,
embedded = false,
refreshTrigger = 0
}: ConversationDrawerProps) {
const [thread, setThread] = useState<ThreadItem[]>([]);
const [loading, setLoading] = useState(false);
const [commentText, setCommentText] = useState('');
const [submitting, setSubmitting] = useState(false);
const [metrics, setMetrics] = useState<AgentMetrics | null>(null);
const [showSummary, setShowSummary] = useState(false);
const fetchConversation = useCallback(async (options: { silent?: boolean } = {}) => {
if (!beadId) return;
if (!options.silent) setLoading(true);
try {
const res = await fetch(`/api/sessions/${beadId}/conversation?projectRoot=${encodeURIComponent(projectRoot)}&_t=${Date.now()}`);
const data = await res.json();
if (data.ok) {
setThread(data.thread);
}
} catch (err) {
if (!options.silent) console.error('Failed to fetch conversation', err);
} finally {
if (!options.silent) setLoading(false);
}
}, [beadId, projectRoot]);
const fetchAgentMetrics = useCallback(async () => {
if (!agentId) return;
try {
const res = await fetch(`/api/agents/${agentId}/stats?projectRoot=${encodeURIComponent(projectRoot)}&_t=${Date.now()}`);
const data = await res.json();
if (data.ok) {
setMetrics(data.metrics);
}
} catch (err) {
console.error('Failed to fetch agent metrics', err);
}
}, [agentId, projectRoot]);
useEffect(() => {
if (open) {
if (beadId) fetchConversation({ silent: refreshTrigger > 0 });
if (agentId) fetchAgentMetrics();
} else {
setThread([]);
setMetrics(null);
setShowSummary(false);
}
}, [open, beadId, agentId, fetchConversation, fetchAgentMetrics, refreshTrigger]);
// Handle escape key
useEffect(() => {
if (!open || embedded) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [open, onClose, embedded]);
const handleAddComment = async (e: React.FormEvent) => {
e.preventDefault();
if (!commentText.trim() || !beadId) return;
setSubmitting(true);
try {
const res = await fetch(`/api/sessions/${beadId}/comment`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ projectRoot, text: commentText })
});
if (res.ok) {
setCommentText('');
await fetchConversation();
onActivity?.();
}
} catch (err) {
console.error('Failed to add comment', err);
} finally {
setSubmitting(false);
}
};
const handleMessageAction = async (messageId: string, action: 'read' | 'ack') => {
if (!beadId) return;
const message = thread.find(t => t.id === messageId)?.data as AgentMessage;
if (!message) return;
try {
const res = await fetch(`/api/sessions/${beadId}/messages/${messageId}/${action}?agent=${encodeURIComponent(message.to_agent)}`, {
method: 'POST'
});
if (res.ok) {
await fetchConversation();
onActivity?.();
}
} catch (err) {
console.error(`Failed to ${action} message`, err);
}
};
const content = (
<div className={`flex h-full w-full flex-col border-l border-white/10 bg-[#0b0c10]/95 shadow-[-32px_0_64px_rgba(0,0,0,0.5)] backdrop-blur-3xl overflow-hidden`}>
<header className="flex items-center justify-between border-b border-white/5 bg-white/[0.02] px-6 py-4 flex-none">
<div className="flex items-center gap-4">
{showAgentContext && (
<button
onClick={onBackToAgent}
className="group flex h-8 w-8 items-center justify-center rounded-full border border-white/10 bg-white/5 transition-all hover:bg-white/10 active:scale-90"
>
<span className="text-lg text-text-muted group-hover:text-text-strong">&larr;</span>
</button>
)}
<div className="flex flex-col">
<span className="system-data text-[10px] font-bold uppercase tracking-[0.2em] text-text-muted/50">
{beadId ? `Task ${beadId}` : `Agent ${agentId}`}
</span>
<h2 className="ui-text text-sm font-bold text-text-strong truncate max-w-[12rem]">
{beadId ? (bead?.title || 'Conversation') : 'Agent Scorecard'}
</h2>
</div>
</div>
<div className="flex items-center gap-2">
{beadId && (
<button
onClick={() => setShowSummary(!showSummary)}
className={`rounded-lg border px-3 py-1.5 text-[10px] font-black uppercase tracking-widest transition-all active:scale-95 ${
showSummary
? 'border-sky-500 bg-sky-500/10 text-sky-300'
: 'border-white/10 bg-white/5 text-text-muted hover:bg-white/10'
}`}
>
{showSummary ? 'Thread' : 'Summary'}
</button>
)}
<button
onClick={onClose}
className="rounded-xl border border-white/10 bg-white/5 px-4 py-1.5 text-xs font-bold text-text-body transition-all hover:bg-white/10 active:scale-95"
>
Close
</button>
</div>
</header>
<div className="flex-1 overflow-y-auto p-6 space-y-8 custom-scrollbar">
{beadId ? (
showSummary ? (
<div className="animate-fade-in">
<KanbanDetail
issue={bead}
framed={false}
projectRoot={projectRoot}
onIssueUpdated={onActivity}
/>
</div>
) : (
// Task View
loading ? (
<div className="flex h-full items-center justify-center text-text-muted">
<span className="animate-pulse">Loading thread...</span>
</div>
) : thread.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center text-center text-text-muted/40 px-8 gap-4">
<div className="h-12 w-12 rounded-full border border-dashed border-white/20 flex items-center justify-center text-xl">💬</div>
<p className="ui-text text-xs italic">No activity or messages yet for this task.</p>
</div>
) : (
<div className="space-y-6">
{thread.map(item => (
<ThreadRow
key={item.id}
item={item}
onRead={(id) => handleMessageAction(id, 'read')}
onAck={(id) => handleMessageAction(id, 'ack')}
/>
))}
</div>
)
)
) : agentId ? (
// Agent View
<div className="flex flex-col gap-6 animate-fade-in">
<div className="rounded-3xl border border-white/5 bg-white/[0.02] p-6 text-center">
<div className="mx-auto h-20 w-20 rounded-full bg-gradient-to-br from-sky-400 to-blue-600 p-[2px] mb-4 shadow-[0_0_20px_rgba(56,189,248,0.2)]">
<div className="flex h-full w-full items-center justify-center rounded-full bg-[#0b0c10] text-2xl font-black text-sky-400">
{agentId?.slice(0, 2).toUpperCase()}
</div>
</div>
<h3 className="ui-text text-xl font-bold text-text-strong">{agentId}</h3>
<p className="ui-text text-xs text-text-muted mt-1 uppercase tracking-widest">Active Operative</p>
</div>
<div className="grid grid-cols-2 gap-4">
<StatBlock label="Active Missions" value={String(metrics?.activeTasks ?? 0)} color="text-sky-400" />
<StatBlock label="Recent Success" value={String(metrics?.completedTasks ?? 0)} color="text-emerald-400" />
</div>
<section className="space-y-4">
<h4 className="ui-text text-[10px] font-black uppercase tracking-[0.2em] text-text-muted/40 px-2">Recent Wins</h4>
<div className="flex flex-col gap-2">
{metrics?.recentWins.length ? metrics.recentWins.map(win => (
<div key={win.id} className="rounded-2xl border border-white/5 bg-white/[0.02] p-4 group hover:border-emerald-500/30 transition-colors">
<p className="system-data text-[10px] font-bold text-emerald-400/60 uppercase tracking-widest">{win.id}</p>
<p className="ui-text text-[11px] text-text-body mt-1 font-bold group-hover:text-text-strong">{win.title}</p>
</div>
)) : (
<p className="ui-text text-xs italic text-text-muted/30 px-2">No completed missions recorded.</p>
)}
</div>
</section>
</div>
) : (
<div className="flex h-full items-center justify-center text-text-muted/20">
<p className="ui-text text-xs uppercase tracking-widest font-black">Context Inactive</p>
</div>
)}
</div>
{beadId && !showSummary && (
<footer className="border-t border-white/5 bg-white/[0.01] p-6 flex-none shadow-[0_-12px_32px_rgba(0,0,0,0.2)]">
<form onSubmit={handleAddComment} className="space-y-4">
<textarea
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
placeholder="Type a message or add a comment..."
rows={2}
className="w-full rounded-2xl border border-white/10 bg-black/40 p-4 text-sm text-text-body outline-none transition-all focus:border-sky-500/50 focus:ring-1 focus:ring-sky-500/20 placeholder:text-text-muted/30 shadow-inner resize-none"
/>
<div className="flex justify-end">
<button
type="submit"
disabled={submitting || !commentText.trim()}
className="rounded-2xl bg-sky-500 px-6 py-2.5 text-xs font-bold text-white shadow-[0_8px_20px_rgba(14,165,233,0.3)] transition-all hover:bg-sky-400 active:scale-95 disabled:opacity-50 disabled:active:scale-100"
>
{submitting ? 'Sending...' : 'Send Message'}
</button>
</div>
</form>
</footer>
)}
</div>
);
if (embedded) {
return content;
}
return (
<AnimatePresence>
{open && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
className="fixed inset-0 z-40 bg-black/60 backdrop-blur-md"
/>
<motion.aside
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
className="fixed right-0 top-0 z-50 flex h-full w-full max-w-lg flex-col"
>
{content}
</motion.aside>
</>
)}
</AnimatePresence>
);
}
function StatBlock({ label, value, color }: { label: string, value: string, color: string }) {
return (
<div className="rounded-2xl border border-white/5 bg-white/[0.01] p-4 text-center">
<p className={`system-data text-2xl font-black ${color}`}>{value}</p>
<p className="ui-text text-[10px] font-bold text-text-muted/40 uppercase tracking-widest mt-1">{label}</p>
</div>
);
}
function getCategoryLabel(category: string): string {
switch (category) {
case 'HANDOFF': return 'Passed to';
case 'BLOCKED': return 'Needs input';
case 'DECISION': return 'Deciding';
default: return 'Update';
}
}
function ThreadRow({ item, onRead, onAck }: {
item: ThreadItem;
onRead: (id: string) => void;
onAck: (id: string) => void;
}) {
const isMessage = item.type === 'message' || item.type === 'interaction';
const data = item.data;
if (item.type === 'interaction') {
return (
<div className="flex flex-col gap-3 w-full items-start">
<div className="flex items-center gap-3">
<div className="h-6 w-6 rounded-lg bg-zinc-500/20 flex items-center justify-center text-[10px] font-black text-zinc-400 border border-white/5">
{data.actor.slice(0, 2).toUpperCase()}
</div>
<span className="ui-text text-xs font-bold text-text-strong">{data.actor}</span>
<span className="ui-text text-[9px] font-black text-text-muted/40 uppercase tracking-widest px-2 py-0.5 rounded-full bg-white/5 border border-white/5">
Comment
</span>
<time className="system-data ml-auto text-[10px] text-text-muted/20">
{new Date(data.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</time>
</div>
<div className="relative rounded-3xl border border-white/10 bg-white/[0.03] p-5 text-sm text-text-body shadow-2xl">
<p className="text-[13px] leading-relaxed opacity-90 whitespace-pre-wrap">{data.text}</p>
</div>
</div>
);
}
return (
<div className={`group flex flex-col gap-3 ${isMessage ? 'items-start' : 'items-center'}`}>
{!isMessage ? (
// Activity Event
<div className="flex items-center gap-4 w-full">
<div className="h-px flex-1 bg-gradient-to-r from-transparent via-white/[0.05] to-transparent" />
<div className="flex items-center gap-3 rounded-full border border-white/5 bg-white/[0.02] px-4 py-1.5 shadow-sm">
<span className="ui-text text-[10px] font-bold text-text-muted/60">
<span className="text-text-strong/80">{data.actor}</span> {data.kind.replace('_', ' ')}
</span>
<time className="system-data text-[9px] text-text-muted/30 whitespace-nowrap">
{new Date(data.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</time>
</div>
<div className="h-px flex-1 bg-gradient-to-r from-transparent via-white/[0.05] to-transparent" />
</div>
) : (
// Agent Message
<div className="flex flex-col gap-3 w-full">
<div className="flex items-center gap-3">
<div className="h-6 w-6 rounded-lg bg-sky-500/20 flex items-center justify-center text-[10px] font-black text-sky-400 border border-sky-500/20">
{data.from_agent.slice(0, 2).toUpperCase()}
</div>
<span className="ui-text text-xs font-bold text-text-strong">{data.from_agent}</span>
<span className="ui-text text-[9px] font-black text-text-muted/40 uppercase tracking-widest px-2 py-0.5 rounded-full bg-white/5 border border-white/5">
{getCategoryLabel(data.category)}
</span>
<time className="system-data ml-auto text-[10px] text-text-muted/20">
{new Date(data.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</time>
</div>
<div className={`relative rounded-3xl border p-5 text-sm ${data.state === 'acked' ? 'border-emerald-500/20 bg-emerald-500/5 text-emerald-100/80 shadow-none' : 'border-white/10 bg-white/[0.03] text-text-body shadow-2xl'}`}>
<p className="font-bold mb-2 text-text-strong">{data.subject}</p>
<p className="text-[13px] leading-relaxed opacity-90 whitespace-pre-wrap">{data.body}</p>
{(data.state === 'unread' || (data.requires_ack && data.state !== 'acked')) && (
<div className="mt-5 flex gap-3 border-t border-white/5 pt-4">
{data.state === 'unread' && (
<button
onClick={() => onRead(data.message_id)}
className="ui-text rounded-xl border border-white/10 bg-white/5 px-4 py-1.5 text-[11px] font-bold text-text-body hover:bg-white/10 transition-all active:scale-95"
>
Mark Seen
</button>
)}
{data.requires_ack && data.state !== 'acked' && (
<button
onClick={() => onAck(data.message_id)}
className="ui-text rounded-xl border border-sky-500/30 bg-sky-500/10 px-4 py-1.5 text-[11px] font-bold text-sky-300 hover:bg-sky-500/20 transition-all active:scale-95 shadow-lg shadow-sky-500/10"
>
Accept Handoff
</button>
)}
</div>
)}
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,79 @@
'use client';
import { motion } from 'framer-motion';
import type { SessionTaskCard } from '../../lib/agent-sessions';
import { statusBorder, statusDotColor, statusGradient, sessionStateGlow } from '../shared/status-utils';
interface SessionFeedCardProps {
card: SessionTaskCard;
onSelect: (id: string) => void;
isHighlighted?: boolean;
}
export function SessionFeedCard({ card, onSelect, isHighlighted }: SessionFeedCardProps) {
return (
<motion.article
layout
onClick={() => onSelect(card.id)}
className={`relative w-full cursor-pointer rounded-[1.25rem] border p-[1rem] text-left transition-all duration-200 ${
isHighlighted
? 'border-sky-500 bg-sky-500/10 ring-1 ring-sky-500/50 scale-[1.02] shadow-[0_0_20px_rgba(56,189,248,0.15)]'
: `${statusBorder(card.status)} ${statusGradient(card.status)} hover:bg-white/[0.04]`
} ${sessionStateGlow(card.sessionState)}`}
>
<div className="flex gap-[0.75rem]">
{/* Compact Avatar */}
<div className="flex-none">
<div className="h-[2.5rem] w-[2.5rem] rounded-xl bg-gradient-to-br from-zinc-700 to-zinc-900 flex items-center justify-center border border-white/5 shadow-inner">
<span className="ui-text text-[0.75rem] font-black text-zinc-400">
{card.owner?.slice(0, 2).toUpperCase() || '??'}
</span>
</div>
</div>
{/* Dense Headline Content */}
<div className="flex-1 min-w-0">
<header className="flex items-center justify-between gap-[0.5rem]">
<div className="flex flex-wrap items-center gap-[0.4rem]">
<span className="ui-text text-[0.8rem] font-black text-text-strong tracking-tight">{card.owner || 'Unassigned'}</span>
<span className="ui-text text-[0.7rem] text-text-muted/50">pulled</span>
<span className="system-data text-[0.7rem] font-black text-sky-400/80 uppercase tracking-widest">{card.id}</span>
</div>
<time className="system-data text-[0.65rem] text-text-muted/30 whitespace-nowrap">
{new Date(card.lastActivityAt || '').toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</time>
</header>
<div className="mt-[0.25rem]">
<h3 className="ui-text text-[0.85rem] font-bold leading-tight text-text-body/90 line-clamp-2 group-hover:text-text-strong">
{card.title}
</h3>
</div>
{card.communication.latestSnippet && (
<div className="mt-[0.75rem] relative rounded-xl bg-black/40 p-[0.75rem] border border-white/5 shadow-inner">
<p className="ui-text text-[0.75rem] italic leading-snug text-text-muted/80 line-clamp-2">
&quot;{card.communication.latestSnippet}&quot;
</p>
</div>
)}
<footer className="mt-[0.75rem] flex items-center justify-between">
<div className="flex items-center gap-[0.5rem]">
<span className={`h-[0.35rem] w-[0.35rem] rounded-full ${statusDotColor(card.status)} shadow-[0_0_6px_currentColor]`} />
<span className="system-data text-[0.6rem] font-black text-text-muted/40 uppercase tracking-widest">
{card.status}
</span>
</div>
<div className="flex gap-[0.4rem]">
<span className="ui-text text-[0.65rem] font-bold text-sky-400/60 uppercase tracking-tighter px-[0.4rem] py-[0.1rem] rounded-md bg-white/5 border border-white/5">
{card.sessionState}
</span>
</div>
</footer>
</div>
</div>
</motion.article>
);
}

View file

@ -0,0 +1,69 @@
'use client';
import { useMemo } from 'react';
import type { EpicBucket } from '../../lib/agent-sessions';
import { SessionFeedCard } from './session-feed-card';
interface SessionTaskFeedProps {
feed: EpicBucket[];
selectedEpicId: string | null;
onSelectTask: (id: string) => void;
highlightTaskId?: string | null;
}
export function SessionTaskFeed({ feed, selectedEpicId, onSelectTask, highlightTaskId }: SessionTaskFeedProps) {
const filteredFeed = useMemo(() => {
if (!selectedEpicId) return feed;
return feed.filter(b => b.epic.id === selectedEpicId);
}, [feed, selectedEpicId]);
if (filteredFeed.length === 0) {
return (
<div className="flex h-64 flex-col items-center justify-center gap-2 rounded-3xl border border-dashed border-white/10 bg-white/[0.01]">
<p className="ui-text text-sm font-bold text-text-muted">No sessions found</p>
<p className="ui-text text-xs text-text-muted/50 text-center max-w-xs px-6">
Try selecting a different epic bucket or check if any tasks are active.
</p>
</div>
);
}
return (
<div className="space-y-16 pb-24">
{filteredFeed.map(bucket => (
<section key={bucket.epic.id} className="space-y-[1.5rem]">
<header className="flex items-center gap-[1rem] px-[0.5rem] group">
<div className="flex flex-col">
<div className="flex items-center gap-2">
<span className="ui-text text-[0.65rem] font-black uppercase tracking-[0.2em] text-sky-400/40">EPIC</span>
<h2 className="ui-text text-[0.9rem] font-black uppercase tracking-tight text-text-strong group-hover:text-sky-300 transition-colors">
{bucket.epic.title}
</h2>
</div>
<span className="system-data text-[0.65rem] font-bold text-text-muted/30 tracking-widest">{bucket.epic.id}</span>
</div>
<div className="h-px flex-1 bg-gradient-to-r from-white/[0.08] to-transparent" />
<div className="flex items-center gap-3">
<span className="system-data rounded-full border border-white/5 bg-white/[0.02] px-[0.6rem] py-[0.2rem] text-[0.7rem] font-black text-text-muted/60 shadow-inner">
{bucket.tasks.length} MISSION{bucket.tasks.length === 1 ? '' : 'S'}
</span>
</div>
</header>
<div className="grid grid-cols-[repeat(auto-fill,minmax(20rem,1fr))] gap-[1.5rem]">
{bucket.tasks.map(task => (
<SessionFeedCard
key={task.id}
card={task}
onSelect={onSelectTask}
isHighlighted={highlightTaskId === task.id}
/>
))}
</div>
</section>
))}
</div>
);
}

View file

@ -0,0 +1,128 @@
'use client';
import type { AgentRecord } from '../../lib/agent-registry';
import { ProjectScopeControls } from '../shared/project-scope-controls';
import type { ProjectScopeOption } from '../../lib/project-scope';
interface SessionsHeaderProps {
agents: AgentRecord[];
activeAgentId: string | null;
onSelectAgent: (id: string | null) => void;
projectScopeKey: string;
projectScopeMode: 'single' | 'aggregate';
projectScopeOptions: ProjectScopeOption[];
stats?: {
active: number;
needsInput: number;
completed: number;
};
}
export function SessionsHeader({
agents,
activeAgentId,
onSelectAgent,
projectScopeKey,
projectScopeMode,
projectScopeOptions,
stats,
}: SessionsHeaderProps) {
return (
<header className="sticky top-0 z-50 flex flex-col border-b border-white/5 bg-[#0b0c10]/60 backdrop-blur-3xl shadow-2xl">
{/* Row 1: Agent Command Deck */}
<div className="flex h-14 items-center gap-4 px-6 border-b border-white/[0.03]">
<div className="flex-none pr-4 border-r border-white/5 mr-2">
<h1 className="ui-text text-[0.6rem] font-black uppercase tracking-[0.3em] text-text-strong/30">Command</h1>
<p className="ui-text text-[0.7rem] font-black text-text-strong">OPERATIVES</p>
</div>
<div className="flex items-center gap-2 overflow-x-auto no-scrollbar py-1">
{agents.map((agent) => (
<AgentStation
key={agent.agent_id}
agent={agent}
isSelected={activeAgentId === agent.agent_id}
onSelect={onSelectAgent}
/>
))}
</div>
</div>
{/* Row 2: Management & Meta */}
<div className="flex h-10 items-center justify-between px-6 bg-white/[0.01]">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<span className="ui-text text-[0.6rem] font-black uppercase tracking-[0.2em] text-sky-400/30 whitespace-nowrap">Load Pulse</span>
{stats && (
<div className="flex items-center gap-1.5">
<StatPill label="Active" value={stats.active} color="bg-emerald-500" />
<StatPill label="Blocked" value={stats.needsInput} color="bg-rose-500" />
</div>
)}
</div>
</div>
<div className="flex items-center gap-4 scale-75 origin-right opacity-70 hover:opacity-100 transition-opacity">
<ProjectScopeControls
projectScopeKey={projectScopeKey}
projectScopeMode={projectScopeMode}
projectScopeOptions={projectScopeOptions}
/>
</div>
</div>
</header>
);
}
function AgentStation({
agent,
isSelected,
onSelect
}: {
agent: AgentRecord,
isSelected: boolean,
onSelect: (id: string | null) => void
}) {
const isActive = agent.status !== 'idle';
return (
<button
onClick={() => onSelect(isSelected ? null : agent.agent_id)}
className={`flex-none group flex w-[9.5rem] items-center gap-2 rounded-lg border px-2 py-1.5 transition-all duration-300 ${
isSelected
? 'border-sky-500/50 bg-sky-500/10 shadow-[0_0_10px_rgba(14,165,233,0.1)]'
: 'border-white/5 bg-white/[0.01] hover:bg-white/5'
}`}
>
<div className="relative flex-none">
<div className={`h-7 w-7 rounded-md bg-gradient-to-br from-zinc-700 to-zinc-900 flex items-center justify-center border border-white/10 shadow-inner transition-transform duration-300 ${isSelected ? 'scale-90' : 'group-hover:scale-105'}`}>
<span className="ui-text text-[0.6rem] font-black text-zinc-400">
{agent.agent_id.slice(0, 2).toUpperCase()}
</span>
</div>
<span className={`absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full border-2 border-[#0b0c10] ${
isActive ? 'bg-emerald-500 shadow-[0_0_10px_rgba(16,185,129,0.8)]' : 'bg-zinc-600'
}`} />
</div>
<div className="flex flex-col items-start min-w-0">
<span className={`ui-text text-[0.65rem] font-black truncate w-full transition-colors ${isSelected ? 'text-sky-300' : 'text-text-body'}`}>
{agent.agent_id}
</span>
<span className="system-data text-[0.5rem] font-bold text-text-muted/30 uppercase tracking-tighter">
{isActive ? 'On Mission' : 'Standby'}
</span>
</div>
</button>
);
}
function StatPill({ label, value, color }: { label: string, value: number, color: string }) {
return (
<div className="flex items-center gap-1 rounded-full border border-white/5 bg-white/5 px-1.5 py-0.5">
<span className={`h-1 w-1 rounded-full ${color}`} />
<span className="system-data text-[8px] font-bold text-text-muted/60 uppercase tracking-tight">{label}</span>
<span className="system-data text-[8px] font-black text-text-strong">{value}</span>
</div>
);
}

View file

@ -0,0 +1,157 @@
'use client';
import { useState, useMemo } from 'react';
import { useBeadsSubscription } from '../../hooks/use-beads-subscription';
import { useSessionFeed } from '../../hooks/use-session-feed';
import { useTimelineStore } from '../timeline/timeline-store';
import type { BeadIssue } from '../../lib/types';
import type { ProjectScopeOption } from '../../lib/project-scope';
import type { AgentRecord } from '../../lib/agent-registry';
import { EpicChipStrip } from '../shared/epic-chip-strip';
import { SessionTaskFeed } from './session-task-feed';
import { ConversationDrawer } from './conversation-drawer';
import { SessionsHeader } from './sessions-header';
interface SessionsPageProps {
issues: BeadIssue[];
agents: AgentRecord[];
projectRoot: string;
projectScopeKey: string;
projectScopeOptions: ProjectScopeOption[];
projectScopeMode: 'single' | 'aggregate';
}
export function SessionsPage({
issues: initialIssues,
agents,
projectRoot,
projectScopeKey,
projectScopeOptions,
projectScopeMode,
}: SessionsPageProps) {
// 2. Session-specific feed
const { feed, loading, refresh: refreshFeed, stats } = useSessionFeed(projectRoot);
const {
selectedAgentId,
selectedTaskId,
setSelectedAgentId,
setSelectedTaskId,
backToAgent
} = useTimelineStore();
const [selectedEpicId, setSelectedEpicId] = useState<string | null>(null);
const [refreshTrigger, setRefreshTrigger] = useState(0);
// 1. Basic subscription for SSE invalidation
const { refresh: refreshIssues, issues: localIssues } = useBeadsSubscription(initialIssues, projectRoot, {
onUpdate: () => {
console.log('[Sessions] SSE update detected. Scheduling silent refresh...');
// Small delay to ensure backend files are flushed
setTimeout(() => {
void refreshFeed({ silent: true });
setRefreshTrigger(prev => prev + 1);
}, 150);
}
});
const epics = initialIssues.filter(i => i.issue_type === 'epic');
const beadCounts = new Map(feed.map(b => [b.epic.id, b.tasks.length]));
const selectedBead = useMemo(() =>
localIssues.find(i => i.id === selectedTaskId) || null,
[localIssues, selectedTaskId]
);
return (
<div className="flex min-h-screen flex-col bg-[#070709]">
<SessionsHeader
agents={agents}
activeAgentId={selectedAgentId}
onSelectAgent={setSelectedAgentId}
projectScopeKey={projectScopeKey}
projectScopeMode={projectScopeMode}
projectScopeOptions={projectScopeOptions}
stats={stats}
/>
<div className="flex flex-1 overflow-hidden">
{/* Main Activity Matrix */}
<main className="flex-1 overflow-y-auto custom-scrollbar">
<div className="mx-auto max-w-[90rem] px-[2rem] py-[2rem]">
<div className="mb-[2rem] overflow-x-auto pb-[0.5rem] no-scrollbar">
<EpicChipStrip
epics={epics}
selectedEpicId={selectedEpicId}
beadCounts={beadCounts}
onSelect={setSelectedEpicId}
/>
</div>
{loading ? (
<div className="flex h-[30rem] items-center justify-center text-text-muted">
<span className="animate-pulse tracking-[0.1em] uppercase text-[0.75rem] font-bold">
Synchronizing mission data...
</span>
</div>
) : (
<SessionTaskFeed
feed={feed}
selectedEpicId={selectedEpicId}
onSelectTask={setSelectedTaskId}
highlightTaskId={selectedTaskId}
/>
)}
</div>
</main>
{/* Integrated Context Sidebar (Desktop Only) */}
<aside className={`hidden xl:block transition-all duration-500 ease-in-out border-l border-white/5 bg-[#0b0c10]/40 backdrop-blur-3xl overflow-hidden relative ${
(selectedTaskId || selectedAgentId) ? 'w-[28rem] opacity-100' : 'w-0 opacity-0 border-l-0'
}`}>
<ConversationDrawer
beadId={selectedTaskId}
bead={selectedBead}
agentId={selectedAgentId}
open={Boolean(selectedTaskId || selectedAgentId)}
onClose={() => {
setSelectedTaskId(null);
setSelectedAgentId(null);
}}
projectRoot={projectRoot}
onActivity={() => {
void refreshIssues();
void refreshFeed();
}}
showAgentContext={Boolean(selectedAgentId && selectedTaskId)}
onBackToAgent={backToAgent}
embedded={true}
refreshTrigger={refreshTrigger}
/>
</aside>
</div>
{/* Mobile/Tablet Drawer (fallback for small screens) */}
<div className="xl:hidden">
<ConversationDrawer
beadId={selectedTaskId}
bead={selectedBead}
agentId={selectedAgentId}
open={Boolean(selectedTaskId || selectedAgentId)}
onClose={() => {
setSelectedTaskId(null);
setSelectedAgentId(null);
}}
projectRoot={projectRoot}
onActivity={() => {
void refreshIssues();
void refreshFeed();
}}
showAgentContext={Boolean(selectedAgentId && selectedTaskId)}
onBackToAgent={backToAgent}
refreshTrigger={refreshTrigger}
/>
</div>
</div>
);
}

View file

@ -0,0 +1,57 @@
export function statusGradient(status: string): string {
switch (status) {
case 'ready':
case 'open':
return 'bg-[linear-gradient(145deg,rgba(34,45,42,0.92)_0%,rgba(24,32,30,0.88)_50%,rgba(18,28,26,0.9)_100%)]';
case 'in_progress':
return 'bg-[linear-gradient(145deg,rgba(42,40,32,0.92)_0%,rgba(32,30,24,0.88)_50%,rgba(26,24,18,0.9)_100%)]';
case 'blocked':
return 'bg-[linear-gradient(145deg,rgba(60,24,30,0.95)_0%,rgba(45,18,24,0.9)_50%,rgba(32,12,16,0.92)_100%)]';
case 'closed':
return 'bg-[linear-gradient(145deg,rgba(28,30,34,0.75)_0%,rgba(22,24,28,0.72)_50%,rgba(18,20,24,0.75)_100%)] opacity-75';
default:
return 'bg-[linear-gradient(145deg,rgba(38,40,48,0.92)_0%,rgba(28,30,36,0.88)_50%,rgba(22,24,30,0.9)_100%)]';
}
}
export function statusBorder(status: string): string {
switch (status) {
case 'ready':
case 'open':
return 'border-emerald-500/20';
case 'in_progress':
return 'border-amber-500/20';
case 'blocked':
return 'border-rose-500/20';
case 'closed':
return 'border-rose-500/30';
default:
return 'border-white/[0.06]';
}
}
export function statusDotColor(status: string): string {
switch (status) {
case 'ready':
case 'open':
return 'bg-emerald-400';
case 'in_progress':
return 'bg-amber-400';
case 'blocked':
return 'bg-rose-400';
case 'closed':
return 'bg-slate-400';
default:
return 'bg-slate-400';
}
}
export function sessionStateGlow(state: string): string {
switch (state) {
case 'active': return 'shadow-[0_0_12px_rgba(74,222,128,0.3)] border-emerald-500/30';
case 'needs_input': return 'shadow-[0_0_12px_rgba(248,113,113,0.3)] border-rose-500/30';
case 'stale': return 'opacity-60 grayscale-[0.5]';
case 'completed': return 'opacity-80';
default: return '';
}
}

View file

@ -0,0 +1,46 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import type { EpicBucket } from '../lib/agent-sessions';
export function useSessionFeed(projectRoot: string) {
const [feed, setFeed] = useState<EpicBucket[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchFeed = useCallback(async (options: { silent?: boolean } = {}) => {
if (!options.silent) setLoading(true);
try {
const res = await fetch(`/api/sessions?projectRoot=${encodeURIComponent(projectRoot)}&_t=${Date.now()}`, {
cache: 'no-store',
});
if (!res.ok) throw new Error('Failed to fetch session feed');
const data = await res.json();
if (data.ok) {
setFeed(data.feed);
} else {
throw new Error(data.error?.message || 'Failed to fetch session feed');
}
} catch (err) {
if (!options.silent) setError(err instanceof Error ? err.message : String(err));
} finally {
if (!options.silent) setLoading(false);
}
}, [projectRoot]);
useEffect(() => {
fetchFeed();
}, [fetchFeed]);
return {
feed,
loading,
error,
refresh: fetchFeed,
stats: {
active: feed.reduce((acc, b) => acc + b.tasks.filter(t => t.sessionState === 'active').length, 0),
needsInput: feed.reduce((acc, b) => acc + b.tasks.filter(t => t.sessionState === 'needs_input').length, 0),
completed: feed.reduce((acc, b) => acc + b.tasks.filter(t => t.sessionState === 'completed').length, 0),
}
};
}