diff --git a/src/app/api/sessions/[beadId]/comment/route.ts b/src/app/api/sessions/[beadId]/comment/route.ts new file mode 100644 index 0000000..6d4bb4b --- /dev/null +++ b/src/app/api/sessions/[beadId]/comment/route.ts @@ -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 { + 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 }); + } +} diff --git a/src/app/api/sessions/[beadId]/conversation/route.ts b/src/app/api/sessions/[beadId]/conversation/route.ts new file mode 100644 index 0000000..bac7b34 --- /dev/null +++ b/src/app/api/sessions/[beadId]/conversation/route.ts @@ -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 { + 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 }); + } +} \ No newline at end of file diff --git a/src/app/api/sessions/[beadId]/messages/[messageId]/ack/route.ts b/src/app/api/sessions/[beadId]/messages/[messageId]/ack/route.ts new file mode 100644 index 0000000..77f1459 --- /dev/null +++ b/src/app/api/sessions/[beadId]/messages/[messageId]/ack/route.ts @@ -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 { + 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); +} diff --git a/src/app/api/sessions/[beadId]/messages/[messageId]/read/route.ts b/src/app/api/sessions/[beadId]/messages/[messageId]/read/route.ts new file mode 100644 index 0000000..19aec9b --- /dev/null +++ b/src/app/api/sessions/[beadId]/messages/[messageId]/read/route.ts @@ -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 { + 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); +} diff --git a/src/app/api/sessions/route.ts b/src/app/api/sessions/route.ts new file mode 100644 index 0000000..0478b46 --- /dev/null +++ b/src/app/api/sessions/route.ts @@ -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 { + 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 }, + ); + } +} diff --git a/src/app/sessions/page.tsx b/src/app/sessions/page.tsx new file mode 100644 index 0000000..ca672d7 --- /dev/null +++ b/src/app/sessions/page.tsx @@ -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>; +} + +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 ( + + ); +} diff --git a/src/components/sessions/conversation-drawer.tsx b/src/components/sessions/conversation-drawer.tsx new file mode 100644 index 0000000..ca002fd --- /dev/null +++ b/src/components/sessions/conversation-drawer.tsx @@ -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([]); + const [loading, setLoading] = useState(false); + const [commentText, setCommentText] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [metrics, setMetrics] = useState(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 = ( +
+
+
+ {showAgentContext && ( + + )} +
+ + {beadId ? `Task ${beadId}` : `Agent ${agentId}`} + +

+ {beadId ? (bead?.title || 'Conversation') : 'Agent Scorecard'} +

+
+
+
+ {beadId && ( + + )} + +
+
+ +
+ {beadId ? ( + showSummary ? ( +
+ +
+ ) : ( + // Task View + loading ? ( +
+ Loading thread... +
+ ) : thread.length === 0 ? ( +
+
💬
+

No activity or messages yet for this task.

+
+ ) : ( +
+ {thread.map(item => ( + handleMessageAction(id, 'read')} + onAck={(id) => handleMessageAction(id, 'ack')} + /> + ))} +
+ ) + ) + ) : agentId ? ( + // Agent View +
+
+
+
+ {agentId?.slice(0, 2).toUpperCase()} +
+
+

{agentId}

+

Active Operative

+
+ +
+ + +
+ +
+

Recent Wins

+
+ {metrics?.recentWins.length ? metrics.recentWins.map(win => ( +
+

{win.id}

+

{win.title}

+
+ )) : ( +

No completed missions recorded.

+ )} +
+
+
+ ) : ( +
+

Context Inactive

+
+ )} +
+ + {beadId && !showSummary && ( +