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:
parent
28abfe3ce2
commit
f3558dc0d1
13 changed files with 1153 additions and 0 deletions
22
src/app/api/sessions/[beadId]/comment/route.ts
Normal file
22
src/app/api/sessions/[beadId]/comment/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
58
src/app/api/sessions/[beadId]/conversation/route.ts
Normal file
58
src/app/api/sessions/[beadId]/conversation/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
33
src/app/api/sessions/route.ts
Normal file
33
src/app/api/sessions/route.ts
Normal 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
45
src/app/sessions/page.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
423
src/components/sessions/conversation-drawer.tsx
Normal file
423
src/components/sessions/conversation-drawer.tsx
Normal 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">←</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>
|
||||
);
|
||||
}
|
||||
79
src/components/sessions/session-feed-card.tsx
Normal file
79
src/components/sessions/session-feed-card.tsx
Normal 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">
|
||||
"{card.communication.latestSnippet}"
|
||||
</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>
|
||||
);
|
||||
}
|
||||
69
src/components/sessions/session-task-feed.tsx
Normal file
69
src/components/sessions/session-task-feed.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
128
src/components/sessions/sessions-header.tsx
Normal file
128
src/components/sessions/sessions-header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
157
src/components/sessions/sessions-page.tsx
Normal file
157
src/components/sessions/sessions-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
src/components/shared/status-utils.tsx
Normal file
57
src/components/shared/status-utils.tsx
Normal 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 '';
|
||||
}
|
||||
}
|
||||
46
src/hooks/use-session-feed.ts
Normal file
46
src/hooks/use-session-feed.ts
Normal 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),
|
||||
}
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue