diff --git a/apps/web/package.json b/apps/web/package.json index 2e7a4a9..836138c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -31,6 +31,7 @@ "react": "19.2.3", "react-dom": "19.2.3", "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1", "tailwind-merge": "^3.4.0", "zod": "^4.3.6" }, diff --git a/apps/web/src/__tests__/middleware.test.ts b/apps/web/src/__tests__/middleware.test.ts index 69aef1a..691c6b1 100644 --- a/apps/web/src/__tests__/middleware.test.ts +++ b/apps/web/src/__tests__/middleware.test.ts @@ -109,7 +109,7 @@ describe('Middleware Route Protection', () => { expect(response.headers.get('location')).toBeNull() }) - it('redirects authenticated from /login to /dashboard', async () => { + it('redirects authenticated from /login to /tasks', async () => { const mockUser = { id: 'user-123', email: 'test@example.com', @@ -124,7 +124,7 @@ describe('Middleware Route Protection', () => { expect(response.status).toBe(307) expect(response.headers.get('location')).toBe( - 'http://localhost:3000/dashboard' + 'http://localhost:3000/tasks' ) }) }) @@ -249,7 +249,7 @@ describe('Middleware Route Protection', () => { expect(response.headers.get('location')).toBeNull() }) - it('redirects authenticated from /signup to /dashboard', async () => { + it('redirects authenticated from /signup to /tasks', async () => { const mockUser = { id: 'user-123', email: 'test@example.com', @@ -264,7 +264,7 @@ describe('Middleware Route Protection', () => { expect(response.status).toBe(307) expect(response.headers.get('location')).toBe( - 'http://localhost:3000/dashboard' + 'http://localhost:3000/tasks' ) }) }) diff --git a/apps/web/src/app/(dashboard)/dashboard-client-wrapper.tsx b/apps/web/src/app/(dashboard)/dashboard-client-wrapper.tsx new file mode 100644 index 0000000..6d3dc04 --- /dev/null +++ b/apps/web/src/app/(dashboard)/dashboard-client-wrapper.tsx @@ -0,0 +1,70 @@ +'use client' + +import { useState, createContext, useContext, useCallback } from 'react' +import { DashboardLayout } from '@/components/templates/DashboardLayout' + +interface DashboardContextValue { + /** Set content to override the right panel (replaces feed), or null to restore feed */ + setRightPanelOverride: (content: React.ReactNode | null) => void +} + +const DashboardContext = createContext({ + setRightPanelOverride: () => {}, +}) + +/** + * Hook to access the dashboard context for controlling the right panel content. + * Use `setRightPanelOverride` to replace the activity feed with custom content + * (e.g., TaskDetailInlinePanel), or pass null to restore the feed. + */ +export function useDashboardContext() { + return useContext(DashboardContext) +} + +interface DashboardClientWrapperProps { + /** Header content for the dashboard */ + header: React.ReactNode + /** Sidebar content (e.g., AgentSidebarWithPanel) */ + sidebar: React.ReactNode + /** Default right panel content (e.g., LiveFeed) */ + feed: React.ReactNode + /** Main content area */ + children: React.ReactNode + /** Additional CSS classes */ + className?: string +} + +/** + * DashboardClientWrapper is a client component that wraps DashboardLayout + * and provides context for child components to swap the right panel content. + * + * This bridges the gap between the server-side DashboardShell (which fetches data) + * and client-side components (like TasksClient) that need to control the layout. + */ +export function DashboardClientWrapper({ + header, + sidebar, + feed, + children, + className, +}: DashboardClientWrapperProps) { + const [rightPanelOverride, setRightPanelOverrideState] = useState(null) + + const setRightPanelOverride = useCallback((content: React.ReactNode | null) => { + setRightPanelOverrideState(content) + }, []) + + return ( + + + {children} + + + ) +} diff --git a/apps/web/src/app/(dashboard)/dashboard-shell.tsx b/apps/web/src/app/(dashboard)/dashboard-shell.tsx index 8ac42db..af8750a 100644 --- a/apps/web/src/app/(dashboard)/dashboard-shell.tsx +++ b/apps/web/src/app/(dashboard)/dashboard-shell.tsx @@ -1,6 +1,6 @@ import { redirect } from 'next/navigation' import { createClient } from '@/lib/supabase/server' -import { DashboardLayout } from '@/components/templates/DashboardLayout' +import { DashboardClientWrapper } from './dashboard-client-wrapper' import { Header } from '@/components/organisms/Header' import { AgentSidebarWithPanel, @@ -8,6 +8,7 @@ import { } from '@/components/organisms/AgentSidebarWithPanel' import { type AgentData } from '@/components/organisms/AgentSidebar' import { LiveFeed, type ActivityData } from '@/components/organisms/LiveFeed' +import type { AgentProfileData } from '@/components/templates' interface DashboardShellProps { children: React.ReactNode @@ -44,6 +45,7 @@ export async function DashboardShell({ children }: DashboardShellProps) { // Initialize empty data structures let agents: AgentData[] = [] + let agentProfiles = new Map() let activities: ActivityData[] = [] let pendingTasksCount = 0 @@ -60,8 +62,14 @@ export async function DashboardShell({ children }: DashboardShellProps) { status, blocked_reason, current_task_id, + status_reason, + status_since, agent_specs!inner ( - avatar_color + avatar_color, + description, + personality, + expertise, + collaborates_with ), tasks:current_task_id ( title @@ -84,6 +92,35 @@ export async function DashboardShell({ children }: DashboardShellProps) { : null, blockedReason: agent.blocked_reason, })) + + // Build extended agent profiles map for the sidebar panel + agentProfiles = new Map() + for (const agent of agentsData) { + const specs = agent.agent_specs as { + avatar_color?: string | null + description?: string | null + personality?: string | null + expertise?: string[] | null + collaborates_with?: string[] | null + } + agentProfiles.set(agent.id, { + id: agent.id, + name: agent.name, + role: agent.role, + status: agent.status as AgentProfileData['status'], + avatarColor: specs?.avatar_color ?? undefined, + currentTask: agent.tasks + ? (agent.tasks as { title?: string })?.title + : null, + blockedReason: agent.blocked_reason, + description: specs?.description ?? null, + personality: specs?.personality ?? null, + expertise: specs?.expertise ?? null, + collaborates_with: specs?.collaborates_with ?? null, + statusReason: agent.status_reason ?? null, + statusSince: agent.status_since ?? null, + }) + } } // Fetch recent activities with agent names @@ -148,19 +185,21 @@ export async function DashboardShell({ children }: DashboardShellProps) { const totalAgentsCount = agents.length return ( - } - sidebar={} + sidebar={} feed={} > {children} - + ) } diff --git a/apps/web/src/app/(dashboard)/dashboard/page.tsx b/apps/web/src/app/(dashboard)/dashboard/page.tsx index dd73638..a973f61 100644 --- a/apps/web/src/app/(dashboard)/dashboard/page.tsx +++ b/apps/web/src/app/(dashboard)/dashboard/page.tsx @@ -1,95 +1,5 @@ -import Link from 'next/link' import { redirect } from 'next/navigation' -import { Plus } from 'lucide-react' -import { createClient } from '@/lib/supabase/server' -import { Text, Button } from '@/components/atoms' -export default async function DashboardPage() { - const supabase = await createClient() - - // Get current user - const { - data: { user }, - } = await supabase.auth.getUser() - - if (!user) { - redirect('/login') - } - - // Fetch user's squads - RLS automatically filters to only their squads - const { data: squads, error } = await supabase - .from('squads') - .select('id, name, description, created_at') - .order('created_at', { ascending: false }) - - return ( -
-
- - Dashboard - - - Welcome back, {user.email} - -
- -
- - Your Squads - - - {error && ( -
- Error loading squads: {error.message} -
- )} - - {!error && squads?.length === 0 && ( -
- - You don't have any squads yet. - - - Create your first squad to get started with AI agent coordination. - - - - -
- )} - - {!error && squads && squads.length > 0 && ( -
- {squads.map((squad) => ( - - - {squad.name} - - {squad.description && ( - - {squad.description} - - )} - - Created{' '} - {new Date(squad.created_at!).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - })} - - - ))} -
- )} -
-
- ) +export default function DashboardPage() { + redirect('/tasks') } diff --git a/apps/web/src/app/(dashboard)/tasks/page.tsx b/apps/web/src/app/(dashboard)/tasks/page.tsx index e362a55..d13b02e 100644 --- a/apps/web/src/app/(dashboard)/tasks/page.tsx +++ b/apps/web/src/app/(dashboard)/tasks/page.tsx @@ -79,16 +79,6 @@ export default async function TasksPage() { return (
-
- - Tasks - - - {transformedTasks.length} {transformedTasks.length === 1 ? 'task' : 'tasks'} across your - squads - -
- {error && (
Error loading tasks: {error.message} diff --git a/apps/web/src/app/(dashboard)/tasks/tasks-client.tsx b/apps/web/src/app/(dashboard)/tasks/tasks-client.tsx index 0e598ab..8bc4f16 100644 --- a/apps/web/src/app/(dashboard)/tasks/tasks-client.tsx +++ b/apps/web/src/app/(dashboard)/tasks/tasks-client.tsx @@ -1,11 +1,24 @@ 'use client' -import { useState, useCallback, useEffect } from 'react' +import { useState, useCallback, useEffect, useMemo, useRef } from 'react' import { useSearchParams, useRouter } from 'next/navigation' import { KanbanBoard, type TaskData, type TaskStatus } from '@/components/organisms/KanbanBoard' import { TaskModal, type TaskFormValues } from '@/components/organisms/TaskModal' -import { createClient } from '@/lib/supabase/client' +import { TaskDetailPanel } from '@/components/templates/TaskDetailPanel' +import { StatusDot, Text, Icon } from '@/components/atoms' +import { DeliverableView } from '@/components/molecules/DeliverableView' +import { StatusFilterBar } from '@/components/molecules/StatusFilterBar' +import { createClient } from '@/lib/supabase/browser' + +interface FetchedDeliverable { + id: string + title: string + content: string | null + type: string + created_at: string + agents: { name: string } | null +} interface TasksClientProps { initialTasks: TaskData[] @@ -18,35 +31,123 @@ export function TasksClient({ initialTasks }: TasksClientProps) { // Local state for tasks (allows optimistic updates) const [tasks, setTasks] = useState(initialTasks) + // Filter state + const [activeFilter, setActiveFilter] = useState(null) + // Modal state const [selectedTask, setSelectedTask] = useState(null) const [isModalOpen, setIsModalOpen] = useState(false) - // Auto-open task modal from URL query parameter + // Detail panel state + const [isDetailOpen, setIsDetailOpen] = useState(false) + + // Deliverables state + const [deliverables, setDeliverables] = useState([]) + const [selectedDeliverable, setSelectedDeliverable] = useState(null) + const userDismissedDeliverable = useRef(false) + + // Compute status counts from all tasks + const statusCounts = useMemo(() => { + const counts: Record = {} + tasks.forEach((t) => { + counts[t.status] = (counts[t.status] || 0) + 1 + }) + return counts + }, [tasks]) + + // Filter tasks based on active filter + const filteredTasks = useMemo(() => { + if (!activeFilter) return tasks + return tasks.filter((t) => t.status === activeFilter) + }, [tasks, activeFilter]) + + // Count of tasks that are actively being worked + const activeTasksCount = tasks.filter((t) => + ['assigned', 'in_progress'].includes(t.status) + ).length + + // Status configurations for the filter bar + const statusConfigs = useMemo( + () => [ + { status: 'inbox', label: 'Inbox', count: statusCounts.inbox || 0, color: 'bg-text-muted' }, + { status: 'assigned', label: 'Assigned', count: statusCounts.assigned || 0, color: 'bg-priority-normal' }, + { status: 'in_progress', label: 'In Progress', count: statusCounts.in_progress || 0, color: 'bg-status-active' }, + { status: 'review', label: 'Review', count: statusCounts.review || 0, color: 'bg-status-idle' }, + { status: 'done', label: 'Done', count: statusCounts.done || 0, color: 'bg-status-active' }, + ], + [statusCounts] + ) + + // Auto-open task detail panel from URL query parameter useEffect(() => { const taskId = searchParams.get('task') if (taskId && tasks.length > 0) { const task = tasks.find((t) => t.id === taskId) if (task) { setSelectedTask(task) - setIsModalOpen(true) + setIsDetailOpen(true) } // Clear the query param to avoid re-opening on refresh router.replace('/tasks', { scroll: false }) } }, [searchParams, tasks, router]) + // Fetch deliverables when task detail panel opens + useEffect(() => { + if (!selectedTask || !isDetailOpen) { + setDeliverables([]) + setSelectedDeliverable(null) + userDismissedDeliverable.current = false + return + } + + const fetchDeliverables = async () => { + userDismissedDeliverable.current = false + const supabase = createClient() + const { data, error } = await supabase + .from('documents') + .select('id, title, content, type, created_at, agents:created_by_agent_id(name)') + .eq('task_id', selectedTask.id) + .order('created_at', { ascending: false }) + + if (!error && data) { + setDeliverables(data as unknown as FetchedDeliverable[]) + } + } + + fetchDeliverables() + }, [selectedTask, isDetailOpen]) + + // Auto-select single deliverable for direct content view + useEffect(() => { + if (deliverables.length === 1 && !selectedDeliverable && !userDismissedDeliverable.current) { + setSelectedDeliverable(deliverables[0]) + } + }, [deliverables, selectedDeliverable]) + /** - * Handle task click - open the modal with the selected task + * Open the edit modal for a task (closes detail panel first) */ - const handleTaskClick = useCallback((taskId: string) => { + const handleEditTask = useCallback((taskId: string) => { const task = tasks.find((t) => t.id === taskId) if (task) { setSelectedTask(task) + setIsDetailOpen(false) setIsModalOpen(true) } }, [tasks]) + /** + * Handle task click - show slide-over detail panel + */ + const handleTaskClick = useCallback((taskId: string) => { + const task = tasks.find((t) => t.id === taskId) + if (task) { + setSelectedTask(task) + setIsDetailOpen(true) + } + }, [tasks]) + /** * Handle closing the modal */ @@ -141,13 +242,105 @@ export function TasksClient({ initialTasks }: TasksClientProps) { // This is a simplified implementation that updates core task fields }, [selectedTask]) + /** + * Local component for rendering deliverables list or detail view + */ + const DeliverablesSection = () => { + if (deliverables.length === 0) { + return null + } + + // If a deliverable is selected, show the full document view + if (selectedDeliverable) { + return ( + { + userDismissedDeliverable.current = true + setSelectedDeliverable(null) + }} + /> + ) + } + + // Show deliverables list + return ( +
+ + DELIVERABLES ({deliverables.length}) + +
+ {deliverables.map((doc) => ( + + ))} +
+
+ ) + } + return ( <> - +
+ {/* Header row */} +
+
+ + + MISSION QUEUE + +
+
+
+ + {tasks.length} +
+ + {activeTasksCount} active + +
+
+ + {/* Filter bar */} + + + {/* Board */} + +
+ + { + if (!open) { + setIsDetailOpen(false) + setSelectedTask(null) + setSelectedDeliverable(null) + } + }} + task={selectedTask} + > + + ) } diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 1a4bf2e..7b49467 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -66,3 +66,23 @@ body { color: var(--foreground); font-family: var(--font-sans); } + +@keyframes feed-entry-in { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Utility: hide scrollbar while keeping scroll functionality */ +@utility scrollbar-none { + -ms-overflow-style: none; + scrollbar-width: none; + &::-webkit-scrollbar { + display: none; + } +} diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 3264a4d..5ae81a7 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -9,7 +9,7 @@ export default async function Home() { } = await supabase.auth.getUser() if (user) { - redirect('/dashboard') + redirect('/tasks') } else { redirect('/login') } diff --git a/apps/web/src/components/molecules/AgentFilterChips/AgentFilterChips.tsx b/apps/web/src/components/molecules/AgentFilterChips/AgentFilterChips.tsx new file mode 100644 index 0000000..75d29c2 --- /dev/null +++ b/apps/web/src/components/molecules/AgentFilterChips/AgentFilterChips.tsx @@ -0,0 +1,143 @@ +'use client' + +import { forwardRef, useCallback } from 'react' +import type { KeyboardEvent } from 'react' + +import { cn } from '@/lib/utils' + +/** + * Props for the AgentFilterChips component + */ +export interface AgentFilterChipsProps { + /** List of agents to display as filter chips */ + agents: Array<{ id: string; name: string; activityCount?: number }> + /** Currently selected agent ID, or null for "All" */ + selectedAgentId: string | null + /** Callback when an agent chip is selected */ + onAgentSelect: (agentId: string | null) => void + /** Additional CSS classes */ + className?: string +} + +/** + * AgentFilterChips molecule component + * + * A horizontally scrollable row of agent chips for filtering the live feed + * by a specific agent. Implements a radio group pattern for accessibility. + * + * @example + * ```tsx + * setSelected(id)} + * /> + * ``` + */ +export const AgentFilterChips = forwardRef( + function AgentFilterChips({ agents, selectedAgentId, onAgentSelect, className }, ref) { + const allChips = [ + { id: null as string | null, name: 'All', activityCount: undefined as number | undefined }, + ...agents, + ] + + const currentIndex = allChips.findIndex( + (chip) => chip.id === selectedAgentId + ) + + const handleKeyDown = useCallback( + (event: KeyboardEvent, index: number) => { + let nextIndex: number | null = null + + switch (event.key) { + case 'ArrowRight': + case 'ArrowDown': + event.preventDefault() + nextIndex = index < allChips.length - 1 ? index + 1 : 0 + break + case 'ArrowLeft': + case 'ArrowUp': + event.preventDefault() + nextIndex = index > 0 ? index - 1 : allChips.length - 1 + break + case 'Home': + event.preventDefault() + nextIndex = 0 + break + case 'End': + event.preventDefault() + nextIndex = allChips.length - 1 + break + case 'Enter': + case ' ': + event.preventDefault() + onAgentSelect(allChips[index].id) + return + } + + if (nextIndex !== null) { + onAgentSelect(allChips[nextIndex].id) + const button = document.querySelector( + `[data-testid="agent-chip-${allChips[nextIndex].id ?? 'all'}"]` + ) as HTMLButtonElement | null + button?.focus() + } + }, + [allChips, onAgentSelect] + ) + + return ( +
+ {allChips.map((chip, index) => { + const isSelected = chip.id === selectedAgentId + + return ( + + ) + })} +
+ ) + } +) + +AgentFilterChips.displayName = 'AgentFilterChips' diff --git a/apps/web/src/components/molecules/AgentFilterChips/index.ts b/apps/web/src/components/molecules/AgentFilterChips/index.ts new file mode 100644 index 0000000..87cce73 --- /dev/null +++ b/apps/web/src/components/molecules/AgentFilterChips/index.ts @@ -0,0 +1 @@ +export { AgentFilterChips, type AgentFilterChipsProps } from './AgentFilterChips' diff --git a/apps/web/src/components/molecules/AgentListItem/AgentListItem.tsx b/apps/web/src/components/molecules/AgentListItem/AgentListItem.tsx new file mode 100644 index 0000000..70a7982 --- /dev/null +++ b/apps/web/src/components/molecules/AgentListItem/AgentListItem.tsx @@ -0,0 +1,222 @@ +import { forwardRef } from 'react' + +import { Avatar, Icon, StatusDot, Text } from '@/components/atoms' +import type { AgentData } from '@/components/organisms/AgentSidebar' +import { cn } from '@/lib/utils' + +export interface AgentListItemProps { + /** Agent data to display */ + agent: AgentData + /** Whether this item is currently selected */ + isSelected: boolean + /** Callback when the item is clicked */ + onClick: () => void + /** Additional CSS classes */ + className?: string +} + +/** + * Maps a role string to a role type badge label and className. + */ +function getRoleType(role: string): { label: string; className: string } { + const lower = role.toLowerCase() + if ( + lower.includes('lead') || + lower.includes('coordinator') || + lower.includes('manager') + ) { + return { label: 'LEAD', className: 'bg-amber-700/15 text-amber-700' } + } + if ( + lower.includes('writer') || + lower.includes('editor') || + lower.includes('social') || + lower.includes('content') + ) { + return { label: 'INT', className: 'bg-emerald-700/15 text-emerald-700' } + } + return { label: 'SPC', className: 'bg-orange-700/15 text-orange-700' } +} + +/** + * Maps agent status to a display label and color className. + */ +function getStatusText( + status: AgentData['status'] +): { label: string; className: string } { + switch (status) { + case 'active': + return { label: 'WORKING', className: 'text-status-active' } + case 'idle': + return { label: 'IDLE', className: 'text-status-idle' } + case 'blocked': + return { label: 'BLOCKED', className: 'text-status-blocked' } + case 'offline': + return { label: 'OFFLINE', className: 'text-status-offline' } + } +} + +/** + * Maps agent status to StatusDot status. + * StatusDot does not support 'blocked', so we map it to 'offline'. + */ +function getStatusDotStatus( + status: AgentData['status'] +): 'active' | 'idle' | 'offline' { + if (status === 'blocked') return 'offline' + return status +} + +/** + * Maps avatar color strings to valid Avatar color props. + */ +function getAvatarColor( + color?: string +): 'accent' | 'blue' | 'green' | 'purple' | 'orange' | 'pink' | 'teal' { + const validColors = [ + 'accent', + 'blue', + 'green', + 'purple', + 'orange', + 'pink', + 'teal', + ] as const + if (color && validColors.includes(color as (typeof validColors)[number])) { + return color as (typeof validColors)[number] + } + return 'accent' +} + +/** + * AgentListItem molecule component + * + * Renders a single agent row in the sidebar with avatar, status overlay, + * role badge, status text label, and optional current task / blocked reason. + * + * @example + * ```tsx + * handleAgentClick(agent)} + * /> + * ``` + */ +export const AgentListItem = forwardRef( + ({ agent, isSelected, onClick, className }, ref) => { + const statusDotStatus = getStatusDotStatus(agent.status) + const roleType = getRoleType(agent.role) + const statusText = getStatusText(agent.status) + + return ( + + ) + } +) + +AgentListItem.displayName = 'AgentListItem' diff --git a/apps/web/src/components/molecules/AgentListItem/index.ts b/apps/web/src/components/molecules/AgentListItem/index.ts new file mode 100644 index 0000000..27919aa --- /dev/null +++ b/apps/web/src/components/molecules/AgentListItem/index.ts @@ -0,0 +1,2 @@ +export { AgentListItem } from './AgentListItem' +export type { AgentListItemProps } from './AgentListItem' diff --git a/apps/web/src/components/molecules/AgentStatusBox/AgentStatusBox.tsx b/apps/web/src/components/molecules/AgentStatusBox/AgentStatusBox.tsx new file mode 100644 index 0000000..67ce5c1 --- /dev/null +++ b/apps/web/src/components/molecules/AgentStatusBox/AgentStatusBox.tsx @@ -0,0 +1,111 @@ +'use client' + +import { forwardRef } from 'react' +import { cn } from '@/lib/utils' +import { Badge, Text } from '@/components/atoms' +import { formatRelativeTime } from '@/lib/formatRelativeTime' + +type AgentStatus = 'active' | 'idle' | 'blocked' | 'offline' + +export interface AgentStatusBoxProps { + /** Current agent status */ + status: AgentStatus + /** Reason for current status (e.g., "Waiting for review") */ + statusReason?: string | null + /** ISO timestamp for when this status started */ + statusSince?: string | null + /** Additional CSS classes */ + className?: string +} + +/** + * Maps agent status to border color token + */ +const statusBorderStyles: Record = { + active: 'border-status-active', + idle: 'border-status-idle', + blocked: 'border-status-blocked', + offline: 'border-status-offline', +} + +/** + * Maps agent status to badge variant + */ +const statusBadgeVariant: Record< + AgentStatus, + 'status-active' | 'status-idle' | 'status-blocked' | 'status-offline' +> = { + active: 'status-active', + idle: 'status-idle', + blocked: 'status-blocked', + offline: 'status-offline', +} + +/** + * Human-readable status labels + */ +const statusLabels: Record = { + active: 'Working', + idle: 'Idle', + blocked: 'Blocked', + offline: 'Offline', +} + +/** + * AgentStatusBox molecule component + * + * Bordered container showing agent status with optional reason and timing. + * The border color reflects the current agent status. + * + * @example + * ```tsx + * + * ``` + */ +export const AgentStatusBox = forwardRef( + ({ status, statusReason, statusSince, className }, ref) => { + return ( +
+
+ + {statusLabels[status]} + + + {statusReason && ( +
+ + Status Reason: + + + {statusReason} + +
+ )} + + {statusSince && ( + + Since {formatRelativeTime(statusSince)} + + )} +
+
+ ) + } +) + +AgentStatusBox.displayName = 'AgentStatusBox' diff --git a/apps/web/src/components/molecules/AgentStatusBox/index.ts b/apps/web/src/components/molecules/AgentStatusBox/index.ts new file mode 100644 index 0000000..45b66bb --- /dev/null +++ b/apps/web/src/components/molecules/AgentStatusBox/index.ts @@ -0,0 +1,2 @@ +export { AgentStatusBox } from './AgentStatusBox' +export type { AgentStatusBoxProps } from './AgentStatusBox' diff --git a/apps/web/src/components/molecules/AllAgentsRow/AllAgentsRow.tsx b/apps/web/src/components/molecules/AllAgentsRow/AllAgentsRow.tsx new file mode 100644 index 0000000..b7bed80 --- /dev/null +++ b/apps/web/src/components/molecules/AllAgentsRow/AllAgentsRow.tsx @@ -0,0 +1,83 @@ +import { forwardRef } from 'react' + +import { Icon, Text } from '@/components/atoms' +import { cn } from '@/lib/utils' + +export interface AllAgentsRowProps { + /** Total number of agents */ + totalCount: number + /** Number of currently active agents */ + activeCount: number + /** Whether this row is currently selected */ + isSelected: boolean + /** Callback when the row is clicked */ + onClick: () => void + /** Additional CSS classes */ + className?: string +} + +/** + * AllAgentsRow molecule component + * + * Summary row shown at the top of the agent sidebar. Displays total and + * active agent counts with a Users icon. Supports selected state styling. + * + * @example + * ```tsx + * handleAllAgentsClick()} + * /> + * ``` + */ +export const AllAgentsRow = forwardRef( + ({ totalCount, activeCount, isSelected, onClick, className }, ref) => { + return ( + + ) + } +) + +AllAgentsRow.displayName = 'AllAgentsRow' diff --git a/apps/web/src/components/molecules/AllAgentsRow/index.ts b/apps/web/src/components/molecules/AllAgentsRow/index.ts new file mode 100644 index 0000000..ce49cea --- /dev/null +++ b/apps/web/src/components/molecules/AllAgentsRow/index.ts @@ -0,0 +1,2 @@ +export { AllAgentsRow } from './AllAgentsRow' +export type { AllAgentsRowProps } from './AllAgentsRow' diff --git a/apps/web/src/components/molecules/AssigneeDisplay/AssigneeDisplay.test.tsx b/apps/web/src/components/molecules/AssigneeDisplay/AssigneeDisplay.test.tsx new file mode 100644 index 0000000..7f8bda8 --- /dev/null +++ b/apps/web/src/components/molecules/AssigneeDisplay/AssigneeDisplay.test.tsx @@ -0,0 +1,68 @@ +import { render, screen } from '@testing-library/react' +import { AssigneeDisplay } from './AssigneeDisplay' + +describe('AssigneeDisplay', () => { + const mockAssignee = { + id: 'user-1', + name: 'Alice', + avatarColor: 'blue', + } + + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-02-05T12:00:00Z')) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('renders the assignee name', () => { + render() + expect(screen.getByText('Alice')).toBeInTheDocument() + }) + + it('renders avatar with correct title', () => { + render() + expect(screen.getByTitle('Alice')).toBeInTheDocument() + }) + + it('renders relative timestamp when provided', () => { + render( + + ) + expect(screen.getByText('5m ago')).toBeInTheDocument() + }) + + it('does not render timestamp when not provided', () => { + render() + // Only the name text should be present, no timestamp + const container = screen.getByTestId('assignee-display') + const textElements = container.querySelectorAll('span') + // Should not contain a relative time string + const allText = container.textContent + expect(allText).not.toContain('ago') + }) + + it('applies custom className', () => { + render( + + ) + const container = screen.getByTestId('assignee-display') + expect(container).toHaveClass('custom-class') + }) + + it('has correct test id', () => { + render() + expect(screen.getByTestId('assignee-display')).toBeInTheDocument() + }) + + it('handles assignee without avatar color', () => { + const noColor = { id: 'user-2', name: 'Bob' } + render() + expect(screen.getByText('Bob')).toBeInTheDocument() + }) +}) diff --git a/apps/web/src/components/molecules/AssigneeDisplay/AssigneeDisplay.tsx b/apps/web/src/components/molecules/AssigneeDisplay/AssigneeDisplay.tsx new file mode 100644 index 0000000..7591bf1 --- /dev/null +++ b/apps/web/src/components/molecules/AssigneeDisplay/AssigneeDisplay.tsx @@ -0,0 +1,97 @@ +import { forwardRef } from 'react' + +import { cn } from '@/lib/utils' +import { formatRelativeTime } from '@/lib/formatRelativeTime' +import { Avatar, Text } from '@/components/atoms' + +/** + * Assignee data for display purposes + */ +export interface AssigneeDisplayData { + /** Unique identifier for the assignee */ + id: string + /** Display name */ + name: string + /** Optional avatar color */ + avatarColor?: string +} + +/** + * Props for the AssigneeDisplay component + */ +export interface AssigneeDisplayProps + extends React.HTMLAttributes { + /** The assignee to display */ + assignee: AssigneeDisplayData + /** Optional timestamp to show as relative time (right-aligned) */ + timestamp?: string | null +} + +const validAvatarColors = [ + 'accent', + 'blue', + 'green', + 'purple', + 'orange', + 'pink', + 'teal', +] as const + +type AvatarColor = (typeof validAvatarColors)[number] + +function toAvatarColor(color?: string): AvatarColor { + if (color && validAvatarColors.includes(color as AvatarColor)) { + return color as AvatarColor + } + return 'accent' +} + +/** + * AssigneeDisplay molecule component + * + * Shows an assignee avatar (xs size), their name, and an optional + * relative timestamp aligned to the right. + * + * @example + * ```tsx + * + * ``` + */ +export const AssigneeDisplay = forwardRef( + ({ assignee, timestamp, className, ...props }, ref) => { + return ( +
+ + + {assignee.name} + + {timestamp && ( + + {formatRelativeTime(timestamp)} + + )} +
+ ) + } +) + +AssigneeDisplay.displayName = 'AssigneeDisplay' diff --git a/apps/web/src/components/molecules/AssigneeDisplay/index.ts b/apps/web/src/components/molecules/AssigneeDisplay/index.ts new file mode 100644 index 0000000..500cdb4 --- /dev/null +++ b/apps/web/src/components/molecules/AssigneeDisplay/index.ts @@ -0,0 +1,2 @@ +export { AssigneeDisplay } from './AssigneeDisplay' +export type { AssigneeDisplayProps, AssigneeDisplayData } from './AssigneeDisplay' diff --git a/apps/web/src/components/molecules/AttentionList/AttentionList.tsx b/apps/web/src/components/molecules/AttentionList/AttentionList.tsx new file mode 100644 index 0000000..dfc9a74 --- /dev/null +++ b/apps/web/src/components/molecules/AttentionList/AttentionList.tsx @@ -0,0 +1,113 @@ +'use client' + +import { forwardRef } from 'react' +import { cn } from '@/lib/utils' +import { Icon, Text } from '@/components/atoms' +import { formatRelativeTime } from '@/lib/formatRelativeTime' + +export interface AttentionItem { + id: string + type: 'mention' | 'waiting_task' + title: string + description?: string + timestamp?: string +} + +export interface AttentionListProps { + /** List of items requiring agent attention */ + items: AttentionItem[] + /** Additional CSS classes */ + className?: string +} + +/** + * Maps attention item type to an icon name + */ +function getItemIcon(type: AttentionItem['type']): 'MessageSquare' | 'Clock' { + return type === 'mention' ? 'MessageSquare' : 'Clock' +} + +/** + * Maps attention item type to a human-readable label + */ +function getItemTypeLabel(type: AttentionItem['type']): string { + return type === 'mention' ? 'Mention' : 'Waiting Task' +} + +/** + * AttentionList molecule component + * + * Displays a list of items requiring an agent's attention, such as + * unread mentions and waiting tasks. Shows an empty state when there + * are no items. + * + * @example + * ```tsx + * + * ``` + */ +export const AttentionList = forwardRef( + ({ items, className }, ref) => { + if (items.length === 0) { + return ( +
+ + + No items need attention + +
+ ) + } + + return ( +
+ {items.map((item) => ( +
+
+ +
+
+ + {item.title} + + {item.description && ( + + {item.description} + + )} + {item.timestamp && ( + + {formatRelativeTime(item.timestamp)} + + )} +
+
+ ))} +
+ ) + } +) + +AttentionList.displayName = 'AttentionList' diff --git a/apps/web/src/components/molecules/AttentionList/index.ts b/apps/web/src/components/molecules/AttentionList/index.ts new file mode 100644 index 0000000..6ee319e --- /dev/null +++ b/apps/web/src/components/molecules/AttentionList/index.ts @@ -0,0 +1,2 @@ +export { AttentionList } from './AttentionList' +export type { AttentionListProps, AttentionItem } from './AttentionList' diff --git a/apps/web/src/components/molecules/Clock/Clock.tsx b/apps/web/src/components/molecules/Clock/Clock.tsx index a030d45..8bfefe8 100644 --- a/apps/web/src/components/molecules/Clock/Clock.tsx +++ b/apps/web/src/components/molecules/Clock/Clock.tsx @@ -51,11 +51,13 @@ function formatTime( * Formats a Date object to a date string. */ function formatDate(date: Date): string { - return date.toLocaleDateString('en-US', { - weekday: 'short', - month: 'short', - day: 'numeric', - }) + return date + .toLocaleDateString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric', + }) + .toUpperCase() } /** diff --git a/apps/web/src/components/molecules/ConnectionStatus/ConnectionStatus.tsx b/apps/web/src/components/molecules/ConnectionStatus/ConnectionStatus.tsx new file mode 100644 index 0000000..dcd66dd --- /dev/null +++ b/apps/web/src/components/molecules/ConnectionStatus/ConnectionStatus.tsx @@ -0,0 +1,53 @@ +import { forwardRef } from 'react' +import { StatusDot } from '@/components/atoms' +import { cn } from '@/lib/utils' + +export interface ConnectionStatusProps + extends React.HTMLAttributes { + /** Whether the dashboard is connected to the backend */ + isConnected: boolean + /** Additional CSS classes */ + className?: string +} + +/** + * ConnectionStatus - Displays connection state with a status dot and label. + * + * When connected: green dot + "ONLINE" + * When disconnected: red dot + "OFFLINE" + * + * @example + * ```tsx + * + * + * ``` + */ +export const ConnectionStatus = forwardRef< + HTMLDivElement, + ConnectionStatusProps +>(({ isConnected, className, ...props }, ref) => { + return ( +
+ + + {isConnected ? 'ONLINE' : 'OFFLINE'} + +
+ ) +}) + +ConnectionStatus.displayName = 'ConnectionStatus' diff --git a/apps/web/src/components/molecules/ConnectionStatus/index.ts b/apps/web/src/components/molecules/ConnectionStatus/index.ts new file mode 100644 index 0000000..418cd45 --- /dev/null +++ b/apps/web/src/components/molecules/ConnectionStatus/index.ts @@ -0,0 +1,4 @@ +export { + ConnectionStatus, + type ConnectionStatusProps, +} from './ConnectionStatus' diff --git a/apps/web/src/components/molecules/DeliverableView/DeliverableView.tsx b/apps/web/src/components/molecules/DeliverableView/DeliverableView.tsx new file mode 100644 index 0000000..bad906c --- /dev/null +++ b/apps/web/src/components/molecules/DeliverableView/DeliverableView.tsx @@ -0,0 +1,85 @@ +'use client' + +import { forwardRef } from 'react' +import { cn } from '@/lib/utils' +import { Badge, Icon, Text } from '@/components/atoms' +import { MarkdownViewer } from '@/components/molecules/MarkdownViewer' + +export interface DeliverableDocument { + /** Document title */ + title: string + /** Markdown content of the document */ + content: string + /** Author name */ + author?: string + /** ISO date string for when the document was created */ + createdAt?: string +} + +export interface DeliverableViewProps { + /** The document to display */ + document: DeliverableDocument + /** Callback when the back button is clicked */ + onBack: () => void + /** Additional CSS classes */ + className?: string +} + +/** + * DeliverableView displays a document with a back navigation header, + * a DELIVERABLE badge, and rendered markdown content. + * + * @example + * ```tsx + * setView('list')} + * /> + * ``` + */ +export const DeliverableView = forwardRef( + ({ document, onBack, className }, ref) => { + return ( +
+ {/* Back navigation + DELIVERABLE badge */} +
+ + + DELIVERABLE + +
+ + {/* Document title */} + + 📄 {document.title} + + + {/* Document content */} + +
+ ) + } +) + +DeliverableView.displayName = 'DeliverableView' diff --git a/apps/web/src/components/molecules/DeliverableView/index.ts b/apps/web/src/components/molecules/DeliverableView/index.ts new file mode 100644 index 0000000..d651490 --- /dev/null +++ b/apps/web/src/components/molecules/DeliverableView/index.ts @@ -0,0 +1,2 @@ +export { DeliverableView } from './DeliverableView' +export type { DeliverableViewProps, DeliverableDocument } from './DeliverableView' diff --git a/apps/web/src/components/molecules/FeedEntry/FeedEntry.tsx b/apps/web/src/components/molecules/FeedEntry/FeedEntry.tsx new file mode 100644 index 0000000..7c942fc --- /dev/null +++ b/apps/web/src/components/molecules/FeedEntry/FeedEntry.tsx @@ -0,0 +1,168 @@ +'use client' + +import { forwardRef } from 'react' +import Link from 'next/link' + +import { cn } from '@/lib/utils' +import { formatRelativeTime } from '@/lib/formatRelativeTime' +import { Avatar, Icon, Text } from '@/components/atoms' +import type { ActivityData, ActivityType } from '@/components/organisms/LiveFeed/LiveFeed' + +// Icon name type matching what we use from lucide +type IconNameType = + | 'ListPlus' + | 'ArrowRightLeft' + | 'UserCheck' + | 'MessageSquare' + | 'FilePlus' + | 'Zap' + | 'Activity' + +/** + * Props for the FeedEntry component + */ +export interface FeedEntryProps { + /** Activity data to render */ + activity: ActivityData + /** Function to generate href for task links */ + getTaskHref?: (taskId: string) => string + /** Additional CSS classes */ + className?: string +} + +/** + * Maps activity type to an icon name + */ +function getActivityIcon(type: ActivityType): IconNameType { + const iconMap: Record = { + task_created: 'ListPlus', + task_status_changed: 'ArrowRightLeft', + task_assigned: 'UserCheck', + message_sent: 'MessageSquare', + document_created: 'FilePlus', + agent_status_changed: 'Zap', + } + return iconMap[type] || 'Activity' +} + +/** + * Maps activity type to a color class for the icon badge + */ +function getActivityColor(type: ActivityType): string { + const colorMap: Record = { + task_created: 'bg-status-active/15 text-status-active', + task_status_changed: 'bg-accent/15 text-accent', + task_assigned: 'bg-accent-secondary text-accent', + message_sent: 'bg-accent-muted text-accent', + document_created: 'bg-status-blocked/15 text-status-blocked', + agent_status_changed: 'bg-status-idle/15 text-status-idle', + } + return colorMap[type] || 'bg-background-elevated text-text-muted' +} + +/** + * FeedEntry molecule component + * + * A rich activity entry for the live feed. Shows agent avatar (or type icon), + * agent name, action message, relative timestamp, and a small type badge. + * Task-linked entries are rendered as clickable links. + * + * @example + * ```tsx + * `/tasks/${id}`} + * /> + * ``` + */ +export const FeedEntry = forwardRef( + function FeedEntry({ activity, getTaskHref, className }, ref) { + const iconName = getActivityIcon(activity.type) + const colorClass = getActivityColor(activity.type) + const relativeTime = formatRelativeTime(activity.created_at) + const messageId = `feed-entry-message-${activity.id}` + + const taskHref = activity.task_id + ? getTaskHref + ? getTaskHref(activity.task_id) + : `/tasks/${activity.task_id}` + : null + + const content = ( + <> + {/* Agent avatar or activity type icon */} +
+ {activity.agent_name ? ( + + ) : ( +
+ +
+ )} +
+ + {/* Activity content */} +
+

+ {activity.agent_name && ( + {activity.agent_name} + )} + {activity.message} +

+ + {relativeTime} + +
+ + {/* Activity type badge */} +
+ +
+ + ) + + if (taskHref) { + return ( +
  • + + {content} + +
  • + ) + } + + return ( +
  • + {content} +
  • + ) + } +) + +FeedEntry.displayName = 'FeedEntry' diff --git a/apps/web/src/components/molecules/FeedEntry/index.ts b/apps/web/src/components/molecules/FeedEntry/index.ts new file mode 100644 index 0000000..f7318c3 --- /dev/null +++ b/apps/web/src/components/molecules/FeedEntry/index.ts @@ -0,0 +1 @@ +export { FeedEntry, type FeedEntryProps } from './FeedEntry' diff --git a/apps/web/src/components/molecules/FeedTypeTabs/FeedTypeTabs.tsx b/apps/web/src/components/molecules/FeedTypeTabs/FeedTypeTabs.tsx new file mode 100644 index 0000000..777be9f --- /dev/null +++ b/apps/web/src/components/molecules/FeedTypeTabs/FeedTypeTabs.tsx @@ -0,0 +1,149 @@ +'use client' + +import { forwardRef, useCallback } from 'react' +import type { KeyboardEvent } from 'react' + +import { cn } from '@/lib/utils' + +/** + * Feed type for filtering activities + */ +export type FeedType = 'all' | 'tasks' | 'comments' | 'docs' | 'status' + +/** + * Props for the FeedTypeTabs component + */ +export interface FeedTypeTabsProps { + /** Currently active feed type */ + activeType: FeedType + /** Callback when a tab is selected */ + onTypeChange: (type: FeedType) => void + /** Optional counts per feed type */ + counts?: Partial> + /** Additional CSS classes */ + className?: string +} + +/** + * Tab configuration + */ +interface TabConfig { + value: FeedType + label: string +} + +const TABS: TabConfig[] = [ + { value: 'all', label: 'All' }, + { value: 'tasks', label: 'Tasks' }, + { value: 'comments', label: 'Comments' }, + { value: 'docs', label: 'Docs' }, + { value: 'status', label: 'Status' }, +] + +/** + * FeedTypeTabs molecule component + * + * A tab bar for filtering live feed activities by type. + * Implements the WAI-ARIA tablist pattern for accessibility. + * + * @example + * ```tsx + * const [type, setType] = useState('all') + * + * + * ``` + */ +export const FeedTypeTabs = forwardRef( + function FeedTypeTabs({ activeType, onTypeChange, counts, className }, ref) { + const handleKeyDown = useCallback( + (event: KeyboardEvent, index: number) => { + let nextIndex: number | null = null + + switch (event.key) { + case 'ArrowRight': + event.preventDefault() + nextIndex = index < TABS.length - 1 ? index + 1 : 0 + break + case 'ArrowLeft': + event.preventDefault() + nextIndex = index > 0 ? index - 1 : TABS.length - 1 + break + case 'Home': + event.preventDefault() + nextIndex = 0 + break + case 'End': + event.preventDefault() + nextIndex = TABS.length - 1 + break + } + + if (nextIndex !== null) { + onTypeChange(TABS[nextIndex].value) + const button = document.querySelector( + `[data-testid="feed-tab-${TABS[nextIndex].value}"]` + ) as HTMLButtonElement | null + button?.focus() + } + }, + [onTypeChange] + ) + + return ( +
    + {TABS.map((tab, index) => { + const isSelected = tab.value === activeType + const count = counts?.[tab.value] + + return ( + + ) + })} +
    + ) + } +) + +FeedTypeTabs.displayName = 'FeedTypeTabs' diff --git a/apps/web/src/components/molecules/FeedTypeTabs/index.ts b/apps/web/src/components/molecules/FeedTypeTabs/index.ts new file mode 100644 index 0000000..11caa98 --- /dev/null +++ b/apps/web/src/components/molecules/FeedTypeTabs/index.ts @@ -0,0 +1 @@ +export { FeedTypeTabs, type FeedTypeTabsProps, type FeedType } from './FeedTypeTabs' diff --git a/apps/web/src/components/molecules/FilterIndicator/FilterIndicator.tsx b/apps/web/src/components/molecules/FilterIndicator/FilterIndicator.tsx new file mode 100644 index 0000000..d0585bd --- /dev/null +++ b/apps/web/src/components/molecules/FilterIndicator/FilterIndicator.tsx @@ -0,0 +1,46 @@ +import { forwardRef } from 'react' +import { cn } from '@/lib/utils' + +export interface FilterIndicatorProps + extends React.HTMLAttributes { + /** Name of the agent being filtered */ + agentName: string + /** Number of tasks for this agent */ + taskCount: number + /** Additional CSS classes */ + className?: string +} + +/** + * FilterIndicator - Displays the active agent filter replacing the stats area. + * + * Shows "FILTERING BY [Agent Name]" with task count below. + * Agent name is highlighted in accent color. + * + * @example + * ```tsx + * + * ``` + */ +export const FilterIndicator = forwardRef( + ({ agentName, taskCount, className, ...props }, ref) => { + return ( +
    +
    + FILTERING BY + {agentName} +
    + + {taskCount} {taskCount === 1 ? 'TASK' : 'TASKS'} + +
    + ) + } +) + +FilterIndicator.displayName = 'FilterIndicator' diff --git a/apps/web/src/components/molecules/FilterIndicator/index.ts b/apps/web/src/components/molecules/FilterIndicator/index.ts new file mode 100644 index 0000000..46c0e6a --- /dev/null +++ b/apps/web/src/components/molecules/FilterIndicator/index.ts @@ -0,0 +1,4 @@ +export { + FilterIndicator, + type FilterIndicatorProps, +} from './FilterIndicator' diff --git a/apps/web/src/components/molecules/MarkdownViewer/MarkdownViewer.tsx b/apps/web/src/components/molecules/MarkdownViewer/MarkdownViewer.tsx new file mode 100644 index 0000000..b2c701b --- /dev/null +++ b/apps/web/src/components/molecules/MarkdownViewer/MarkdownViewer.tsx @@ -0,0 +1,146 @@ +'use client' + +import { forwardRef } from 'react' +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import { cn } from '@/lib/utils' + +export interface MarkdownViewerProps { + /** Markdown content string to render */ + content: string + /** Additional CSS classes */ + className?: string +} + +/** + * MarkdownViewer renders markdown content with design-token-based styling. + * + * Supports GFM (GitHub Flavored Markdown) including tables, strikethrough, + * task lists, and autolinks via remark-gfm. + * + * @example + * ```tsx + * + * ``` + */ +export const MarkdownViewer = forwardRef( + ({ content, className }, ref) => { + return ( +
    + ( +

    + {children} +

    + ), + h2: ({ children }) => ( +

    + {children} +

    + ), + h3: ({ children }) => ( +

    + {children} +

    + ), + h4: ({ children }) => ( +

    + {children} +

    + ), + p: ({ children }) => ( +

    {children}

    + ), + a: ({ href, children }) => ( + + {children} + + ), + ul: ({ children }) => ( +
      + {children} +
    + ), + ol: ({ children }) => ( +
      + {children} +
    + ), + li: ({ children }) =>
  • {children}
  • , + blockquote: ({ children }) => ( +
    + {children} +
    + ), + code: ({ children, className: codeClassName }) => { + // Inline code (no language class) vs block code + const isBlock = codeClassName?.startsWith('language-') + if (isBlock) { + return ( + + {children} + + ) + } + return ( + + {children} + + ) + }, + pre: ({ children }) => ( +
    +                {children}
    +              
    + ), + hr: () =>
    , + table: ({ children }) => ( +
    + {children}
    +
    + ), + thead: ({ children }) => ( + + {children} + + ), + th: ({ children }) => ( + + {children} + + ), + td: ({ children }) => ( + + {children} + + ), + strong: ({ children }) => ( + {children} + ), + em: ({ children }) => ( + {children} + ), + del: ({ children }) => ( + {children} + ), + }} + > + {content} +
    +
    + ) + } +) + +MarkdownViewer.displayName = 'MarkdownViewer' diff --git a/apps/web/src/components/molecules/MarkdownViewer/index.ts b/apps/web/src/components/molecules/MarkdownViewer/index.ts new file mode 100644 index 0000000..1e7688a --- /dev/null +++ b/apps/web/src/components/molecules/MarkdownViewer/index.ts @@ -0,0 +1,2 @@ +export { MarkdownViewer } from './MarkdownViewer' +export type { MarkdownViewerProps } from './MarkdownViewer' diff --git a/apps/web/src/components/molecules/SquadBadge/SquadBadge.tsx b/apps/web/src/components/molecules/SquadBadge/SquadBadge.tsx new file mode 100644 index 0000000..6d948a8 --- /dev/null +++ b/apps/web/src/components/molecules/SquadBadge/SquadBadge.tsx @@ -0,0 +1,40 @@ +import { forwardRef } from 'react' +import { cn } from '@/lib/utils' + +export interface SquadBadgeProps extends React.HTMLAttributes { + /** Name of the squad to display */ + squadName: string + /** Additional CSS classes */ + className?: string +} + +/** + * SquadBadge - Displays the squad name in a subtle badge next to the logo. + * + * Uses mono font, uppercase, and muted styling to fit the command-center aesthetic. + * + * @example + * ```tsx + * + * ``` + */ +export const SquadBadge = forwardRef( + ({ squadName, className, ...props }, ref) => { + return ( + + {squadName} + + ) + } +) + +SquadBadge.displayName = 'SquadBadge' diff --git a/apps/web/src/components/molecules/SquadBadge/index.ts b/apps/web/src/components/molecules/SquadBadge/index.ts new file mode 100644 index 0000000..bbdecae --- /dev/null +++ b/apps/web/src/components/molecules/SquadBadge/index.ts @@ -0,0 +1 @@ +export { SquadBadge, type SquadBadgeProps } from './SquadBadge' diff --git a/apps/web/src/components/molecules/StatusFilterBar/StatusFilterBar.tsx b/apps/web/src/components/molecules/StatusFilterBar/StatusFilterBar.tsx new file mode 100644 index 0000000..cc46afd --- /dev/null +++ b/apps/web/src/components/molecules/StatusFilterBar/StatusFilterBar.tsx @@ -0,0 +1,84 @@ +import { forwardRef } from 'react' + +import { cn } from '@/lib/utils' +import { StatusFilterPill } from '@/components/molecules/StatusFilterPill' + +export interface StatusConfig { + /** Status key (e.g., "inbox", "in_progress") */ + status: string + /** Display label for the pill */ + label: string + /** Count of tasks with this status */ + count: number + /** Tailwind bg-class for the colored dot */ + color: string +} + +export interface StatusFilterBarProps extends React.HTMLAttributes { + /** Array of status configurations to render as filter pills */ + statuses: StatusConfig[] + /** Currently active status filter, or null for "All" */ + activeStatus: string | null + /** Callback when status filter changes. Passes null for "All". */ + onStatusChange: (status: string | null) => void +} + +/** + * StatusFilterBar - Container for status filter pills. + * + * Renders a horizontal row of StatusFilterPill components with an "All" pill + * prepended. Supports horizontal scrolling on small screens. + * + * @example + * ```tsx + * setFilter(status)} + * /> + * ``` + */ +export const StatusFilterBar = forwardRef( + ({ statuses, activeStatus, onStatusChange, className, ...props }, ref) => { + const totalCount = statuses.reduce((sum, s) => sum + s.count, 0) + + return ( +
    + {/* "All" pill */} + onStatusChange(null)} + /> + + {/* Status pills */} + {statuses.map((config) => ( + onStatusChange(config.status)} + /> + ))} +
    + ) + } +) + +StatusFilterBar.displayName = 'StatusFilterBar' diff --git a/apps/web/src/components/molecules/StatusFilterBar/index.ts b/apps/web/src/components/molecules/StatusFilterBar/index.ts new file mode 100644 index 0000000..282db84 --- /dev/null +++ b/apps/web/src/components/molecules/StatusFilterBar/index.ts @@ -0,0 +1 @@ +export { StatusFilterBar, type StatusFilterBarProps, type StatusConfig } from './StatusFilterBar' diff --git a/apps/web/src/components/molecules/StatusFilterPill/StatusFilterPill.tsx b/apps/web/src/components/molecules/StatusFilterPill/StatusFilterPill.tsx new file mode 100644 index 0000000..19916c0 --- /dev/null +++ b/apps/web/src/components/molecules/StatusFilterPill/StatusFilterPill.tsx @@ -0,0 +1,63 @@ +import { forwardRef } from 'react' + +import { cn } from '@/lib/utils' + +export interface StatusFilterPillProps + extends Omit, 'color'> { + /** Display label for the pill (e.g., "All", "Inbox", "In Progress") */ + label: string + /** Count of items matching this filter */ + count: number + /** Tailwind bg-class for the colored dot (e.g., "bg-status-active") */ + color: string + /** Whether this pill is currently selected */ + isActive: boolean + /** Callback when the pill is clicked */ + onClick: () => void +} + +/** + * StatusFilterPill - Individual rounded pill button for filtering tasks by status. + * + * Displays a small colored dot, label text, and count badge. + * Selected state uses filled background; unselected is transparent with hover. + * + * @example + * ```tsx + * setFilter('in_progress')} + * /> + * ``` + */ +export const StatusFilterPill = forwardRef( + ({ label, count, color, isActive, onClick, className, ...props }, ref) => { + return ( + + ) + } +) + +StatusFilterPill.displayName = 'StatusFilterPill' diff --git a/apps/web/src/components/molecules/StatusFilterPill/index.ts b/apps/web/src/components/molecules/StatusFilterPill/index.ts new file mode 100644 index 0000000..5bc8bd4 --- /dev/null +++ b/apps/web/src/components/molecules/StatusFilterPill/index.ts @@ -0,0 +1 @@ +export { StatusFilterPill, type StatusFilterPillProps } from './StatusFilterPill' diff --git a/apps/web/src/components/molecules/StatusToggle/StatusToggle.tsx b/apps/web/src/components/molecules/StatusToggle/StatusToggle.tsx new file mode 100644 index 0000000..e18dcca --- /dev/null +++ b/apps/web/src/components/molecules/StatusToggle/StatusToggle.tsx @@ -0,0 +1,70 @@ +import { forwardRef } from 'react' +import { cn } from '@/lib/utils' + +type SquadStatus = 'active' | 'paused' + +export interface StatusToggleProps + extends Omit, 'children'> { + /** Current squad status */ + status: SquadStatus + /** Callback when the toggle is clicked */ + onToggle: () => void + /** Additional CSS classes */ + className?: string +} + +const statusStyles: Record = { + active: 'bg-status-active/15 text-status-active border-status-active/30', + paused: 'bg-status-offline/15 text-status-offline border-status-offline/30', +} + +const statusLabels: Record = { + active: 'ACTIVE', + paused: 'PAUSED', +} + +/** + * StatusToggle - Pill-shaped toggle button for squad status. + * + * Displays the current status (ACTIVE or PAUSED) with color-coded styling + * and toggles between states on click. + * + * @example + * ```tsx + * setStatus('paused')} /> + * setStatus('active')} disabled /> + * ``` + */ +export const StatusToggle = forwardRef( + ({ status, onToggle, disabled, className, ...props }, ref) => { + return ( + + ) + } +) + +StatusToggle.displayName = 'StatusToggle' diff --git a/apps/web/src/components/molecules/StatusToggle/index.ts b/apps/web/src/components/molecules/StatusToggle/index.ts new file mode 100644 index 0000000..def9c1b --- /dev/null +++ b/apps/web/src/components/molecules/StatusToggle/index.ts @@ -0,0 +1 @@ +export { StatusToggle, type StatusToggleProps } from './StatusToggle' diff --git a/apps/web/src/components/molecules/SystemMessage/SystemMessage.tsx b/apps/web/src/components/molecules/SystemMessage/SystemMessage.tsx new file mode 100644 index 0000000..5199dd9 --- /dev/null +++ b/apps/web/src/components/molecules/SystemMessage/SystemMessage.tsx @@ -0,0 +1,86 @@ +import { forwardRef } from 'react' + +import { cn } from '@/lib/utils' +import { Icon, Text } from '@/components/atoms' +import { formatRelativeTime } from '@/lib/formatRelativeTime' + +/** + * Props for the SystemMessage component + */ +export interface SystemMessageProps { + /** The system message text */ + message: string + /** Optional timestamp for the message */ + timestamp?: string + /** Visual variant of the message */ + variant?: 'warning' | 'info' + /** Additional CSS classes */ + className?: string +} + +const variantStyles: Record<'warning' | 'info', string> = { + warning: 'border-l-2 border-status-blocked bg-status-blocked/5', + info: 'border-l-2 border-accent bg-accent/5', +} + +const variantIcons: Record<'warning' | 'info', 'TriangleAlert' | 'Info'> = { + warning: 'TriangleAlert', + info: 'Info', +} + +const variantIconColors: Record<'warning' | 'info', string> = { + warning: 'text-status-blocked', + info: 'text-accent', +} + +/** + * SystemMessage molecule component + * + * Displays a system-level message with special styling to distinguish + * it from regular activity entries. Supports warning and info variants. + * + * @example + * ```tsx + * + * ``` + */ +export const SystemMessage = forwardRef( + function SystemMessage({ message, timestamp, variant = 'info', className }, ref) { + const relativeTime = timestamp ? formatRelativeTime(timestamp) : null + + return ( +
    + +
    + + {message} + + {relativeTime && ( + + {relativeTime} + + )} +
    +
    + ) + } +) + +SystemMessage.displayName = 'SystemMessage' diff --git a/apps/web/src/components/molecules/SystemMessage/index.ts b/apps/web/src/components/molecules/SystemMessage/index.ts new file mode 100644 index 0000000..fd1503c --- /dev/null +++ b/apps/web/src/components/molecules/SystemMessage/index.ts @@ -0,0 +1 @@ +export { SystemMessage, type SystemMessageProps } from './SystemMessage' diff --git a/apps/web/src/components/molecules/TaskCardCompact/TaskCardCompact.test.tsx b/apps/web/src/components/molecules/TaskCardCompact/TaskCardCompact.test.tsx new file mode 100644 index 0000000..c100774 --- /dev/null +++ b/apps/web/src/components/molecules/TaskCardCompact/TaskCardCompact.test.tsx @@ -0,0 +1,119 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { TaskCardCompact } from './TaskCardCompact' +import type { TaskData } from '@/components/organisms/KanbanColumn' + +describe('TaskCardCompact', () => { + const mockTask: TaskData = { + id: 'task-1', + title: 'Fix login bug', + status: 'in_progress', + priority: 'urgent', + position: 0, + } + + it('renders the task title', () => { + render() + expect(screen.getByText('Fix login bug')).toBeInTheDocument() + }) + + it('renders with correct test id', () => { + render() + expect(screen.getByTestId('task-compact-task-1')).toBeInTheDocument() + }) + + it('renders priority dot with urgent color', () => { + render() + const container = screen.getByTestId('task-compact-task-1') + const dot = container.querySelector('.rounded-full.h-1\\.5') + expect(dot).toHaveClass('bg-priority-urgent') + }) + + it('renders priority dot with high color', () => { + const highTask = { ...mockTask, priority: 'high' as const } + render() + const container = screen.getByTestId('task-compact-task-1') + const dot = container.querySelector('.rounded-full.h-1\\.5') + expect(dot).toHaveClass('bg-priority-high') + }) + + it('renders priority dot with normal color', () => { + const normalTask = { ...mockTask, priority: 'normal' as const } + render() + const container = screen.getByTestId('task-compact-task-1') + const dot = container.querySelector('.rounded-full.h-1\\.5') + expect(dot).toHaveClass('bg-accent') + }) + + it('renders priority dot with low color', () => { + const lowTask = { ...mockTask, priority: 'low' as const } + render() + const container = screen.getByTestId('task-compact-task-1') + const dot = container.querySelector('.rounded-full.h-1\\.5') + expect(dot).toHaveClass('bg-text-muted') + }) + + it('calls onClick when clicked', async () => { + const user = userEvent.setup() + const handleClick = vi.fn() + render() + + await user.click(screen.getByTestId('task-compact-task-1')) + expect(handleClick).toHaveBeenCalledTimes(1) + }) + + it('calls onClick on Enter key', async () => { + const user = userEvent.setup() + const handleClick = vi.fn() + render() + + const card = screen.getByTestId('task-compact-task-1') + card.focus() + await user.keyboard('{Enter}') + expect(handleClick).toHaveBeenCalledTimes(1) + }) + + it('calls onClick on Space key', async () => { + const user = userEvent.setup() + const handleClick = vi.fn() + render() + + const card = screen.getByTestId('task-compact-task-1') + card.focus() + await user.keyboard(' ') + expect(handleClick).toHaveBeenCalledTimes(1) + }) + + it('has button role for accessibility', () => { + render() + const card = screen.getByTestId('task-compact-task-1') + expect(card).toHaveAttribute('role', 'button') + }) + + it('has descriptive aria-label', () => { + render() + const card = screen.getByTestId('task-compact-task-1') + expect(card).toHaveAttribute( + 'aria-label', + 'Fix login bug, priority urgent' + ) + }) + + it('is keyboard focusable', () => { + render() + const card = screen.getByTestId('task-compact-task-1') + expect(card).toHaveAttribute('tabIndex', '0') + }) + + it('applies custom className', () => { + render() + const card = screen.getByTestId('task-compact-task-1') + expect(card).toHaveClass('custom-class') + }) + + it('has hover styles', () => { + render() + const card = screen.getByTestId('task-compact-task-1') + expect(card).toHaveClass('hover:bg-background-elevated') + }) +}) diff --git a/apps/web/src/components/molecules/TaskCardCompact/TaskCardCompact.tsx b/apps/web/src/components/molecules/TaskCardCompact/TaskCardCompact.tsx new file mode 100644 index 0000000..ee9d60b --- /dev/null +++ b/apps/web/src/components/molecules/TaskCardCompact/TaskCardCompact.tsx @@ -0,0 +1,103 @@ +import { forwardRef, useCallback } from 'react' + +import { cn } from '@/lib/utils' +import { Icon, Text } from '@/components/atoms' +import type { TaskData, TaskPriority } from '@/components/organisms/KanbanColumn' + +/** + * Props for the TaskCardCompact component + */ +export interface TaskCardCompactProps + extends Omit, 'onClick'> { + /** The task data to display */ + task: TaskData + /** Callback when the compact card is clicked */ + onClick?: () => void +} + +/** + * Returns the Tailwind background class for a priority dot indicator + */ +function getPriorityDotColor(priority: TaskPriority): string { + const colors: Record = { + urgent: 'bg-priority-urgent', + high: 'bg-priority-high', + normal: 'bg-accent', + low: 'bg-text-muted', + } + return colors[priority] +} + +/** + * TaskCardCompact molecule component + * + * A single-line compact task row for list view mode. Shows a priority + * dot indicator, truncated title, and a chevron for navigation. + * + * @example + * ```tsx + * openTaskDetail('1')} + * /> + * ``` + */ +export const TaskCardCompact = forwardRef( + ({ task, onClick, className, ...props }, ref) => { + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onClick?.() + } + }, + [onClick] + ) + + return ( +
    + {/* Priority dot */} +
    + ) + } +) + +TaskCardCompact.displayName = 'TaskCardCompact' diff --git a/apps/web/src/components/molecules/TaskCardCompact/index.ts b/apps/web/src/components/molecules/TaskCardCompact/index.ts new file mode 100644 index 0000000..2a179f7 --- /dev/null +++ b/apps/web/src/components/molecules/TaskCardCompact/index.ts @@ -0,0 +1,2 @@ +export { TaskCardCompact } from './TaskCardCompact' +export type { TaskCardCompactProps } from './TaskCardCompact' diff --git a/apps/web/src/components/molecules/index.ts b/apps/web/src/components/molecules/index.ts index 13ea644..8f770f6 100644 --- a/apps/web/src/components/molecules/index.ts +++ b/apps/web/src/components/molecules/index.ts @@ -13,6 +13,8 @@ * - TaskMeta: Task metadata display (assignees, priority, timestamp) * - UnreadMentions: Badge showing count of unread @mentions * - ViewToggle: Segmented control for switching between view modes + * - MarkdownViewer: Rich markdown content renderer with design tokens + * - DeliverableView: Document viewer wrapping MarkdownViewer with navigation */ export { @@ -63,3 +65,25 @@ export { type UnreadMentionsIndicatorProps, } from './UnreadMentions' export { ViewToggle, type ViewToggleProps, type ViewMode } from './ViewToggle' +export { AllAgentsRow, type AllAgentsRowProps } from './AllAgentsRow' +export { AgentListItem, type AgentListItemProps } from './AgentListItem' +export { StatusFilterPill, type StatusFilterPillProps } from './StatusFilterPill' +export { + StatusFilterBar, + type StatusFilterBarProps, + type StatusConfig, +} from './StatusFilterBar' +export { + AssigneeDisplay, + type AssigneeDisplayProps, + type AssigneeDisplayData, +} from './AssigneeDisplay' +export { TaskCardCompact, type TaskCardCompactProps } from './TaskCardCompact' +export { AgentFilterChips, type AgentFilterChipsProps } from './AgentFilterChips' +export { FeedTypeTabs, type FeedTypeTabsProps, type FeedType } from './FeedTypeTabs' +export { FeedEntry, type FeedEntryProps } from './FeedEntry' +export { SystemMessage, type SystemMessageProps } from './SystemMessage' +export { AgentStatusBox, type AgentStatusBoxProps } from './AgentStatusBox' +export { AttentionList, type AttentionListProps, type AttentionItem } from './AttentionList' +export { MarkdownViewer, type MarkdownViewerProps } from './MarkdownViewer' +export { DeliverableView, type DeliverableViewProps, type DeliverableDocument } from './DeliverableView' diff --git a/apps/web/src/components/organisms/AgentSidebar/AgentSidebar.test.tsx b/apps/web/src/components/organisms/AgentSidebar/AgentSidebar.test.tsx index 36e0cd6..9194df8 100644 --- a/apps/web/src/components/organisms/AgentSidebar/AgentSidebar.test.tsx +++ b/apps/web/src/components/organisms/AgentSidebar/AgentSidebar.test.tsx @@ -23,10 +23,10 @@ describe('AgentSidebar', () => { expect(screen.getByText('AGENTS')).toBeInTheDocument() }) - it('renders agent count badge', () => { + it('renders total agent count badge', () => { render() - expect(screen.getByText('1/4 active')).toBeInTheDocument() + expect(screen.getByText('4')).toBeInTheDocument() }) it('renders all agents', () => { @@ -37,14 +37,121 @@ describe('AgentSidebar', () => { expect(screen.getByText('Research Bot')).toBeInTheDocument() expect(screen.getByText('Offline Agent')).toBeInTheDocument() }) + }) + + describe('AllAgentsRow', () => { + it('renders AllAgentsRow with correct counts', () => { + render() + + const allAgentsRow = screen.getByTestId('all-agents-row') + expect(allAgentsRow).toBeInTheDocument() + expect(screen.getByText('All Agents')).toBeInTheDocument() + expect(screen.getByText('4 total')).toBeInTheDocument() + expect(screen.getByText('1 ACTIVE')).toBeInTheDocument() + }) + + it('selects AllAgentsRow when no agent is selected', () => { + render() + + const allAgentsRow = screen.getByTestId('all-agents-row') + expect(allAgentsRow).toHaveAttribute('aria-pressed', 'true') + expect(allAgentsRow).toHaveClass('bg-accent/10') + }) + + it('deselects AllAgentsRow when an agent is selected', () => { + render() + + const allAgentsRow = screen.getByTestId('all-agents-row') + expect(allAgentsRow).toHaveAttribute('aria-pressed', 'false') + expect(allAgentsRow).not.toHaveClass('bg-accent/10') + }) + + it('calls onAllAgentsClick when AllAgentsRow is clicked', async () => { + const user = userEvent.setup() + const handleAllAgentsClick = vi.fn() + render( + + ) + + await user.click(screen.getByTestId('all-agents-row')) + + expect(handleAllAgentsClick).toHaveBeenCalledOnce() + }) + }) + + describe('AgentListItem role badges', () => { + it('renders INT badge for writer roles', () => { + render() + + const roleBadge = screen.getByTestId('role-badge-1') + expect(roleBadge).toHaveTextContent('INT') + }) + + it('renders INT badge for editor roles', () => { + render() + + const roleBadge = screen.getByTestId('role-badge-2') + expect(roleBadge).toHaveTextContent('INT') + }) + + it('renders SPC badge for specialist roles', () => { + render() + + const roleBadge = screen.getByTestId('role-badge-3') + expect(roleBadge).toHaveTextContent('SPC') + }) + + it('renders LEAD badge for lead roles', () => { + const leadAgents: AgentData[] = [ + { id: '1', name: 'Team Lead', role: 'Squad Lead', status: 'active' }, + ] + render() + + const roleBadge = screen.getByTestId('role-badge-1') + expect(roleBadge).toHaveTextContent('LEAD') + }) + + it('renders LEAD badge for coordinator roles', () => { + const coordAgents: AgentData[] = [ + { id: '1', name: 'Coordinator Bot', role: 'Task Coordinator', status: 'idle' }, + ] + render() + + const roleBadge = screen.getByTestId('role-badge-1') + expect(roleBadge).toHaveTextContent('LEAD') + }) + }) + + describe('AgentListItem status text', () => { + it('renders WORKING for active agents', () => { + render() + + const statusText = screen.getByTestId('status-text-1') + expect(statusText).toHaveTextContent('WORKING') + }) + + it('renders IDLE for idle agents', () => { + render() + + const statusText = screen.getByTestId('status-text-2') + expect(statusText).toHaveTextContent('IDLE') + }) - it('renders agent roles', () => { + it('renders BLOCKED for blocked agents', () => { render() - expect(screen.getByText('Writer')).toBeInTheDocument() - expect(screen.getByText('Editor')).toBeInTheDocument() - expect(screen.getByText('Researcher')).toBeInTheDocument() - expect(screen.getByText('Helper')).toBeInTheDocument() + const statusText = screen.getByTestId('status-text-3') + expect(statusText).toHaveTextContent('BLOCKED') + }) + + it('renders OFFLINE for offline agents', () => { + render() + + const statusText = screen.getByTestId('status-text-4') + expect(statusText).toHaveTextContent('OFFLINE') }) }) @@ -62,10 +169,10 @@ describe('AgentSidebar', () => { expect(screen.getByText('AGENTS')).toBeInTheDocument() }) - it('does not show count badge when loading', () => { + it('does not show AllAgentsRow when loading', () => { render() - expect(screen.queryByText(/active/)).not.toBeInTheDocument() + expect(screen.queryByTestId('all-agents-row')).not.toBeInTheDocument() }) }) @@ -144,6 +251,13 @@ describe('AgentSidebar', () => { const agentButton = screen.getByTestId('agent-Content-Writer') expect(agentButton).toHaveAttribute('aria-pressed', 'true') }) + + it('AllAgentsRow is deselected when an agent is selected', () => { + render() + + const allAgentsRow = screen.getByTestId('all-agents-row') + expect(allAgentsRow).toHaveAttribute('aria-pressed', 'false') + }) }) describe('click handling', () => { @@ -176,6 +290,14 @@ describe('AgentSidebar', () => { agentButton.focus() expect(document.activeElement).toBe(agentButton) }) + + it('AllAgentsRow is focusable', () => { + render() + + const allAgentsRow = screen.getByTestId('all-agents-row') + allAgentsRow.focus() + expect(document.activeElement).toBe(allAgentsRow) + }) }) describe('accessibility', () => { @@ -198,7 +320,16 @@ describe('AgentSidebar', () => { const agentButton = screen.getByTestId('agent-Content-Writer') expect(agentButton).toHaveAttribute('aria-label', expect.stringContaining('Content Writer')) - expect(agentButton).toHaveAttribute('aria-label', expect.stringContaining('Active')) + expect(agentButton).toHaveAttribute('aria-label', expect.stringContaining('WORKING')) + }) + + it('AllAgentsRow has descriptive aria-label', () => { + render() + + const allAgentsRow = screen.getByTestId('all-agents-row') + expect(allAgentsRow).toHaveAttribute('aria-label', expect.stringContaining('All Agents')) + expect(allAgentsRow).toHaveAttribute('aria-label', expect.stringContaining('4 total')) + expect(allAgentsRow).toHaveAttribute('aria-label', expect.stringContaining('1 active')) }) it('renders as list structure', () => { @@ -239,14 +370,17 @@ describe('AgentSidebar', () => { const singleAgent = [mockAgents[0]] render() - expect(screen.getByText('1/1 active')).toBeInTheDocument() + expect(screen.getByText('1')).toBeInTheDocument() + expect(screen.getByText('1 total')).toBeInTheDocument() + expect(screen.getByText('1 ACTIVE')).toBeInTheDocument() }) it('handles all agents inactive', () => { const inactiveAgents = mockAgents.map(a => ({ ...a, status: 'idle' as const })) render() - expect(screen.getByText('0/4 active')).toBeInTheDocument() + expect(screen.getByText('4')).toBeInTheDocument() + expect(screen.getByText('0 ACTIVE')).toBeInTheDocument() }) it('handles agent name with spaces', () => { diff --git a/apps/web/src/components/organisms/AgentSidebar/AgentSidebar.tsx b/apps/web/src/components/organisms/AgentSidebar/AgentSidebar.tsx index 43bafd8..d90e0ac 100644 --- a/apps/web/src/components/organisms/AgentSidebar/AgentSidebar.tsx +++ b/apps/web/src/components/organisms/AgentSidebar/AgentSidebar.tsx @@ -1,6 +1,8 @@ 'use client' -import { Avatar, Badge, Icon, StatusDot, Text } from '@/components/atoms' +import { Badge, Icon, Text } from '@/components/atoms' +import { AgentListItem } from '@/components/molecules/AgentListItem' +import { AllAgentsRow } from '@/components/molecules/AllAgentsRow' import { cn } from '@/lib/utils' /** @@ -33,67 +35,20 @@ export interface AgentSidebarProps { selectedAgentId?: string | null /** Callback when an agent is clicked */ onAgentClick?: (agent: AgentData) => void + /** Callback when the All Agents row is clicked */ + onAllAgentsClick?: () => void /** Whether the sidebar is in a loading state */ isLoading?: boolean /** Additional CSS classes */ className?: string } -/** - * Maps agent status to StatusDot status - * Note: StatusDot doesn't support 'blocked', so we map it to 'offline' - */ -function getStatusDotStatus( - status: AgentData['status'] -): 'active' | 'idle' | 'offline' { - if (status === 'blocked') return 'offline' - return status -} - -/** - * Gets a human-readable label for the agent status - */ -function getStatusLabel(status: AgentData['status']): string { - switch (status) { - case 'active': - return 'Active' - case 'idle': - return 'Idle' - case 'blocked': - return 'Blocked' - case 'offline': - return 'Offline' - default: - return 'Unknown' - } -} - -/** - * Maps avatar color strings to Avatar color props - */ -function getAvatarColor( - color?: string -): 'accent' | 'blue' | 'green' | 'purple' | 'orange' | 'pink' | 'teal' { - const validColors = [ - 'accent', - 'blue', - 'green', - 'purple', - 'orange', - 'pink', - 'teal', - ] as const - if (color && validColors.includes(color as (typeof validColors)[number])) { - return color as (typeof validColors)[number] - } - return 'accent' -} - /** * AgentSidebar organism component * * Displays a list of agents with their status indicators in the dashboard sidebar. - * Supports agent selection and shows visual feedback for the selected agent. + * Includes an "All Agents" summary row at the top and individual agent rows with + * role badges, status text labels, and selection state. * * @example * ```tsx @@ -104,6 +59,7 @@ function getAvatarColor( * ]} * selectedAgentId="1" * onAgentClick={(agent) => console.log('Selected:', agent.name)} + * onAllAgentsClick={() => console.log('All agents selected')} * /> * ``` */ @@ -111,6 +67,7 @@ export function AgentSidebar({ agents, selectedAgentId, onAgentClick, + onAllAgentsClick, isLoading = false, className, }: AgentSidebarProps) { @@ -187,108 +144,29 @@ export function AgentSidebar({ AGENTS - {activeCount}/{agents.length} active + {agents.length}
    + {/* All Agents Row */} + onAllAgentsClick?.()} + /> + {/* Agent list */}
      - {agents.map((agent) => { - const isSelected = selectedAgentId === agent.id - const statusDotStatus = getStatusDotStatus(agent.status) - - return ( -
    • - -
    • - ) - })} + {agents.map((agent) => ( +
    • + onAgentClick?.(agent)} + /> +
    • + ))}
    ) diff --git a/apps/web/src/components/organisms/AgentSidebarWithPanel/AgentSidebarWithPanel.tsx b/apps/web/src/components/organisms/AgentSidebarWithPanel/AgentSidebarWithPanel.tsx index 7825c3c..1f6501b 100644 --- a/apps/web/src/components/organisms/AgentSidebarWithPanel/AgentSidebarWithPanel.tsx +++ b/apps/web/src/components/organisms/AgentSidebarWithPanel/AgentSidebarWithPanel.tsx @@ -2,12 +2,15 @@ import * as React from 'react' +import { formatRelativeTime } from '@/lib/formatRelativeTime' import { Text } from '@/components/atoms' +import { AttentionList } from '@/components/molecules/AttentionList' import { AgentSidebar } from '@/components/organisms/AgentSidebar' import type { AgentData } from '@/components/organisms/AgentSidebar' import { AgentProfilePanel } from '@/components/templates' import type { AgentProfileData } from '@/components/templates' -import { createClient } from '@/lib/supabase/client' +import { useAgentAttention } from '@/hooks/useAgentAttention' +import { createClient } from '@/lib/supabase/browser' import { cn } from '@/lib/utils' import type { Database } from '@mission-control/database' @@ -41,26 +44,6 @@ function getActivityTypeLabel(type: ActivityRow['type']): string { return labels[type] ?? 'Activity' } -/** - * Formats a timestamp as relative time (e.g., "2h ago", "5m ago") - */ -function formatRelativeTime(timestamp: string | null): string { - if (!timestamp) return '' - - const now = new Date() - const date = new Date(timestamp) - const diffMs = now.getTime() - date.getTime() - const diffSeconds = Math.floor(diffMs / 1000) - const diffMinutes = Math.floor(diffSeconds / 60) - const diffHours = Math.floor(diffMinutes / 60) - const diffDays = Math.floor(diffHours / 24) - - if (diffDays > 0) return `${diffDays}d ago` - if (diffHours > 0) return `${diffHours}h ago` - if (diffMinutes > 0) return `${diffMinutes}m ago` - return 'just now' -} - /** * Timeline content component showing agent activities */ @@ -169,6 +152,11 @@ export function AgentSidebarWithPanel({ const [messages, setMessages] = React.useState([]) const [isLoading, setIsLoading] = React.useState(false) + // Fetch attention items for selected agent + const { items: attentionItems, totalCount: attentionCount } = useAgentAttention( + selectedAgent?.id ?? null + ) + /** * Handles agent click from sidebar - selects the agent and opens the panel */ @@ -177,6 +165,16 @@ export function AgentSidebarWithPanel({ setIsPanelOpen(true) }, []) + /** + * Handles All Agents click - deselects current agent + */ + const handleAllAgentsClick = React.useCallback(() => { + setSelectedAgent(null) + setIsPanelOpen(false) + setActivities([]) + setMessages([]) + }, []) + /** * Handles panel close - resets selection */ @@ -259,12 +257,36 @@ export function AgentSidebarWithPanel({ } }, [selectedAgent, agentProfiles]) + /** + * Handles sending a direct message to the selected agent + */ + const handleSendMessage = React.useCallback(async (message: string) => { + if (!selectedAgent) return + const supabase = createClient() + const { error } = await supabase.from('direct_messages').insert({ + agent_id: selectedAgent.id, + content: message, + from_human: true, + }) + if (!error) { + // Re-fetch messages to show the new one + const { data: messagesData } = await supabase + .from('direct_messages') + .select('*') + .eq('agent_id', selectedAgent.id) + .order('created_at', { ascending: true }) + .limit(50) + if (messagesData) setMessages(messagesData) + } + }, [selectedAgent]) + return (
    } + attentionContent={} + attentionCount={attentionCount} + onSendMessage={handleSendMessage} />
    ) diff --git a/apps/web/src/components/organisms/Header/Header.test.tsx b/apps/web/src/components/organisms/Header/Header.test.tsx index d304100..8047d35 100644 --- a/apps/web/src/components/organisms/Header/Header.test.tsx +++ b/apps/web/src/components/organisms/Header/Header.test.tsx @@ -1,152 +1,222 @@ import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { Header } from './Header' -// Mock next/link to avoid router issues in tests -vi.mock('next/link', () => ({ - default: ({ children, href, ...props }: { children: React.ReactNode; href: string }) => ( - {children} - ), +// Mock next/navigation to keep tests safe from router context +vi.mock('next/navigation', () => ({ + usePathname: () => '/dashboard', })) describe('Header', () => { describe('basic rendering', () => { - it('renders header element', () => { + it('renders header element with data-testid', () => { render(
    ) - expect(screen.getByTestId('header')).toBeInTheDocument() + const header = screen.getByTestId('header') + expect(header).toBeInTheDocument() + expect(header.tagName).toBe('HEADER') }) - it('renders logo section', () => { + it('shows diamond logo icon', () => { render(
    ) - expect(screen.getByTestId('header-logo')).toBeInTheDocument() + expect(screen.getByTestId('header-logo-icon')).toBeInTheDocument() }) - it('renders MC badge in logo', () => { + it('shows MISSION CONTROL title', () => { render(
    ) - expect(screen.getByText('MC')).toBeInTheDocument() + expect(screen.getByTestId('header-title')).toHaveTextContent( + 'MISSION CONTROL' + ) }) + }) - it('logo links to home', () => { - render(
    ) + describe('squad badge', () => { + it('shows squad badge when squadName is provided', () => { + render(
    ) - const link = screen.getByTestId('header-logo').querySelector('a') - expect(link).toHaveAttribute('href', '/') + expect(screen.getByTestId('squad-badge')).toBeInTheDocument() + expect(screen.getByText('Alpha Team')).toBeInTheDocument() }) - }) - describe('stats display', () => { - it('renders stats section', () => { + it('does not show squad badge when squadName is not provided', () => { render(
    ) - expect(screen.getByTestId('header-stats')).toBeInTheDocument() + expect(screen.queryByTestId('squad-badge')).not.toBeInTheDocument() }) + }) - it('displays agent counts', () => { - render(
    ) + describe('stats display', () => { + it('shows agents active stat', () => { + render(
    ) expect(screen.getByText('3')).toBeInTheDocument() - expect(screen.getByText('/5')).toBeInTheDocument() - expect(screen.getByText(/AGENTS ACTIVE/)).toBeInTheDocument() + expect(screen.getByText('AGENTS ACTIVE')).toBeInTheDocument() }) - it('displays pending tasks count', () => { + it('shows tasks in queue stat', () => { render(
    ) expect(screen.getByText('12')).toBeInTheDocument() - expect(screen.getByText(/PENDING/)).toBeInTheDocument() + expect(screen.getByText('TASKS IN QUEUE')).toBeInTheDocument() }) it('defaults to zero counts', () => { render(
    ) const zeros = screen.getAllByText('0') - expect(zeros.length).toBeGreaterThanOrEqual(1) + expect(zeros).toHaveLength(2) }) - }) - describe('clock placeholder', () => { - it('renders clock section', () => { + it('renders stats section with data-testid', () => { render(
    ) - expect(screen.getByTestId('header-clock')).toBeInTheDocument() + expect(screen.getByTestId('header-stats')).toBeInTheDocument() }) + }) - it('displays placeholder time', () => { + describe('status toggle', () => { + it('shows StatusToggle with active status by default', () => { render(
    ) - expect(screen.getByText('00:00:00')).toBeInTheDocument() + const toggle = screen.getByTestId('status-toggle') + expect(toggle).toBeInTheDocument() + expect(toggle).toHaveTextContent('ACTIVE') + }) + + it('shows StatusToggle with paused status', () => { + render(
    ) + + const toggle = screen.getByTestId('status-toggle') + expect(toggle).toHaveTextContent('PAUSED') + }) + + it('calls onSquadStatusToggle when clicked', async () => { + const user = userEvent.setup() + const handleToggle = vi.fn() + render( +
    + ) + + await user.click(screen.getByTestId('status-toggle')) + expect(handleToggle).toHaveBeenCalledTimes(1) }) }) - describe('docs button', () => { - it('renders docs button', () => { + describe('action buttons', () => { + it('renders chat button', () => { render(
    ) - expect(screen.getByTestId('header-docs-button')).toBeInTheDocument() + expect(screen.getByTestId('header-chat-button')).toBeInTheDocument() + expect(screen.getByLabelText('Open chat')).toBeInTheDocument() }) - it('docs button links to /docs by default', () => { + it('renders broadcast button', () => { render(
    ) - const link = screen.getByTestId('header-docs-button') - expect(link).toHaveAttribute('href', '/docs') + expect( + screen.getByTestId('header-broadcast-button') + ).toBeInTheDocument() + expect(screen.getByLabelText('Send broadcast')).toBeInTheDocument() }) - it('docs button uses custom URL when provided', () => { - render(
    ) + it('renders docs button', () => { + render(
    ) - const link = screen.getByTestId('header-docs-button') - expect(link).toHaveAttribute('href', '/custom-docs') + expect(screen.getByTestId('header-docs-button')).toBeInTheDocument() + expect(screen.getByLabelText('Open documentation')).toBeInTheDocument() }) - it('docs link opens in new tab', () => { - render(
    ) + it('calls action handlers when clicked', async () => { + const user = userEvent.setup() + const onChat = vi.fn() + const onBroadcast = vi.fn() + const onDocs = vi.fn() + + render( +
    + ) - const link = screen.getByTestId('header-docs-button') - expect(link).toHaveAttribute('target', '_blank') - expect(link).toHaveAttribute('rel', 'noopener noreferrer') + await user.click(screen.getByTestId('header-chat-button')) + expect(onChat).toHaveBeenCalledTimes(1) + + await user.click(screen.getByTestId('header-broadcast-button')) + expect(onBroadcast).toHaveBeenCalledTimes(1) + + await user.click(screen.getByTestId('header-docs-button')) + expect(onDocs).toHaveBeenCalledTimes(1) }) + }) - it('has accessible label', () => { + describe('clock', () => { + it('renders Clock component', () => { render(
    ) - expect(screen.getByLabelText('Open documentation')).toBeInTheDocument() + expect(screen.getByTestId('clock')).toBeInTheDocument() }) }) - describe('focus indicator', () => { - it('renders focus indicator when focusedAgent is provided', () => { - render(
    ) + describe('connection status', () => { + it('shows connected state by default', () => { + render(
    ) - expect(screen.getByTestId('header-focus-indicator')).toBeInTheDocument() + expect(screen.getByTestId('connection-status')).toBeInTheDocument() + expect(screen.getByText('ONLINE')).toBeInTheDocument() }) - it('displays agent name in focus indicator', () => { - render(
    ) + it('shows disconnected state', () => { + render(
    ) - expect(screen.getByText(/IN FOCUS:/)).toBeInTheDocument() - expect(screen.getByText(/Content Writer/)).toBeInTheDocument() + expect(screen.getByText('OFFLINE')).toBeInTheDocument() }) + }) - it('does not render focus indicator when focusedAgent is null', () => { - render(
    ) + describe('filter indicator', () => { + it('shows FilterIndicator when filteredAgentName is set', () => { + render( +
    + ) - expect(screen.queryByTestId('header-focus-indicator')).not.toBeInTheDocument() + expect(screen.getByTestId('filter-indicator')).toBeInTheDocument() + expect(screen.getByText('Content Writer')).toBeInTheDocument() + expect(screen.getByText('5 TASKS')).toBeInTheDocument() }) - it('does not render focus indicator when focusedAgent is not provided', () => { - render(
    ) + it('FilterIndicator replaces stats display', () => { + render( +
    + ) - expect(screen.queryByTestId('header-focus-indicator')).not.toBeInTheDocument() + expect(screen.getByTestId('filter-indicator')).toBeInTheDocument() + expect(screen.queryByTestId('header-stats')).not.toBeInTheDocument() }) - it('focus indicator has accent styling', () => { - render(
    ) + it('shows stats when filteredAgentName is null', () => { + render(
    ) - const indicator = screen.getByTestId('header-focus-indicator') - expect(indicator).toHaveClass('border-accent/30', 'bg-accent/10') + expect( + screen.queryByTestId('filter-indicator') + ).not.toBeInTheDocument() + expect(screen.getByTestId('header-stats')).toBeInTheDocument() + }) + + it('shows stats when filteredAgentName is not provided', () => { + render(
    ) + + expect( + screen.queryByTestId('filter-indicator') + ).not.toBeInTheDocument() + expect(screen.getByTestId('header-stats')).toBeInTheDocument() }) }) @@ -158,13 +228,6 @@ describe('Header', () => { expect(header).toHaveClass('custom-class') }) - it('header is rendered as header element', () => { - render(
    ) - - const header = screen.getByTestId('header') - expect(header.tagName).toBe('HEADER') - }) - it('has full width and padding', () => { render(
    ) @@ -179,19 +242,4 @@ describe('Header', () => { expect(header).toHaveClass('flex', 'items-center', 'justify-between') }) }) - - describe('edge cases', () => { - it('handles large agent counts', () => { - render(
    ) - - expect(screen.getByText('999')).toBeInTheDocument() - expect(screen.getByText('/1000')).toBeInTheDocument() - }) - - it('handles long agent names', () => { - render(
    ) - - expect(screen.getByText(/Super Long Agent Name That Could Overflow/)).toBeInTheDocument() - }) - }) }) diff --git a/apps/web/src/components/organisms/Header/Header.tsx b/apps/web/src/components/organisms/Header/Header.tsx index 1f67f93..f9e2815 100644 --- a/apps/web/src/components/organisms/Header/Header.tsx +++ b/apps/web/src/components/organisms/Header/Header.tsx @@ -1,167 +1,198 @@ 'use client' -import Link from 'next/link' -import { usePathname } from 'next/navigation' -import { LayoutDashboard, Users, Bot, ListTodo, Plus } from 'lucide-react' -import { Badge, Button, Icon } from '@/components/atoms' +import { forwardRef } from 'react' +import { Diamond, MessageSquare, Megaphone, BookOpen } from 'lucide-react' +import { Button } from '@/components/atoms' +import { Clock } from '@/components/molecules/Clock' +import { SquadBadge } from '@/components/molecules/SquadBadge' +import { StatusToggle } from '@/components/molecules/StatusToggle' +import { ConnectionStatus } from '@/components/molecules/ConnectionStatus' +import { FilterIndicator } from '@/components/molecules/FilterIndicator' import { cn } from '@/lib/utils' -const navItems = [ - { href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard }, - { href: '/squads', label: 'Squads', icon: Users }, - { href: '/agents', label: 'Agents', icon: Bot }, - { href: '/tasks', label: 'Tasks', icon: ListTodo }, -] as const - export interface HeaderProps { - /** Name of currently focused agent (shows "IN FOCUS: [Agent]" display) */ - focusedAgent?: string | null - /** Number of active agents for stats display */ + /** Name of the current squad */ + squadName?: string + /** Current squad operational status */ + squadStatus?: 'active' | 'paused' + /** Callback when squad status toggle is clicked */ + onSquadStatusToggle?: () => void + /** Number of currently active agents */ activeAgentsCount?: number - /** Total number of agents for stats display */ + /** Total number of agents in the squad */ totalAgentsCount?: number - /** Number of pending tasks */ + /** Number of tasks currently in the queue */ pendingTasksCount?: number - /** URL for docs button */ - docsUrl?: string + /** Callback when the chat action button is clicked */ + onChatClick?: () => void + /** Callback when the broadcast action button is clicked */ + onBroadcastClick?: () => void + /** Callback when the docs action button is clicked */ + onDocsClick?: () => void + /** Whether the dashboard is connected to the backend */ + isConnected?: boolean + /** When set, replaces the stats display with a filter indicator */ + filteredAgentName?: string | null + /** Number of tasks for the filtered agent */ + filteredTaskCount?: number /** Additional CSS classes */ className?: string } /** - * Header organism component for the Mission Control dashboard. + * Header organism component for the Mission Control command-center dashboard. * - * Displays: - * - Logo ("MC" badge + "Mission Control" text) - * - Stats badges showing active agents and pending tasks - * - Clock placeholder area (Clock molecule will be task 2.1.6) - * - Docs button linking to documentation - * - Optional "IN FOCUS: [Agent]" display + * Displays a horizontal status bar with: + * - Logo (Diamond icon) + "MISSION CONTROL" branding + optional squad badge + * - Center: Large stat numbers (agents active, tasks in queue) or filter indicator + * - Status toggle pill (ACTIVE / PAUSED) + * - Action buttons (Chat, Broadcast, Docs) + * - Clock with date + * - Connection status indicator * * @example * ```tsx *
    toggleStatus()} * activeAgentsCount={3} * totalAgentsCount={5} * pendingTasksCount={12} - * focusedAgent="Content Writer" + * isConnected={true} * /> * ``` */ -export function Header({ - focusedAgent, - activeAgentsCount = 0, - totalAgentsCount = 0, - pendingTasksCount = 0, - docsUrl = '/docs', - className, -}: HeaderProps) { - const pathname = usePathname() - - return ( -
    - {/* Left section: Logo and Navigation */} -
    - -
    - MC -
    - - - {/* Navigation */} - -
    - - {/* Center section: Stats and Clock */} -
    - {/* Stats Area */} -
    - - {activeAgentsCount} - /{totalAgentsCount} - {' AGENTS ACTIVE'} - - - {pendingTasksCount} - {' PENDING'} - + MISSION CONTROL + + {squadName && }
    - {/* Clock Area - placeholder for Clock molecule (task 2.1.6) */} + {/* Center section: Stats or Filter Indicator */}
    - - - 00:00:00 - + {filteredAgentName ? ( + + ) : ( +
    +
    + + {activeAgentsCount} + + + AGENTS ACTIVE + +
    +
    + + {pendingTasksCount} + + + TASKS IN QUEUE + +
    +
    + )}
    -
    - {/* Right section: Docs button and Focus indicator */} -
    - {/* Docs Button */} - - - + {/* Right section: Status toggle + Actions + Clock + Connection */} +
    + {})} + disabled={!onSquadStatusToggle} + /> - {/* Focus Indicator */} - {focusedAgent && ( + {/* Action buttons */}
    - - - IN FOCUS: {focusedAgent} - + + +
    - )} -
    -
    - ) -} + + + + + +
    + ) + } +) + +Header.displayName = 'Header' diff --git a/apps/web/src/components/organisms/KanbanColumn/KanbanColumn.test.tsx b/apps/web/src/components/organisms/KanbanColumn/KanbanColumn.test.tsx index 7b509e4..0772636 100644 --- a/apps/web/src/components/organisms/KanbanColumn/KanbanColumn.test.tsx +++ b/apps/web/src/components/organisms/KanbanColumn/KanbanColumn.test.tsx @@ -37,10 +37,10 @@ describe('KanbanColumn', () => { expect(screen.getByText('INBOX')).toBeInTheDocument() }) - it('renders task count badge', () => { + it('renders task count in parentheses', () => { render() - expect(screen.getByText('3')).toBeInTheDocument() + expect(screen.getByText('(3)')).toBeInTheDocument() }) it('renders all tasks', () => { @@ -202,10 +202,10 @@ describe('KanbanColumn', () => { expect(column).toHaveAttribute('aria-label', 'INBOX column, 3 tasks') }) - it('task count badge has aria-label', () => { + it('renders task count text', () => { render() - expect(screen.getByLabelText('3 tasks')).toBeInTheDocument() + expect(screen.getByText('(3)')).toBeInTheDocument() }) }) diff --git a/apps/web/src/components/organisms/KanbanColumn/KanbanColumn.tsx b/apps/web/src/components/organisms/KanbanColumn/KanbanColumn.tsx index 78635a3..12fb70e 100644 --- a/apps/web/src/components/organisms/KanbanColumn/KanbanColumn.tsx +++ b/apps/web/src/components/organisms/KanbanColumn/KanbanColumn.tsx @@ -4,7 +4,7 @@ import type { ComponentProps, ReactNode } from 'react' import { useMemo } from 'react' import { cn } from '@/lib/utils' -import { Badge, Icon, Text } from '@/components/atoms' +import { Icon, Text } from '@/components/atoms' type IconName = ComponentProps['name'] @@ -60,6 +60,8 @@ export interface TaskData { updated_at?: string | null /** Whether the task is blocked */ isBlocked?: boolean + /** Optional tags for categorization */ + tags?: string[] } /** @@ -164,27 +166,18 @@ export function KanbanColumn({ {/* Column header */}
    -
    -
    - - {tasks.length} - +
    {/* Task list with drop zone support */} diff --git a/apps/web/src/components/organisms/LiveFeed/LiveFeed.test.tsx b/apps/web/src/components/organisms/LiveFeed/LiveFeed.test.tsx index ae5b670..8aa94cd 100644 --- a/apps/web/src/components/organisms/LiveFeed/LiveFeed.test.tsx +++ b/apps/web/src/components/organisms/LiveFeed/LiveFeed.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react' +import { render, screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { LiveFeed, type ActivityData } from './LiveFeed' @@ -14,6 +14,7 @@ describe('LiveFeed', () => { { id: '1', squad_id: 'squad-1', + agent_id: 'agent-1', type: 'task_created', message: 'created a new task', agent_name: 'Content Writer', @@ -23,6 +24,7 @@ describe('LiveFeed', () => { { id: '2', squad_id: 'squad-1', + agent_id: 'agent-2', type: 'task_status_changed', message: 'moved task to in progress', agent_name: 'Editor', @@ -32,12 +34,18 @@ describe('LiveFeed', () => { { id: '3', squad_id: 'squad-1', + agent_id: 'agent-1', type: 'message_sent', message: 'sent a message', created_at: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), // 2 hours ago }, ] + const mockAgents = [ + { id: 'agent-1', name: 'Content Writer' }, + { id: 'agent-2', name: 'Editor' }, + ] + describe('basic rendering', () => { it('renders feed container', () => { render() @@ -45,10 +53,10 @@ describe('LiveFeed', () => { expect(screen.getByTestId('live-feed')).toBeInTheDocument() }) - it('renders ACTIVITY header', () => { + it('renders LIVE FEED header', () => { render() - expect(screen.getByText('ACTIVITY')).toBeInTheDocument() + expect(screen.getByText('LIVE FEED')).toBeInTheDocument() }) it('renders all activities', () => { @@ -81,6 +89,195 @@ describe('LiveFeed', () => { expect(screen.getByText('30m ago')).toBeInTheDocument() expect(screen.getByText('2h ago')).toBeInTheDocument() }) + + it('renders FeedTypeTabs', () => { + render() + + expect(screen.getByTestId('feed-type-tabs')).toBeInTheDocument() + expect(screen.getByTestId('feed-tab-all')).toBeInTheDocument() + expect(screen.getByTestId('feed-tab-tasks')).toBeInTheDocument() + expect(screen.getByTestId('feed-tab-comments')).toBeInTheDocument() + }) + + it('renders StatusDot in header', () => { + render() + + // StatusDot has role="status" + const statusDots = screen.getAllByRole('status') + expect(statusDots.length).toBeGreaterThanOrEqual(1) + }) + }) + + describe('agent filter chips', () => { + it('renders AgentFilterChips when agents prop is provided', () => { + render() + + expect(screen.getByTestId('agent-filter-chips')).toBeInTheDocument() + }) + + it('does not render AgentFilterChips when agents prop is not provided', () => { + render() + + expect(screen.queryByTestId('agent-filter-chips')).not.toBeInTheDocument() + }) + + it('renders All chip and agent chips', () => { + render() + + expect(screen.getByTestId('agent-chip-all')).toBeInTheDocument() + expect(screen.getByTestId('agent-chip-agent-1')).toBeInTheDocument() + expect(screen.getByTestId('agent-chip-agent-2')).toBeInTheDocument() + }) + + it('filters activities when an agent chip is clicked', async () => { + const user = userEvent.setup() + render() + + // Click on Content Writer chip (agent-1) + await user.click(screen.getByTestId('agent-chip-agent-1')) + + // Activities by agent-1 should remain visible + expect(screen.getByTestId('activity-1')).toBeInTheDocument() + expect(screen.getByTestId('activity-3')).toBeInTheDocument() + + // Activity by agent-2 should be filtered out + expect(screen.queryByTestId('activity-2')).not.toBeInTheDocument() + }) + + it('shows all activities when All chip is clicked after filtering', async () => { + const user = userEvent.setup() + render() + + // Filter by agent + await user.click(screen.getByTestId('agent-chip-agent-2')) + expect(screen.queryByTestId('activity-1')).not.toBeInTheDocument() + + // Reset by clicking All + await user.click(screen.getByTestId('agent-chip-all')) + expect(screen.getByTestId('activity-1')).toBeInTheDocument() + expect(screen.getByTestId('activity-2')).toBeInTheDocument() + expect(screen.getByTestId('activity-3')).toBeInTheDocument() + }) + }) + + describe('feed type tabs', () => { + it('renders all type tabs', () => { + render() + + expect(screen.getByTestId('feed-tab-all')).toBeInTheDocument() + expect(screen.getByTestId('feed-tab-tasks')).toBeInTheDocument() + expect(screen.getByTestId('feed-tab-comments')).toBeInTheDocument() + expect(screen.getByTestId('feed-tab-docs')).toBeInTheDocument() + expect(screen.getByTestId('feed-tab-status')).toBeInTheDocument() + }) + + it('filters activities by type when a tab is clicked', async () => { + const user = userEvent.setup() + render() + + // Click "Comments" tab - should only show message_sent activities + await user.click(screen.getByTestId('feed-tab-comments')) + + expect(screen.getByTestId('activity-3')).toBeInTheDocument() // message_sent + expect(screen.queryByTestId('activity-1')).not.toBeInTheDocument() // task_created + expect(screen.queryByTestId('activity-2')).not.toBeInTheDocument() // task_status_changed + }) + + it('filters activities by tasks type', async () => { + const user = userEvent.setup() + render() + + // Click "Tasks" tab + await user.click(screen.getByTestId('feed-tab-tasks')) + + expect(screen.getByTestId('activity-1')).toBeInTheDocument() // task_created + expect(screen.getByTestId('activity-2')).toBeInTheDocument() // task_status_changed + expect(screen.queryByTestId('activity-3')).not.toBeInTheDocument() // message_sent + }) + + it('shows all activities when All tab is clicked', async () => { + const user = userEvent.setup() + render() + + // Filter then reset + await user.click(screen.getByTestId('feed-tab-comments')) + await user.click(screen.getByTestId('feed-tab-all')) + + expect(screen.getByTestId('activity-1')).toBeInTheDocument() + expect(screen.getByTestId('activity-2')).toBeInTheDocument() + expect(screen.getByTestId('activity-3')).toBeInTheDocument() + }) + + it('shows empty filter message when no activities match', async () => { + const user = userEvent.setup() + render() + + // Click "Docs" tab - no document_created activities in mock data + await user.click(screen.getByTestId('feed-tab-docs')) + + expect(screen.getByText('No activities match the current filters')).toBeInTheDocument() + }) + + it('displays type counts in tabs', () => { + render() + + // "All" tab should show total count + const allTab = screen.getByTestId('feed-tab-all') + expect(within(allTab).getByText('(3)')).toBeInTheDocument() + }) + }) + + describe('combined filtering', () => { + it('filters by both agent and type simultaneously', async () => { + const user = userEvent.setup() + + const activities: ActivityData[] = [ + { + id: '1', + squad_id: 's1', + agent_id: 'agent-1', + type: 'task_created', + message: 'task by writer', + agent_name: 'Writer', + created_at: new Date().toISOString(), + }, + { + id: '2', + squad_id: 's1', + agent_id: 'agent-1', + type: 'message_sent', + message: 'comment by writer', + agent_name: 'Writer', + created_at: new Date().toISOString(), + }, + { + id: '3', + squad_id: 's1', + agent_id: 'agent-2', + type: 'task_created', + message: 'task by editor', + agent_name: 'Editor', + created_at: new Date().toISOString(), + }, + ] + + const agents = [ + { id: 'agent-1', name: 'Writer' }, + { id: 'agent-2', name: 'Editor' }, + ] + + render() + + // Filter by agent-1 + await user.click(screen.getByTestId('agent-chip-agent-1')) + // Filter by tasks type + await user.click(screen.getByTestId('feed-tab-tasks')) + + // Only task_created by agent-1 should be visible + expect(screen.getByTestId('activity-1')).toBeInTheDocument() + expect(screen.queryByTestId('activity-2')).not.toBeInTheDocument() + expect(screen.queryByTestId('activity-3')).not.toBeInTheDocument() + }) }) describe('loading state', () => { @@ -207,20 +404,78 @@ describe('LiveFeed', () => { describe('activity types', () => { it('renders different icons for different activity types', () => { const activities: ActivityData[] = [ - { id: '1', squad_id: 's1', type: 'task_created', message: 'task', created_at: new Date().toISOString() }, - { id: '2', squad_id: 's1', type: 'task_assigned', message: 'assigned', created_at: new Date().toISOString() }, - { id: '3', squad_id: 's1', type: 'message_sent', message: 'message', created_at: new Date().toISOString() }, - { id: '4', squad_id: 's1', type: 'document_created', message: 'document', created_at: new Date().toISOString() }, + { id: '1', squad_id: 's1', agent_id: 'a1', type: 'task_created', message: 'task', created_at: new Date().toISOString() }, + { id: '2', squad_id: 's1', agent_id: 'a1', type: 'task_assigned', message: 'assigned', created_at: new Date().toISOString() }, + { id: '3', squad_id: 's1', agent_id: 'a1', type: 'message_sent', message: 'message', created_at: new Date().toISOString() }, + { id: '4', squad_id: 's1', agent_id: 'a1', type: 'document_created', message: 'document', created_at: new Date().toISOString() }, { id: '5', squad_id: 's1', type: 'agent_status_changed', message: 'status', created_at: new Date().toISOString() }, ] render() - // All activities should render + // Non-system activities should render as FeedEntry expect(screen.getByTestId('activity-1')).toBeInTheDocument() expect(screen.getByTestId('activity-2')).toBeInTheDocument() expect(screen.getByTestId('activity-3')).toBeInTheDocument() expect(screen.getByTestId('activity-4')).toBeInTheDocument() - expect(screen.getByTestId('activity-5')).toBeInTheDocument() + // System activity (agent_status_changed) renders as SystemMessage + expect(screen.getByTestId('system-message')).toBeInTheDocument() + expect(screen.getByText('status')).toBeInTheDocument() + }) + }) + + describe('system message rendering', () => { + it('renders SystemMessage for activities without agent_id', () => { + const activities: ActivityData[] = [ + { id: '1', squad_id: 's1', type: 'task_created', message: 'system task created', created_at: new Date().toISOString() }, + ] + render() + + expect(screen.getByTestId('system-message')).toBeInTheDocument() + expect(screen.getByText('system task created')).toBeInTheDocument() + }) + + it('renders SystemMessage for agent_status_changed activities', () => { + const activities: ActivityData[] = [ + { id: '1', squad_id: 's1', agent_id: 'a1', type: 'agent_status_changed', message: 'Agent activated', created_at: new Date().toISOString() }, + ] + render() + + expect(screen.getByTestId('system-message')).toBeInTheDocument() + expect(screen.getByText('Agent activated')).toBeInTheDocument() + }) + + it('renders warning variant for messages containing paused or offline', () => { + const activities: ActivityData[] = [ + { id: '1', squad_id: 's1', type: 'agent_status_changed', message: 'Agent PAUSED by user', created_at: new Date().toISOString() }, + ] + const { container } = render() + + const systemMsg = screen.getByTestId('system-message') + expect(systemMsg).toBeInTheDocument() + // Warning variant uses border-status-blocked styling + expect(systemMsg.className).toContain('border-status-blocked') + }) + + it('renders info variant for non-warning system messages', () => { + const activities: ActivityData[] = [ + { id: '1', squad_id: 's1', type: 'agent_status_changed', message: 'Agent activated', created_at: new Date().toISOString() }, + ] + render() + + const systemMsg = screen.getByTestId('system-message') + expect(systemMsg).toBeInTheDocument() + // Info variant uses border-accent styling + expect(systemMsg.className).toContain('border-accent') + }) + + it('renders FeedEntry for activities with agent_id and non-status type', () => { + const activities: ActivityData[] = [ + { id: '1', squad_id: 's1', agent_id: 'a1', type: 'task_created', message: 'created task', agent_name: 'Writer', created_at: new Date().toISOString() }, + ] + render() + + expect(screen.getByTestId('activity-1')).toBeInTheDocument() + expect(screen.queryByTestId('system-message')).not.toBeInTheDocument() }) }) @@ -253,6 +508,20 @@ describe('LiveFeed', () => { // Either the li or inner element should have article role expect(activity).toHaveAttribute('role', 'article') }) + + it('agent filter chips have radiogroup role', () => { + render() + + const chips = screen.getByTestId('agent-filter-chips') + expect(chips).toHaveAttribute('role', 'radiogroup') + }) + + it('feed type tabs have tablist role', () => { + render() + + const tabs = screen.getByTestId('feed-type-tabs') + expect(tabs).toHaveAttribute('role', 'tablist') + }) }) describe('styling', () => { diff --git a/apps/web/src/components/organisms/LiveFeed/LiveFeed.tsx b/apps/web/src/components/organisms/LiveFeed/LiveFeed.tsx index 16e37cd..664a70d 100644 --- a/apps/web/src/components/organisms/LiveFeed/LiveFeed.tsx +++ b/apps/web/src/components/organisms/LiveFeed/LiveFeed.tsx @@ -1,10 +1,14 @@ 'use client' -import { useMemo } from 'react' -import Link from 'next/link' +import { useMemo, useState } from 'react' import { cn } from '@/lib/utils' -import { Avatar, Icon, Skeleton, Text } from '@/components/atoms' +import { Icon, Skeleton, StatusDot, Text } from '@/components/atoms' +import { AgentFilterChips } from '@/components/molecules/AgentFilterChips' +import { FeedTypeTabs } from '@/components/molecules/FeedTypeTabs' +import type { FeedType } from '@/components/molecules/FeedTypeTabs' +import { FeedEntry } from '@/components/molecules/FeedEntry' +import { SystemMessage } from '@/components/molecules/SystemMessage' /** * Activity type enum matching database schema @@ -56,73 +60,32 @@ export interface LiveFeedProps { maxItems?: number /** Function to generate href for task links. Defaults to `/tasks/${taskId}` */ getTaskHref?: (taskId: string) => string + /** List of agents for the filter chips */ + agents?: Array<{ id: string; name: string }> /** Additional CSS classes */ className?: string } -// Type for icon names we use -type IconNameType = - | 'ListPlus' - | 'ArrowRightLeft' - | 'UserCheck' - | 'MessageSquare' - | 'FilePlus' - | 'Zap' - | 'Activity' - | 'Loader' - | 'ChevronDown' - | 'Users' - /** - * Maps activity type to an icon name + * Maps FeedType to the ActivityType values it includes */ -function getActivityIcon(type: ActivityType): IconNameType { - const iconMap: Record = { - task_created: 'ListPlus', - task_status_changed: 'ArrowRightLeft', - task_assigned: 'UserCheck', - message_sent: 'MessageSquare', - document_created: 'FilePlus', - agent_status_changed: 'Zap', - } - return iconMap[type] || 'Activity' +const feedTypeMap: Record, ActivityType[]> = { + tasks: ['task_created', 'task_status_changed', 'task_assigned'], + comments: ['message_sent'], + docs: ['document_created'], + status: ['agent_status_changed'], } /** - * Maps activity type to a color class for the icon background + * Determines which FeedType bucket an ActivityType belongs to */ -function getActivityColor(type: ActivityType): string { - const colorMap: Record = { - task_created: 'bg-green-100 text-green-600', - task_status_changed: 'bg-blue-100 text-blue-600', - task_assigned: 'bg-purple-100 text-purple-600', - message_sent: 'bg-accent-muted text-accent', - document_created: 'bg-orange-100 text-orange-600', - agent_status_changed: 'bg-yellow-100 text-yellow-600', +function getFeedTypeForActivity(type: ActivityType): Exclude { + for (const [feedType, activityTypes] of Object.entries(feedTypeMap)) { + if (activityTypes.includes(type)) { + return feedType as Exclude + } } - return colorMap[type] || 'bg-background-elevated text-text-muted' -} - -/** - * Formats a timestamp to relative time (e.g., "2m ago", "1h ago") - */ -function formatRelativeTime(timestamp: string | null | undefined): string { - if (!timestamp) return '' - - const date = new Date(timestamp) - const now = new Date() - const diffMs = now.getTime() - date.getTime() - const diffSec = Math.floor(diffMs / 1000) - const diffMin = Math.floor(diffSec / 60) - const diffHour = Math.floor(diffMin / 60) - const diffDay = Math.floor(diffHour / 24) - - if (diffSec < 60) return 'just now' - if (diffMin < 60) return `${diffMin}m ago` - if (diffHour < 24) return `${diffHour}h ago` - if (diffDay < 7) return `${diffDay}d ago` - - return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + return 'tasks' } /** @@ -130,6 +93,7 @@ function formatRelativeTime(timestamp: string | null | undefined): string { * * Displays a real-time activity stream showing recent events in the squad. * Activities are displayed newest first with icons indicating the type of activity. + * Supports filtering by agent and activity type. * * Uses `content-visibility: auto` for performance optimization on long lists. * @@ -138,6 +102,7 @@ function formatRelativeTime(timestamp: string | null | undefined): string { * fetchMore()} * /> * ``` @@ -149,15 +114,61 @@ export function LiveFeed({ onLoadMore, maxItems, getTaskHref, + agents, className, }: LiveFeedProps) { - // Limit activities if maxItems is set - const displayedActivities = useMemo(() => { - if (maxItems && activities.length > maxItems) { - return activities.slice(0, maxItems) + const [selectedAgentId, setSelectedAgentId] = useState(null) + const [selectedType, setSelectedType] = useState('all') + + function isSystemActivity(activity: ActivityData): boolean { + return !activity.agent_id || activity.type === 'agent_status_changed' + } + + // Compute counts per agent from full activities array + const agentCounts = useMemo(() => { + if (!agents) return undefined + const counts: Record = {} + for (const activity of activities) { + if (activity.agent_id) { + counts[activity.agent_id] = (counts[activity.agent_id] || 0) + 1 + } + } + return agents.map((agent) => ({ + ...agent, + activityCount: counts[agent.id] || 0, + })) + }, [agents, activities]) + + // Compute counts per feed type from full activities array + const typeCounts = useMemo(() => { + const counts: Partial> = { all: activities.length } + for (const activity of activities) { + const feedType = getFeedTypeForActivity(activity.type) + counts[feedType] = (counts[feedType] || 0) + 1 + } + return counts + }, [activities]) + + // Filter and limit activities + const filteredActivities = useMemo(() => { + let filtered = activities + + if (selectedAgentId) { + filtered = filtered.filter((a) => a.agent_id === selectedAgentId) + } + + if (selectedType !== 'all') { + filtered = filtered.filter((a) => + feedTypeMap[selectedType]?.includes(a.type) + ) } - return activities - }, [activities, maxItems]) + + if (maxItems) { + filtered = filtered.slice(0, maxItems) + } + + return filtered + }, [activities, selectedAgentId, selectedType, maxItems]) if (isLoading && activities.length === 0) { return ( @@ -193,21 +204,61 @@ export function LiveFeed({ > + {/* Agent filter chips */} + {agentCounts && agentCounts.length > 0 && ( + + )} + + {/* Feed type tabs */} + + {/* Activity list with content-visibility for performance */}
      - {displayedActivities.map((activity, index) => ( - - ))} + {filteredActivities.map((activity, index) => { + const isSystem = isSystemActivity(activity) + const borderClass = index < filteredActivities.length - 1 ? 'border-b border-border' : undefined + if (isSystem) { + return ( +
    • + +
    • + ) + } + return ( + + ) + })}
    + {/* Empty filtered state */} + {filteredActivities.length === 0 && ( +
    + + No activities match the current filters + +
    + )} + {/* Load more indicator */} {hasMore && ( - - - {/* Accessible title for Radix — always rendered */} + {/* Accessible title for Radix -- always rendered */} {agent ? `${agent.name} profile` : 'Loading agent profile'} + {/* Panel top bar */} +
    +
    + + + AGENT PROFILE + +
    + + + +
    + {/* Header */}
    {agent ? ( <> @@ -368,7 +422,12 @@ export function AgentProfilePanel({ {agent.name}
    - + {agent.role}
    + {/* Status detail box */} + {agent && (agent.statusReason || agent.statusSince) && ( + + )} + {/* Description */} {agent.description && ( + + Attention + {attentionCount != null && attentionCount > 0 && ( + + {attentionCount} + + )} + Timeline Messages + + {attentionContent ?? ( + + No items need attention + + )} + + )} + + {onSendMessage && ( +
    + + SEND MESSAGE TO {agent.name.toUpperCase()} + + +
    + )}
    ) : ( diff --git a/apps/web/src/components/templates/DashboardLayout/DashboardLayout.tsx b/apps/web/src/components/templates/DashboardLayout/DashboardLayout.tsx index e6dd30a..d4c89a8 100644 --- a/apps/web/src/components/templates/DashboardLayout/DashboardLayout.tsx +++ b/apps/web/src/components/templates/DashboardLayout/DashboardLayout.tsx @@ -18,6 +18,8 @@ export interface DashboardLayoutProps { header?: React.ReactNode /** Name of the currently focused agent (displays "IN FOCUS: [Agent]" in header) */ focusedAgent?: string | null + /** Override content for the right panel (replaces feed when set) */ + rightPanelOverride?: React.ReactNode /** Additional CSS classes to apply to the root element */ className?: string } @@ -59,6 +61,7 @@ export function DashboardLayout({ feed, header, focusedAgent, + rightPanelOverride, className, }: DashboardLayoutProps) { const [sidebarCollapsed, setSidebarCollapsed] = useState(false) @@ -192,7 +195,7 @@ export function DashboardLayout({ {/* Right feed column - hidden on mobile */} - {feed && ( + {(feed || rightPanelOverride) && ( )} diff --git a/apps/web/src/components/templates/TaskDetailPanel/TaskDetailInlinePanel.tsx b/apps/web/src/components/templates/TaskDetailPanel/TaskDetailInlinePanel.tsx new file mode 100644 index 0000000..d63d95d --- /dev/null +++ b/apps/web/src/components/templates/TaskDetailPanel/TaskDetailInlinePanel.tsx @@ -0,0 +1,467 @@ +'use client' + +import * as React from 'react' +import * as TabsPrimitive from '@radix-ui/react-tabs' + +import { cn } from '@/lib/utils' +import { Badge, Button, Icon, Skeleton, StatusDot, Text } from '@/components/atoms' +import type { TaskData, TaskStatus, TaskPriority } from '@/components/organisms' + +/** + * Maps task priority to badge variant + */ +function getPriorityBadgeVariant( + priority: TaskPriority +): 'priority-urgent' | 'priority-high' | 'priority-normal' | 'priority-low' { + const variants = { + urgent: 'priority-urgent', + high: 'priority-high', + normal: 'priority-normal', + low: 'priority-low', + } as const + return variants[priority] +} + +/** + * Maps task status to badge variant + */ +function getStatusBadgeVariant( + status: TaskStatus +): 'status-active' | 'status-idle' | 'status-blocked' | 'default' { + const variants: Record = { + inbox: 'default', + assigned: 'status-idle', + in_progress: 'status-active', + review: 'status-active', + done: 'status-idle', + blocked: 'status-blocked', + } + return variants[status] +} + +/** + * Human-readable status labels + */ +function getStatusLabel(status: TaskStatus): string { + const labels: Record = { + inbox: 'Inbox', + assigned: 'Assigned', + in_progress: 'In Progress', + review: 'Review', + done: 'Done', + blocked: 'Blocked', + } + return labels[status] +} + +/** + * Human-readable priority labels + */ +function getPriorityLabel(priority: TaskPriority): string { + const labels: Record = { + urgent: 'Urgent', + high: 'High', + normal: 'Normal', + low: 'Low', + } + return labels[priority] +} + +/** + * Loading skeleton for the inline panel content + */ +function InlinePanelSkeleton() { + return ( +
    + {/* Title skeleton */} + + + {/* Badges skeleton */} +
    + + +
    + + {/* Description skeleton */} +
    + + + +
    + + {/* Separator */} +
    + + {/* Content area skeleton */} +
    + + +
    +
    + ) +} + +/** + * Props for the TaskDetailInlinePanel component. + */ +export interface TaskDetailInlinePanelProps { + /** The task to display details for (null shows loading skeleton) */ + task: TaskData | null + /** Callback when the close button is clicked */ + onClose: () => void + /** Callback when the edit button is clicked */ + onEdit?: (taskId: string) => void + /** Callback when the delete button is clicked */ + onTaskDelete?: (taskId: string) => Promise | void + /** Children to render in the details tab body (for comments, documents, etc.) */ + children?: React.ReactNode + /** Content to render in the deliverables tab. When provided, replaces the default empty state. */ + deliverablesContent?: React.ReactNode + /** Additional CSS classes */ + className?: string +} + +/** + * TaskDetailInlinePanel is a non-modal inline panel that renders task details + * within a container (typically the right sidebar of the dashboard layout). + * + * Unlike TaskDetailPanel (which uses a Radix Dialog overlay), this component + * is a regular `
    + + {/* Scrollable body */} +
    {task ? ( - <> - + {/* Task title */} +

    {task.title} - +

    + {/* Badges */}
    - - ) : ( - <> - -
    - - -
    - - )} -
    - {/* Body - scrollable content area */} -
    - {task ? ( -
    {/* Description */} {task.description && ( )} - {/* Visual separator if description exists */} - {task.description && children && ( -
    + {/* Assignees */} + {task.assignees && task.assignees.length > 0 && ( +
    + + Assignees + +
    + {task.assignees.map((assignee) => ( + + {assignee.name} + + ))} +
    +
    )} - {/* Children slot for comments, documents, etc. */} - {children} + {/* Separator + children (deliverables content, etc.) */} + {children && ( + <> +
    + {children} + + )}
    ) : ( )}
    - - {/* Footer with action buttons */} -
    -
    - {onTaskDelete && task && ( - - )} -
    - -
    - {onTaskUpdate && task && ( - - )} - -
    -
    diff --git a/apps/web/src/components/templates/TaskDetailPanel/index.ts b/apps/web/src/components/templates/TaskDetailPanel/index.ts index 98668d8..cffd246 100644 --- a/apps/web/src/components/templates/TaskDetailPanel/index.ts +++ b/apps/web/src/components/templates/TaskDetailPanel/index.ts @@ -1,2 +1,4 @@ export { TaskDetailPanel } from './TaskDetailPanel' export type { TaskDetailPanelProps } from './TaskDetailPanel' +export { TaskDetailInlinePanel } from './TaskDetailInlinePanel' +export type { TaskDetailInlinePanelProps } from './TaskDetailInlinePanel' diff --git a/apps/web/src/components/templates/__tests__/AgentProfilePanel.test.tsx b/apps/web/src/components/templates/__tests__/AgentProfilePanel.test.tsx new file mode 100644 index 0000000..28d09ec --- /dev/null +++ b/apps/web/src/components/templates/__tests__/AgentProfilePanel.test.tsx @@ -0,0 +1,849 @@ +import { render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useState } from 'react' + +import { AgentProfilePanel } from '@/components/templates' +import type { AgentProfileData } from '@/components/templates' +import { Button } from '@/components/atoms' + +const mockAgent: AgentProfileData = { + id: 'agent-1', + name: 'Writer Agent', + role: 'Content Lead', + status: 'active', + avatarColor: 'blue', + currentTask: 'Writing blog post', + blockedReason: null, + description: 'A creative writing agent specializing in long-form content.', + personality: 'Detail-oriented and creative', + expertise: ['SEO', 'Copywriting', 'Research'], + collaborates_with: ['Editor Agent', 'Social Agent'], + statusReason: 'Working on blog draft', + statusSince: '2026-02-05T08:00:00Z', +} + +/** + * Helper wrapper that provides a trigger button for opening the panel. + */ +function TestWrapper({ + agent = mockAgent, + ...props +}: Partial> & { + agent?: AgentProfileData | null +}) { + const [open, setOpen] = useState(false) + + return ( + <> + + + + ) +} + +describe('AgentProfilePanel', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Panel header (top bar)', () => { + it('shows "AGENT PROFILE" label with StatusDot in top bar', () => { + render( + + ) + + const topBar = screen.getByTestId('agent-profile-top-bar') + expect(topBar).toBeInTheDocument() + expect(topBar).toHaveTextContent('AGENT PROFILE') + + // StatusDot should be present in the top bar + const statusDot = within(topBar).getByRole('status') + expect(statusDot).toBeInTheDocument() + }) + + it('has close button in top bar', () => { + render( + + ) + + const closeButton = screen.getByTestId('agent-profile-close-button') + expect(closeButton).toBeInTheDocument() + expect(closeButton).toHaveAttribute('aria-label', 'Close panel') + }) + + it('closes panel when close button is clicked', async () => { + const user = userEvent.setup() + + render() + + // Open + await user.click(screen.getByTestId('trigger-button')) + await waitFor(() => { + expect(screen.getByTestId('agent-profile-panel')).toBeInTheDocument() + }) + + // Close + await user.click(screen.getByTestId('agent-profile-close-button')) + await waitFor(() => { + expect(screen.queryByTestId('agent-profile-panel')).not.toBeInTheDocument() + }) + }) + }) + + describe('Agent info display', () => { + it('displays agent name', () => { + render( + + ) + + expect(screen.getByTestId('agent-profile-name')).toHaveTextContent('Writer Agent') + }) + + it('displays agent role badge', () => { + render( + + ) + + const roleBadge = screen.getByTestId('agent-profile-role-badge') + expect(roleBadge).toHaveTextContent('Content Lead') + }) + + it('displays agent status badge', () => { + render( + + ) + + const workingElements = screen.getAllByText('Working') + expect(workingElements.length).toBeGreaterThanOrEqual(1) + }) + + it('displays agent description', () => { + render( + + ) + + expect(screen.getByTestId('agent-profile-description')).toHaveTextContent( + 'A creative writing agent specializing in long-form content.' + ) + }) + }) + + describe('ABOUT section', () => { + it('renders ABOUT heading with correct styling', () => { + render( + + ) + + const bioSection = screen.getByTestId('agent-bio-section') + expect(bioSection).toBeInTheDocument() + expect(bioSection).toHaveTextContent('ABOUT') + }) + + it('renders personality quote', () => { + render( + + ) + + expect(screen.getByTestId('agent-bio-personality')).toHaveTextContent( + 'Detail-oriented and creative' + ) + }) + + it('renders expertise tags', () => { + render( + + ) + + const expertiseList = screen.getByTestId('agent-bio-expertise-list') + expect(expertiseList).toBeInTheDocument() + expect(within(expertiseList).getByText('SEO')).toBeInTheDocument() + expect(within(expertiseList).getByText('Copywriting')).toBeInTheDocument() + expect(within(expertiseList).getByText('Research')).toBeInTheDocument() + }) + + it('renders collaborators list', () => { + render( + + ) + + const collabList = screen.getByTestId('agent-bio-collaborates-list') + expect(within(collabList).getByText('Editor Agent')).toBeInTheDocument() + expect(within(collabList).getByText('Social Agent')).toBeInTheDocument() + }) + + it('does not render bio section when all fields are empty', () => { + const agentNoProfile: AgentProfileData = { + ...mockAgent, + personality: null, + expertise: null, + collaborates_with: null, + } + + render( + + ) + + expect(screen.queryByTestId('agent-bio-section')).not.toBeInTheDocument() + }) + }) + + describe('Attention tab', () => { + it('renders Attention tab trigger', () => { + render( + + ) + + expect(screen.getByTestId('agent-profile-tab-attention')).toBeInTheDocument() + expect(screen.getByTestId('agent-profile-tab-attention')).toHaveTextContent('Attention') + }) + + it('shows count badge when attentionCount > 0', () => { + render( + + ) + + const badge = screen.getByTestId('attention-count-badge') + expect(badge).toBeInTheDocument() + expect(badge).toHaveTextContent('5') + }) + + it('does not show count badge when attentionCount is 0', () => { + render( + + ) + + expect(screen.queryByTestId('attention-count-badge')).not.toBeInTheDocument() + }) + + it('does not show count badge when attentionCount is undefined', () => { + render( + + ) + + expect(screen.queryByTestId('attention-count-badge')).not.toBeInTheDocument() + }) + + it('defaults to attention tab when attentionCount > 0', () => { + render( + Items here
    } + /> + ) + + // Attention tab should be active by default + const attentionTab = screen.getByTestId('agent-profile-tab-attention') + expect(attentionTab).toHaveAttribute('data-state', 'active') + + // Attention content should be visible + expect(screen.getByTestId('custom-attention-content')).toBeInTheDocument() + }) + + it('defaults to timeline tab when attentionCount is 0', () => { + render( + + ) + + const timelineTab = screen.getByTestId('agent-profile-tab-timeline') + expect(timelineTab).toHaveAttribute('data-state', 'active') + }) + + it('renders attention content when provided', () => { + render( + Custom content
    } + /> + ) + + expect(screen.getByTestId('custom-attention')).toBeInTheDocument() + }) + + it('shows fallback when no attention content is provided', async () => { + const user = userEvent.setup() + + render( + + ) + + // Click the attention tab + await user.click(screen.getByTestId('agent-profile-tab-attention')) + + expect(screen.getByText('No items need attention')).toBeInTheDocument() + }) + }) + + describe('Timeline and Messages tabs', () => { + it('renders Timeline tab', () => { + render( + + ) + + expect(screen.getByTestId('agent-profile-tab-timeline')).toBeInTheDocument() + }) + + it('renders Messages tab', () => { + render( + + ) + + expect(screen.getByTestId('agent-profile-tab-messages')).toBeInTheDocument() + }) + + it('displays timeline content', async () => { + const user = userEvent.setup() + + render( + Timeline items} + /> + ) + + // Click the timeline tab + await user.click(screen.getByTestId('agent-profile-tab-timeline')) + expect(screen.getByTestId('custom-timeline')).toBeInTheDocument() + }) + + it('displays messages content', async () => { + const user = userEvent.setup() + + render( + Message items} + /> + ) + + // Click the messages tab + await user.click(screen.getByTestId('agent-profile-tab-messages')) + expect(screen.getByTestId('custom-messages')).toBeInTheDocument() + }) + }) + + describe('DirectMessageInput in Messages tab', () => { + it('renders DirectMessageInput when onSendMessage is provided', async () => { + const user = userEvent.setup() + + render( + + ) + + // Navigate to messages tab + await user.click(screen.getByTestId('agent-profile-tab-messages')) + + const dmSection = screen.getByTestId('agent-profile-dm-section') + expect(dmSection).toBeInTheDocument() + expect(dmSection).toHaveTextContent('SEND MESSAGE TO WRITER AGENT') + expect(screen.getByTestId('direct-message-input')).toBeInTheDocument() + }) + + it('does not render DirectMessageInput when onSendMessage is not provided', async () => { + const user = userEvent.setup() + + render( + + ) + + // Navigate to messages tab + await user.click(screen.getByTestId('agent-profile-tab-messages')) + + expect(screen.queryByTestId('agent-profile-dm-section')).not.toBeInTheDocument() + }) + }) + + describe('Role badge colors', () => { + it('applies Lead role badge styling', () => { + render( + + ) + + const roleBadge = screen.getByTestId('agent-profile-role-badge') + expect(roleBadge.className).toContain('bg-accent') + expect(roleBadge.className).toContain('text-white') + }) + + it('applies Integration role badge styling', () => { + const integrationAgent: AgentProfileData = { + ...mockAgent, + role: 'Integration Engineer', + } + + render( + + ) + + const roleBadge = screen.getByTestId('agent-profile-role-badge') + expect(roleBadge.className).toContain('bg-priority-normal') + expect(roleBadge.className).toContain('text-white') + }) + + it('applies Specialist role badge styling', () => { + const specialistAgent: AgentProfileData = { + ...mockAgent, + role: 'SEO Specialist', + } + + render( + + ) + + const roleBadge = screen.getByTestId('agent-profile-role-badge') + expect(roleBadge.className).toContain('bg-status-active') + expect(roleBadge.className).toContain('text-white') + }) + + it('uses default styling for unrecognized roles', () => { + const genericAgent: AgentProfileData = { + ...mockAgent, + role: 'General Helper', + } + + render( + + ) + + const roleBadge = screen.getByTestId('agent-profile-role-badge') + // Should not have any of the special role classes + expect(roleBadge.className).not.toContain('bg-accent') + expect(roleBadge.className).not.toContain('bg-priority-normal') + expect(roleBadge.className).not.toContain('bg-status-active') + }) + }) + + describe('Loading skeleton', () => { + it('shows skeleton when agent is null', () => { + render( + + ) + + // Panel should still render with loading state + expect(screen.getByTestId('agent-profile-panel')).toBeInTheDocument() + expect(screen.getByText('Loading agent profile')).toBeInTheDocument() + }) + + it('does not render tabs when loading', () => { + render( + + ) + + expect(screen.queryByTestId('agent-profile-tabs')).not.toBeInTheDocument() + }) + + it('still shows top bar when loading', () => { + render( + + ) + + expect(screen.getByTestId('agent-profile-top-bar')).toBeInTheDocument() + expect(screen.getByTestId('agent-profile-top-bar')).toHaveTextContent('AGENT PROFILE') + }) + }) + + describe('Panel open/close', () => { + it('opens panel on trigger click', async () => { + const user = userEvent.setup() + + render() + + expect(screen.queryByTestId('agent-profile-panel')).not.toBeInTheDocument() + + await user.click(screen.getByTestId('trigger-button')) + + await waitFor(() => { + expect(screen.getByTestId('agent-profile-panel')).toBeInTheDocument() + }) + }) + + it('closes panel on overlay click', async () => { + const user = userEvent.setup() + + render() + + await user.click(screen.getByTestId('trigger-button')) + + await waitFor(() => { + expect(screen.getByTestId('agent-profile-panel')).toBeInTheDocument() + }) + + await user.click(screen.getByTestId('agent-profile-overlay')) + + await waitFor(() => { + expect(screen.queryByTestId('agent-profile-panel')).not.toBeInTheDocument() + }) + }) + + it('closes panel on Escape key', async () => { + const user = userEvent.setup() + + render() + + await user.click(screen.getByTestId('trigger-button')) + + await waitFor(() => { + expect(screen.getByTestId('agent-profile-panel')).toBeInTheDocument() + }) + + await user.keyboard('{Escape}') + + await waitFor(() => { + expect(screen.queryByTestId('agent-profile-panel')).not.toBeInTheDocument() + }) + }) + + it('calls onOpenChange with false on close', async () => { + const onOpenChange = vi.fn() + const user = userEvent.setup() + + render( + + ) + + await user.keyboard('{Escape}') + + expect(onOpenChange).toHaveBeenCalledWith(false) + }) + }) + + describe('Accessibility', () => { + it('panel has aria-labelledby pointing to title', () => { + render( + + ) + + const panel = screen.getByTestId('agent-profile-panel') + expect(panel).toHaveAttribute('aria-labelledby', 'agent-profile-title') + }) + + it('panel has aria-describedby when agent has description', () => { + render( + + ) + + const panel = screen.getByTestId('agent-profile-panel') + expect(panel).toHaveAttribute('aria-describedby', 'agent-profile-description') + }) + + it('panel does not have aria-describedby when agent has no description', () => { + const agentNoDesc: AgentProfileData = { + ...mockAgent, + description: null, + } + + render( + + ) + + const panel = screen.getByTestId('agent-profile-panel') + expect(panel).not.toHaveAttribute('aria-describedby') + }) + + it('close button has aria-label', () => { + render( + + ) + + const closeButton = screen.getByTestId('agent-profile-close-button') + expect(closeButton).toHaveAttribute('aria-label', 'Close panel') + }) + + it('DialogTitle is always rendered with sr-only', () => { + render( + + ) + + const title = screen.getByText('Writer Agent profile') + expect(title).toBeInTheDocument() + expect(title.className).toContain('sr-only') + }) + }) +}) + +describe('AgentStatusBox', () => { + // Import dynamically since it's a molecule + it('renders with status badge', async () => { + const { AgentStatusBox } = await import('@/components/molecules/AgentStatusBox') + + render( + + ) + + const box = screen.getByTestId('agent-status-box') + expect(box).toBeInTheDocument() + expect(screen.getByText('Working')).toBeInTheDocument() + }) + + it('renders status reason when provided', async () => { + const { AgentStatusBox } = await import('@/components/molecules/AgentStatusBox') + + render( + + ) + + expect(screen.getByText('Status Reason:')).toBeInTheDocument() + expect(screen.getByText('Waiting for code review')).toBeInTheDocument() + }) + + it('renders since timestamp when provided', async () => { + const { AgentStatusBox } = await import('@/components/molecules/AgentStatusBox') + + // Use a recent date so formatRelativeTime returns something predictable + const now = new Date() + const fiveMinAgo = new Date(now.getTime() - 5 * 60 * 1000).toISOString() + + render( + + ) + + expect(screen.getByText(/Since/)).toBeInTheDocument() + }) + + it('does not render reason when not provided', async () => { + const { AgentStatusBox } = await import('@/components/molecules/AgentStatusBox') + + render( + + ) + + expect(screen.queryByText('Status Reason:')).not.toBeInTheDocument() + }) + + it('applies correct border color for each status', async () => { + const { AgentStatusBox } = await import('@/components/molecules/AgentStatusBox') + + const { rerender } = render( + + ) + + let box = screen.getByTestId('agent-status-box') + expect(box.className).toContain('border-status-active') + + rerender() + box = screen.getByTestId('agent-status-box') + expect(box.className).toContain('border-status-blocked') + + rerender() + box = screen.getByTestId('agent-status-box') + expect(box.className).toContain('border-status-offline') + + rerender() + box = screen.getByTestId('agent-status-box') + expect(box.className).toContain('border-status-idle') + }) +}) + +describe('AttentionList', () => { + it('renders items', async () => { + const { AttentionList } = await import('@/components/molecules/AttentionList') + + const items = [ + { id: '1', type: 'mention' as const, title: '@Writer mentioned you' }, + { id: '2', type: 'waiting_task' as const, title: 'Review draft', description: 'Blog post needs review' }, + ] + + render() + + const list = screen.getByTestId('attention-list') + expect(list).toBeInTheDocument() + + const listItems = screen.getAllByTestId('attention-list-item') + expect(listItems).toHaveLength(2) + + expect(screen.getByText('@Writer mentioned you')).toBeInTheDocument() + expect(screen.getByText('Review draft')).toBeInTheDocument() + expect(screen.getByText('Blog post needs review')).toBeInTheDocument() + }) + + it('renders empty state when no items', async () => { + const { AttentionList } = await import('@/components/molecules/AttentionList') + + render() + + expect(screen.getByTestId('attention-list-empty')).toBeInTheDocument() + expect(screen.getByText('No items need attention')).toBeInTheDocument() + }) + + it('renders timestamp when provided', async () => { + const { AttentionList } = await import('@/components/molecules/AttentionList') + + const now = new Date() + const twoMinAgo = new Date(now.getTime() - 2 * 60 * 1000).toISOString() + + const items = [ + { id: '1', type: 'mention' as const, title: 'Test mention', timestamp: twoMinAgo }, + ] + + render() + + // formatRelativeTime should produce "2m ago" + expect(screen.getByText('2m ago')).toBeInTheDocument() + }) +}) diff --git a/apps/web/src/components/templates/__tests__/TaskDetailInlinePanel.test.tsx b/apps/web/src/components/templates/__tests__/TaskDetailInlinePanel.test.tsx new file mode 100644 index 0000000..b903e63 --- /dev/null +++ b/apps/web/src/components/templates/__tests__/TaskDetailInlinePanel.test.tsx @@ -0,0 +1,416 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +import { TaskDetailInlinePanel } from '@/components/templates' +import type { TaskData } from '@/components/organisms' + +describe('TaskDetailInlinePanel', () => { + const mockTask: TaskData = { + id: 'task-1', + title: 'Test Task Title', + description: 'This is a test task description.', + status: 'in_progress', + priority: 'high', + position: 0, + assignees: [ + { id: 'agent-1', name: 'Writer Agent' }, + ], + created_at: '2026-02-01T10:00:00Z', + updated_at: '2026-02-05T14:00:00Z', + tags: ['content', 'sprint-1'], + } + + const mockOnClose = vi.fn() + const mockOnEdit = vi.fn() + const mockOnDelete = vi.fn().mockResolvedValue(undefined) + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('header rendering', () => { + it('renders "TASK DETAIL" header with StatusDot', () => { + render( + + ) + + expect(screen.getByText('TASK DETAIL')).toBeInTheDocument() + expect(screen.getByRole('status')).toBeInTheDocument() + }) + + it('renders close button with aria-label', () => { + render( + + ) + + const closeButton = screen.getByTestId('task-detail-inline-close') + expect(closeButton).toBeInTheDocument() + expect(closeButton).toHaveAttribute('aria-label', 'Close panel') + }) + + it('calls onClose when close button is clicked', async () => { + const user = userEvent.setup() + + render( + + ) + + await user.click(screen.getByTestId('task-detail-inline-close')) + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) + }) + + describe('task info rendering', () => { + it('shows task title', () => { + render( + + ) + + expect(screen.getByTestId('task-detail-inline-title')).toHaveTextContent('Test Task Title') + }) + + it('shows status badge', () => { + render( + + ) + + expect(screen.getByTestId('task-detail-inline-status')).toHaveTextContent('In Progress') + }) + + it('shows priority badge', () => { + render( + + ) + + expect(screen.getByTestId('task-detail-inline-priority')).toHaveTextContent('High') + }) + }) + + describe('tabs', () => { + it('renders Details and Deliverables tabs', () => { + render( + + ) + + expect(screen.getByTestId('task-detail-inline-tab-details')).toBeInTheDocument() + expect(screen.getByTestId('task-detail-inline-tab-deliverables')).toBeInTheDocument() + }) + + it('shows Details tab content by default', () => { + render( + + ) + + const detailsContent = screen.getByTestId('task-detail-inline-details-content') + expect(detailsContent).toBeInTheDocument() + }) + + it('shows task description in Details tab', () => { + render( + + ) + + expect(screen.getByTestId('task-detail-inline-description')).toHaveTextContent( + 'This is a test task description.' + ) + }) + + it('switches to Deliverables tab on click', async () => { + const user = userEvent.setup() + + render( + + ) + + await user.click(screen.getByTestId('task-detail-inline-tab-deliverables')) + + const deliverableContent = screen.getByTestId('task-detail-inline-deliverables-content') + expect(deliverableContent).toBeInTheDocument() + expect(deliverableContent).toHaveTextContent('No deliverables yet') + }) + }) + + describe('footer actions', () => { + it('renders edit button when onEdit is provided', () => { + render( + + ) + + expect(screen.getByTestId('task-detail-inline-edit')).toBeInTheDocument() + }) + + it('calls onEdit with task id when edit button is clicked', async () => { + const user = userEvent.setup() + + render( + + ) + + await user.click(screen.getByTestId('task-detail-inline-edit')) + expect(mockOnEdit).toHaveBeenCalledWith('task-1') + }) + + it('renders delete button when onTaskDelete is provided', () => { + render( + + ) + + expect(screen.getByTestId('task-detail-inline-delete')).toBeInTheDocument() + }) + + it('calls onTaskDelete when delete button is clicked', async () => { + const user = userEvent.setup() + + render( + + ) + + await user.click(screen.getByTestId('task-detail-inline-delete')) + + await waitFor(() => { + expect(mockOnDelete).toHaveBeenCalledWith('task-1') + }) + }) + + it('does not render edit button when onEdit is not provided', () => { + render( + + ) + + expect(screen.queryByTestId('task-detail-inline-edit')).not.toBeInTheDocument() + }) + + it('does not render delete button when onTaskDelete is not provided', () => { + render( + + ) + + expect(screen.queryByTestId('task-detail-inline-delete')).not.toBeInTheDocument() + }) + }) + + describe('loading state', () => { + it('shows loading skeleton when task is null', () => { + render( + + ) + + expect(screen.getByLabelText('Loading task details')).toBeInTheDocument() + }) + + it('does not show tabs when task is null', () => { + render( + + ) + + expect(screen.queryByTestId('task-detail-inline-tabs')).not.toBeInTheDocument() + }) + }) + + describe('keyboard accessibility', () => { + it('close button responds to Enter key', async () => { + const user = userEvent.setup() + + render( + + ) + + const closeButton = screen.getByTestId('task-detail-inline-close') + closeButton.focus() + await user.keyboard('{Enter}') + + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) + + it('close button responds to Space key', async () => { + const user = userEvent.setup() + + render( + + ) + + const closeButton = screen.getByTestId('task-detail-inline-close') + closeButton.focus() + await user.keyboard(' ') + + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) + + it('edit button responds to Enter key', async () => { + const user = userEvent.setup() + + render( + + ) + + const editButton = screen.getByTestId('task-detail-inline-edit') + editButton.focus() + await user.keyboard('{Enter}') + + expect(mockOnEdit).toHaveBeenCalledWith('task-1') + }) + + it('delete button responds to Space key', async () => { + const user = userEvent.setup() + + render( + + ) + + const deleteButton = screen.getByTestId('task-detail-inline-delete') + deleteButton.focus() + await user.keyboard(' ') + + await waitFor(() => { + expect(mockOnDelete).toHaveBeenCalledWith('task-1') + }) + }) + }) + + describe('accessibility attributes', () => { + it('has role complementary on the aside element', () => { + render( + + ) + + const panel = screen.getByTestId('task-detail-inline-panel') + expect(panel).toHaveAttribute('role', 'complementary') + }) + + it('has descriptive aria-label with task title', () => { + render( + + ) + + const panel = screen.getByTestId('task-detail-inline-panel') + expect(panel).toHaveAttribute('aria-label', 'Task detail: Test Task Title') + }) + + it('has loading aria-label when task is null', () => { + render( + + ) + + const panel = screen.getByTestId('task-detail-inline-panel') + expect(panel).toHaveAttribute('aria-label', 'Task detail loading') + }) + }) + + describe('children slot', () => { + it('renders children in the details tab', () => { + render( + +
    Custom content
    +
    + ) + + expect(screen.getByTestId('custom-child')).toBeInTheDocument() + }) + }) + + describe('task with minimal data', () => { + it('renders without description', () => { + const minimalTask: TaskData = { + id: 'task-2', + title: 'Minimal Task', + status: 'inbox', + priority: 'low', + position: 1, + } + + render( + + ) + + expect(screen.getByTestId('task-detail-inline-title')).toHaveTextContent('Minimal Task') + expect(screen.queryByTestId('task-detail-inline-description')).not.toBeInTheDocument() + }) + }) +}) diff --git a/apps/web/src/components/templates/index.ts b/apps/web/src/components/templates/index.ts index 55e2e3f..877b50e 100644 --- a/apps/web/src/components/templates/index.ts +++ b/apps/web/src/components/templates/index.ts @@ -1,6 +1,6 @@ export * from './DashboardLayout' -export { TaskDetailPanel } from './TaskDetailPanel' -export type { TaskDetailPanelProps } from './TaskDetailPanel' +export { TaskDetailPanel, TaskDetailInlinePanel } from './TaskDetailPanel' +export type { TaskDetailPanelProps, TaskDetailInlinePanelProps } from './TaskDetailPanel' export { AgentProfilePanel } from './AgentProfilePanel' export type { AgentProfilePanelProps, AgentProfileData, AgentStatus } from './AgentProfilePanel' export { ActivityDetailPanel } from './ActivityDetailPanel' diff --git a/apps/web/src/hooks/useAgentAttention.ts b/apps/web/src/hooks/useAgentAttention.ts new file mode 100644 index 0000000..ef20e27 --- /dev/null +++ b/apps/web/src/hooks/useAgentAttention.ts @@ -0,0 +1,119 @@ +'use client' + +import { useState, useEffect } from 'react' +import { createClient } from '@/lib/supabase/browser' +import type { AttentionItem } from '@/components/molecules/AttentionList' + +export interface UseAgentAttentionResult { + /** Combined list of attention items (mentions + waiting tasks) */ + items: AttentionItem[] + /** Total count of attention items */ + totalCount: number + /** Whether the initial fetch is in progress */ + isLoading: boolean +} + +/** + * Hook for fetching items that require an agent's attention. + * + * Combines unread notifications and tasks with 'inbox' status assigned + * to the specified agent, sorted by timestamp (most recent first). + * + * @param agentId - The agent ID to fetch attention items for, or null to skip fetching + * @returns Object containing items, totalCount, and isLoading state + * + * @example + * ```tsx + * const { items, totalCount, isLoading } = useAgentAttention(agent.id) + * ``` + */ +export function useAgentAttention(agentId: string | null): UseAgentAttentionResult { + const [items, setItems] = useState([]) + const [isLoading, setIsLoading] = useState(false) + + useEffect(() => { + if (!agentId) { + setItems([]) + return + } + + const fetchAttentionItems = async () => { + setIsLoading(true) + const supabase = createClient() + + try { + const attentionItems: AttentionItem[] = [] + + // Fetch unread notifications for this agent + const { data: notifications, error: notifError } = await supabase + .from('notifications') + .select('id, content, created_at') + .eq('mentioned_agent_id', agentId) + .eq('delivered', false) + .order('created_at', { ascending: false }) + .limit(20) + + if (!notifError && notifications) { + for (const notif of notifications) { + attentionItems.push({ + id: `notif-${notif.id}`, + type: 'mention', + title: notif.content ?? 'New mention', + timestamp: notif.created_at, + }) + } + } + + // Fetch tasks assigned to this agent that are in inbox/waiting status + const { data: taskAssignees, error: taskError } = await supabase + .from('task_assignees') + .select('task_id, tasks(id, title, description, created_at, status)') + .eq('agent_id', agentId) + .limit(20) + + if (!taskError && taskAssignees) { + for (const assignee of taskAssignees) { + const task = assignee.tasks as unknown as { + id: string + title: string + description: string | null + created_at: string + status: string + } | null + + if (task && task.status === 'inbox') { + attentionItems.push({ + id: `task-${task.id}`, + type: 'waiting_task', + title: task.title, + description: task.description ?? undefined, + timestamp: task.created_at, + }) + } + } + } + + // Sort by timestamp, most recent first + attentionItems.sort((a, b) => { + if (!a.timestamp || !b.timestamp) return 0 + return new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() + }) + + setItems(attentionItems) + } catch { + // Error handling: keep existing items, log for debugging + console.error('Failed to fetch attention items') + } finally { + setIsLoading(false) + } + } + + fetchAttentionItems() + }, [agentId]) + + return { + items, + totalCount: items.length, + isLoading, + } +} diff --git a/apps/web/src/lib/formatRelativeTime.test.ts b/apps/web/src/lib/formatRelativeTime.test.ts new file mode 100644 index 0000000..70881fb --- /dev/null +++ b/apps/web/src/lib/formatRelativeTime.test.ts @@ -0,0 +1,54 @@ +import { formatRelativeTime } from './formatRelativeTime' + +describe('formatRelativeTime', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-02-05T12:00:00Z')) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('returns empty string for null input', () => { + expect(formatRelativeTime(null)).toBe('') + }) + + it('returns empty string for undefined input', () => { + expect(formatRelativeTime(undefined)).toBe('') + }) + + it('returns "just now" for times less than 60 seconds ago', () => { + const date = new Date('2026-02-05T11:59:30Z') + expect(formatRelativeTime(date)).toBe('just now') + }) + + it('returns minutes ago for times less than 60 minutes ago', () => { + const date = new Date('2026-02-05T11:55:00Z') + expect(formatRelativeTime(date)).toBe('5m ago') + }) + + it('returns hours ago for times less than 24 hours ago', () => { + const date = new Date('2026-02-05T09:00:00Z') + expect(formatRelativeTime(date)).toBe('3h ago') + }) + + it('returns days ago for times less than 7 days ago', () => { + const date = new Date('2026-02-03T12:00:00Z') + expect(formatRelativeTime(date)).toBe('2d ago') + }) + + it('returns formatted date for times 7 or more days ago', () => { + const date = new Date('2026-01-20T12:00:00Z') + expect(formatRelativeTime(date)).toBe('Jan 20') + }) + + it('accepts string input', () => { + expect(formatRelativeTime('2026-02-05T11:55:00Z')).toBe('5m ago') + }) + + it('accepts Date object input', () => { + const date = new Date('2026-02-05T11:55:00Z') + expect(formatRelativeTime(date)).toBe('5m ago') + }) +}) diff --git a/apps/web/src/lib/formatRelativeTime.ts b/apps/web/src/lib/formatRelativeTime.ts new file mode 100644 index 0000000..905973c --- /dev/null +++ b/apps/web/src/lib/formatRelativeTime.ts @@ -0,0 +1,36 @@ +/** + * Formats a date string or Date object as a relative time string. + * + * Returns human-readable relative timestamps like "just now", "5 min ago", + * "2 hours ago", "1 day ago", or a formatted date for older entries. + * + * @param dateInput - A date string, Date object, or null/undefined + * @returns A relative time string, or empty string if input is null/undefined + * + * @example + * ```ts + * formatRelativeTime(new Date()) // "just now" + * formatRelativeTime('2026-02-05T10:00:00Z') // "5 min ago" (if 5 min have passed) + * formatRelativeTime(null) // "" + * ``` + */ +export function formatRelativeTime( + dateInput: string | Date | null | undefined +): string { + if (!dateInput) return '' + + const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffSec = Math.floor(diffMs / 1000) + const diffMin = Math.floor(diffSec / 60) + const diffHour = Math.floor(diffMin / 60) + const diffDay = Math.floor(diffHour / 24) + + if (diffSec < 60) return 'just now' + if (diffMin < 60) return `${diffMin}m ago` + if (diffHour < 24) return `${diffHour}h ago` + if (diffDay < 7) return `${diffDay}d ago` + + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) +} diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts index 67e9438..5cc0e98 100644 --- a/apps/web/src/middleware.ts +++ b/apps/web/src/middleware.ts @@ -74,7 +74,7 @@ export async function middleware(request: NextRequest) { // Redirect authenticated users away from auth pages to dashboard if (user && isAuthPage) { const url = request.nextUrl.clone() - url.pathname = '/dashboard' + url.pathname = '/tasks' return NextResponse.redirect(url) } diff --git a/docs/solutions/runtime-errors/supabase-storagekey-mismatch-empty-rls-results.md b/docs/solutions/runtime-errors/supabase-storagekey-mismatch-empty-rls-results.md new file mode 100644 index 0000000..9619b0b --- /dev/null +++ b/docs/solutions/runtime-errors/supabase-storagekey-mismatch-empty-rls-results.md @@ -0,0 +1,169 @@ +--- +title: Supabase storageKey mismatch causes silent empty RLS results +date: 2026-02-05 +category: runtime-errors +tags: [supabase, auth, rls, next-js, client-side, storageKey] +module: dashboard +symptoms: + - "Client-side Supabase queries return empty results" + - "Deliverables not showing in task detail panel" + - "RLS returns empty array instead of error" + - "Server-side auth works but client-side queries fail silently" +root_cause: "Mismatched Supabase storageKey between browser clients" +severity: high +time_to_resolve: "45 minutes" +--- + +# Supabase storageKey Mismatch Causes Silent Empty RLS Results + +## Symptom + +Client-side Supabase queries (e.g., fetching deliverables from the `documents` table) return empty arrays despite data existing in the database. The behavior is deceptive because: + +- The user appears fully authenticated (dashboard loads, server components render correctly) +- HTTP responses return `200 OK`, not `401` or `403` +- RLS policies are correctly configured +- No errors appear in browser console or network tab +- Only client-side `'use client'` component queries are affected + +## Investigation + +1. **RLS policies on `documents` table** -- confirmed correct; authenticated users with matching `squad_id` should see results. + +2. **Network requests** -- all returned HTTP 200 with `data: []`. Supabase does not return auth errors for RLS-filtered queries; it returns empty results silently. + +3. **Browser JS console test** -- manually calling `supabase.auth.getSession()` from a client created via `@/lib/supabase/client` returned no session. Calling the same method on a client created via `@/lib/supabase/browser` returned a valid session. + +4. **localStorage inspection** -- no auth token stored under Supabase's default key. The token was stored under the custom key `sb-mission-control-auth-token`. + +5. **Import chain trace** -- `tasks-client.tsx` imported `createClient` from `@/lib/supabase/client` (no custom `storageKey`), not from `@/lib/supabase/browser` (which has the matching `storageKey`). + +## Root Cause + +The application had **two** Supabase browser client factories with different auth configurations: + +**`@/lib/supabase/client.ts`** -- NO custom storageKey (uses Supabase default): + +```typescript +import { createBrowserClient } from '@supabase/ssr' + +export function createClient() { + return createBrowserClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! + ) +} +``` + +**`@/lib/supabase/browser.ts`** -- custom storageKey matching middleware: + +```typescript +import { createBrowserClient } from '@supabase/ssr' + +export function createClient() { + return createBrowserClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + auth: { + storageKey: 'sb-mission-control-auth-token', + }, + } + ) +} +``` + +**`middleware.ts`** -- uses the SAME custom storageKey when creating the server client: + +```typescript +const supabase = createServerClient( + (process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL)!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + auth: { + storageKey: 'sb-mission-control-auth-token', + }, + cookies: { /* ... */ }, + } +) +``` + +The middleware stores the auth session under the custom key `sb-mission-control-auth-token`. Any browser client that does not specify this same `storageKey` cannot find the session cookie. It operates as unauthenticated, and RLS returns empty results instead of errors. + +### Why It Was Confusing + +| Observation | Why It Misled | +|---|---| +| Dashboard loaded correctly | Server components and middleware used the correct client | +| HTTP 200 responses | Supabase RLS returns empty arrays for unauthorized queries, not HTTP errors | +| User appeared logged in | Server-side auth (middleware) worked; only client-side was broken | +| No console errors | The Supabase client does not log warnings when it cannot find a session | + +## Solution + +Changed imports in three files from the incorrect client factory to the correct one: + +**Files modified:** + +- `apps/web/src/app/(dashboard)/tasks/tasks-client.tsx` +- `apps/web/src/components/organisms/AgentSidebarWithPanel/AgentSidebarWithPanel.tsx` +- `apps/web/src/hooks/useAgentAttention.ts` + +**Change in each file:** + +```diff +- import { createClient } from '@/lib/supabase/client' ++ import { createClient } from '@/lib/supabase/browser' +``` + +After the fix, all three files use the browser client with the matching `storageKey`, and client-side queries return the expected data. + +## Prevention + +### 1. Single canonical browser client + +There should be ONE browser client factory, not two. Either remove `@/lib/supabase/client.ts` entirely or re-export from `browser.ts`: + +```typescript +// @/lib/supabase/client.ts -- deprecated, re-exports browser client +export { createClient } from './browser' +``` + +### 2. Lint rule for import path + +Add an ESLint rule or code review check to flag imports of `@/lib/supabase/client`: + +```jsonc +// .eslintrc or eslint.config.js +{ + "rules": { + "no-restricted-imports": ["error", { + "paths": [{ + "name": "@/lib/supabase/client", + "message": "Use @/lib/supabase/browser instead. The client.ts factory is missing the custom storageKey required for auth." + }] + }] + } +} +``` + +### 3. Document custom storageKey prominently + +When using a custom `storageKey` in Supabase SSR auth, add a comment in the middleware and in every client factory explaining the requirement: + +```typescript +// IMPORTANT: This storageKey MUST match the value in middleware.ts +// and all other Supabase client factories. Mismatched keys cause +// silent auth failures where RLS returns empty results. +storageKey: 'sb-mission-control-auth-token', +``` + +### 4. Test client-side data fetching with RLS + +Server-side auth success does not guarantee client-side auth works. Integration tests should verify that `'use client'` components can fetch RLS-protected data, not just that server components can. + +## Key Insight + +Supabase RLS is designed to fail silently -- unauthorized queries return empty results (`data: []`), not HTTP 401/403 errors. This is a security feature (it prevents leaking table structure), but it makes auth misconfigurations extremely difficult to debug. When client-side queries return empty results, always verify the auth session is present on the client by calling `supabase.auth.getSession()` in the browser console before investigating RLS policies. + +The `storageKey` option in `@supabase/ssr` controls where the auth token is stored in cookies/localStorage. Every client factory (server, browser, middleware) must use the same `storageKey` value, or they will operate in isolated auth contexts. This is not validated at build time or runtime -- mismatches produce silent failures. diff --git a/packages/database/supabase/migrations/20260205000001_dashboard_ui_columns.sql b/packages/database/supabase/migrations/20260205000001_dashboard_ui_columns.sql new file mode 100644 index 0000000..606c844 --- /dev/null +++ b/packages/database/supabase/migrations/20260205000001_dashboard_ui_columns.sql @@ -0,0 +1,39 @@ +-- Migration: dashboard_ui_columns +-- Adds columns and types needed by the new dashboard UI components: +-- - squad_status enum + status column on squads +-- - tags array on tasks +-- - status_reason and status_since on agents + +BEGIN; + +-- 1. Create squad_status enum +CREATE TYPE squad_status AS ENUM ('active', 'paused', 'archived'); + +-- 2. Add status column to squads table +ALTER TABLE squads + ADD COLUMN status squad_status NOT NULL DEFAULT 'active'; + +-- 3. Add tags array to tasks table +ALTER TABLE tasks + ADD COLUMN tags text[] DEFAULT '{}'; + +-- 4. Add status_reason to agents (general-purpose reason, supplements blocked_reason) +ALTER TABLE agents + ADD COLUMN status_reason text; + +-- 5. Add status_since to agents (tracks when current status was set) +ALTER TABLE agents + ADD COLUMN status_since timestamptz; + +-- 6. Index on squads(status) for filtered queries +CREATE INDEX idx_squads_status ON squads (status); + +-- 7. GIN index on tasks(tags) for array containment queries (@>, &&) +CREATE INDEX idx_tasks_tags ON tasks USING GIN (tags); + +-- 8. Backfill status_since from updated_at for existing agent rows +UPDATE agents + SET status_since = updated_at + WHERE status_since IS NULL; + +COMMIT; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 913471f..8417f32 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -122,6 +122,9 @@ importers: react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.2.10)(react@19.2.3) + remark-gfm: + specifier: ^4.0.1 + version: 4.0.1 tailwind-merge: specifier: ^3.4.0 version: 3.4.0 @@ -2046,6 +2049,10 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + eslint-config-next@16.1.6: resolution: {integrity: sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA==} peerDependencies: @@ -2743,13 +2750,37 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdast-util-find-and-replace@3.0.2: + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + mdast-util-from-markdown@2.0.2: resolution: {integrity: sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==} + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + mdast-util-mdx-expression@2.0.1: resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} @@ -2781,6 +2812,27 @@ packages: micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.1: + resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + micromark-factory-destination@2.0.1: resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} @@ -3146,12 +3198,18 @@ packages: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} + remark-gfm@4.0.1: + resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + remark-parse@11.0.0: resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} remark-rehype@11.1.2: resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -5579,6 +5637,8 @@ snapshots: escape-string-regexp@4.0.0: {} + escape-string-regexp@5.0.0: {} + eslint-config-next@16.1.6(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): dependencies: '@next/eslint-plugin-next': 16.1.6 @@ -6391,8 +6451,17 @@ snapshots: dependencies: semver: 7.7.3 + markdown-table@3.0.4: {} + math-intrinsics@1.1.0: {} + mdast-util-find-and-replace@3.0.2: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + mdast-util-from-markdown@2.0.2: dependencies: '@types/mdast': 4.0.4 @@ -6410,6 +6479,63 @@ snapshots: transitivePeerDependencies: - supports-color + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.2 + micromark-util-character: 2.1.1 + + mdast-util-gfm-footnote@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0: + dependencies: + mdast-util-from-markdown: 2.0.2 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + mdast-util-mdx-expression@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 @@ -6505,6 +6631,64 @@ snapshots: micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-table@2.1.1: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.1 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + micromark-factory-destination@2.0.1: dependencies: micromark-util-character: 2.1.1 @@ -6926,6 +7110,17 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 + remark-gfm@4.0.1: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-gfm: 3.1.0 + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + remark-parse@11.0.0: dependencies: '@types/mdast': 4.0.4 @@ -6943,6 +7138,12 @@ snapshots: unified: 11.0.5 vfile: 6.0.3 + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-to-markdown: 2.1.2 + unified: 11.0.5 + require-from-string@2.0.2: {} resolve-from@4.0.0: {}