social ui refresh: tiered left rail, borderless depth, status-colored right rail
This commit is contained in:
parent
fcbe7df804
commit
560866e268
6 changed files with 671 additions and 397 deletions
|
|
@ -4,13 +4,26 @@ import { useEffect, useState, useMemo } from 'react';
|
||||||
import type { BeadIssue } from '../../lib/types';
|
import type { BeadIssue } from '../../lib/types';
|
||||||
import type { ActivityEvent } from '../../lib/activity';
|
import type { ActivityEvent } from '../../lib/activity';
|
||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { Separator } from '@/components/ui/separator';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
type AgentStatus = 'active' | 'stale' | 'stuck' | 'dead';
|
type AgentStatus = 'active' | 'stale' | 'stuck' | 'dead';
|
||||||
|
|
||||||
|
type AgentTone = {
|
||||||
|
cardClass: string;
|
||||||
|
labelClass: string;
|
||||||
|
ringClass: string;
|
||||||
|
glowClass: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EventTone = {
|
||||||
|
label: string;
|
||||||
|
labelClass: string;
|
||||||
|
dotClass: string;
|
||||||
|
cardClass: string;
|
||||||
|
idClass: string;
|
||||||
|
};
|
||||||
|
|
||||||
interface AgentRosterEntry {
|
interface AgentRosterEntry {
|
||||||
name: string;
|
name: string;
|
||||||
status: AgentStatus;
|
status: AgentStatus;
|
||||||
|
|
@ -94,21 +107,136 @@ function formatRelativeTime(timestamp: string): string {
|
||||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get event kind icon/color
|
function getAgentTone(status: AgentStatus): AgentTone {
|
||||||
function getEventKindInfo(kind: string): { label: string; color: string } {
|
const tones: Record<AgentStatus, AgentTone> = {
|
||||||
const events: Record<string, { label: string; color: string }> = {
|
active: {
|
||||||
created: { label: 'Created', color: 'text-emerald-500' },
|
cardClass: 'bg-[radial-gradient(circle_at_86%_18%,rgba(124,185,122,0.28),transparent_58%),rgba(45,64,47,0.74)]',
|
||||||
closed: { label: 'Closed', color: 'text-amber-500' },
|
labelClass: 'text-[#7CB97A]',
|
||||||
reopened: { label: 'Reopened', color: 'text-blue-500' },
|
ringClass: 'ring-[#7CB97A]/45',
|
||||||
status_changed: { label: 'Status changed', color: 'text-cyan-500' },
|
glowClass: 'bg-[#7CB97A]/30',
|
||||||
priority_changed: { label: 'Priority changed', color: 'text-purple-500' },
|
},
|
||||||
assignee_changed: { label: 'Assigned', color: 'text-indigo-500' },
|
stale: {
|
||||||
heartbeat: { label: 'Heartbeat', color: 'text-muted-foreground' },
|
cardClass: 'bg-[radial-gradient(circle_at_86%_18%,rgba(212,165,116,0.28),transparent_58%),rgba(73,61,46,0.74)]',
|
||||||
dependency_added: { label: 'Dependency added', color: 'text-orange-500' },
|
labelClass: 'text-[#D4A574]',
|
||||||
dependency_removed: { label: 'Dependency removed', color: 'text-red-500' },
|
ringClass: 'ring-[#D4A574]/45',
|
||||||
|
glowClass: 'bg-[#D4A574]/30',
|
||||||
|
},
|
||||||
|
stuck: {
|
||||||
|
cardClass: 'bg-[radial-gradient(circle_at_86%_18%,rgba(201,122,122,0.28),transparent_58%),rgba(74,52,54,0.76)]',
|
||||||
|
labelClass: 'text-[#C97A7A]',
|
||||||
|
ringClass: 'ring-[#C97A7A]/45',
|
||||||
|
glowClass: 'bg-[#C97A7A]/30',
|
||||||
|
},
|
||||||
|
dead: {
|
||||||
|
cardClass: 'bg-[radial-gradient(circle_at_86%_18%,rgba(136,104,112,0.26),transparent_58%),rgba(60,55,60,0.74)]',
|
||||||
|
labelClass: 'text-[#A78A94]',
|
||||||
|
ringClass: 'ring-[#A78A94]/40',
|
||||||
|
glowClass: 'bg-[#A78A94]/25',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return events[kind] || { label: kind.replace(/_/g, ' '), color: 'text-muted-foreground' };
|
return tones[status];
|
||||||
|
}
|
||||||
|
|
||||||
|
// reopened=blue, closed=amber, created/opened=green, others semantic
|
||||||
|
function getEventTone(kind: string): EventTone {
|
||||||
|
const normalized = kind.toLowerCase();
|
||||||
|
const byKind: Record<string, EventTone> = {
|
||||||
|
created: {
|
||||||
|
label: 'Created',
|
||||||
|
labelClass: 'text-[#7CB97A]',
|
||||||
|
dotClass: 'bg-[#7CB97A]',
|
||||||
|
cardClass: 'bg-[radial-gradient(circle_at_88%_18%,rgba(124,185,122,0.26),transparent_55%),rgba(42,62,44,0.68)]',
|
||||||
|
idClass: 'text-[#9ACB98]',
|
||||||
|
},
|
||||||
|
opened: {
|
||||||
|
label: 'Opened',
|
||||||
|
labelClass: 'text-[#7CB97A]',
|
||||||
|
dotClass: 'bg-[#7CB97A]',
|
||||||
|
cardClass: 'bg-[radial-gradient(circle_at_88%_18%,rgba(124,185,122,0.26),transparent_55%),rgba(42,62,44,0.68)]',
|
||||||
|
idClass: 'text-[#9ACB98]',
|
||||||
|
},
|
||||||
|
closed: {
|
||||||
|
label: 'Closed',
|
||||||
|
labelClass: 'text-[#D4A574]',
|
||||||
|
dotClass: 'bg-[#D4A574]',
|
||||||
|
cardClass: 'bg-[radial-gradient(circle_at_88%_18%,rgba(212,165,116,0.28),transparent_55%),rgba(66,56,44,0.7)]',
|
||||||
|
idClass: 'text-[#DAB891]',
|
||||||
|
},
|
||||||
|
reopened: {
|
||||||
|
label: 'Reopened',
|
||||||
|
labelClass: 'text-[#5B95E8]',
|
||||||
|
dotClass: 'bg-[#5B95E8]',
|
||||||
|
cardClass: 'bg-[radial-gradient(circle_at_88%_18%,rgba(91,149,232,0.3),transparent_55%),rgba(42,51,66,0.7)]',
|
||||||
|
idClass: 'text-[#8DB4EF]',
|
||||||
|
},
|
||||||
|
status_changed: {
|
||||||
|
label: 'Status changed',
|
||||||
|
labelClass: 'text-[#D4A574]',
|
||||||
|
dotClass: 'bg-[#D4A574]',
|
||||||
|
cardClass: 'bg-[radial-gradient(circle_at_88%_18%,rgba(212,165,116,0.24),transparent_55%),rgba(63,54,44,0.68)]',
|
||||||
|
idClass: 'text-[#DAB891]',
|
||||||
|
},
|
||||||
|
priority_changed: {
|
||||||
|
label: 'Priority changed',
|
||||||
|
labelClass: 'text-[#D4A574]',
|
||||||
|
dotClass: 'bg-[#D4A574]',
|
||||||
|
cardClass: 'bg-[radial-gradient(circle_at_88%_18%,rgba(212,165,116,0.24),transparent_55%),rgba(63,54,44,0.68)]',
|
||||||
|
idClass: 'text-[#DAB891]',
|
||||||
|
},
|
||||||
|
assignee_changed: {
|
||||||
|
label: 'Assigned',
|
||||||
|
labelClass: 'text-[#D4A574]',
|
||||||
|
dotClass: 'bg-[#D4A574]',
|
||||||
|
cardClass: 'bg-[radial-gradient(circle_at_88%_18%,rgba(212,165,116,0.24),transparent_55%),rgba(63,54,44,0.68)]',
|
||||||
|
idClass: 'text-[#DAB891]',
|
||||||
|
},
|
||||||
|
dependency_added: {
|
||||||
|
label: 'Dependency added',
|
||||||
|
labelClass: 'text-[#D4A574]',
|
||||||
|
dotClass: 'bg-[#D4A574]',
|
||||||
|
cardClass: 'bg-[radial-gradient(circle_at_88%_18%,rgba(212,165,116,0.24),transparent_55%),rgba(63,54,44,0.68)]',
|
||||||
|
idClass: 'text-[#DAB891]',
|
||||||
|
},
|
||||||
|
dependency_removed: {
|
||||||
|
label: 'Dependency removed',
|
||||||
|
labelClass: 'text-[#C97A7A]',
|
||||||
|
dotClass: 'bg-[#C97A7A]',
|
||||||
|
cardClass: 'bg-[radial-gradient(circle_at_88%_18%,rgba(201,122,122,0.24),transparent_55%),rgba(65,47,50,0.7)]',
|
||||||
|
idClass: 'text-[#D9A9A9]',
|
||||||
|
},
|
||||||
|
heartbeat: {
|
||||||
|
label: 'Heartbeat',
|
||||||
|
labelClass: 'text-[#5BA8A0]',
|
||||||
|
dotClass: 'bg-[#5BA8A0]',
|
||||||
|
cardClass: 'bg-[radial-gradient(circle_at_88%_18%,rgba(91,168,160,0.26),transparent_55%),rgba(42,58,60,0.7)]',
|
||||||
|
idClass: 'text-[#8BC9C1]',
|
||||||
|
},
|
||||||
|
commented: {
|
||||||
|
label: 'Commented',
|
||||||
|
labelClass: 'text-[#5BA8A0]',
|
||||||
|
dotClass: 'bg-[#5BA8A0]',
|
||||||
|
cardClass: 'bg-[radial-gradient(circle_at_88%_18%,rgba(91,168,160,0.26),transparent_55%),rgba(42,58,60,0.7)]',
|
||||||
|
idClass: 'text-[#8BC9C1]',
|
||||||
|
},
|
||||||
|
comment_added: {
|
||||||
|
label: 'Commented',
|
||||||
|
labelClass: 'text-[#5BA8A0]',
|
||||||
|
dotClass: 'bg-[#5BA8A0]',
|
||||||
|
cardClass: 'bg-[radial-gradient(circle_at_88%_18%,rgba(91,168,160,0.26),transparent_55%),rgba(42,58,60,0.7)]',
|
||||||
|
idClass: 'text-[#8BC9C1]',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
byKind[normalized] || {
|
||||||
|
label: normalized.replace(/_/g, ' '),
|
||||||
|
labelClass: 'text-[#5BA8A0]',
|
||||||
|
dotClass: 'bg-[#5BA8A0]',
|
||||||
|
cardClass: 'bg-[radial-gradient(circle_at_88%_18%,rgba(91,168,160,0.24),transparent_55%),rgba(42,58,60,0.68)]',
|
||||||
|
idClass: 'text-[#8BC9C1]',
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getInitials(name: string): string {
|
function getInitials(name: string): string {
|
||||||
|
|
@ -164,24 +292,24 @@ export function ActivityPanel({ issues, collapsed = false }: ActivityPanelProps)
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const activeAgents = agentRoster.filter(a => a.status === 'active').length;
|
const activeAgents = agentRoster.filter(a => a.status === 'active').length;
|
||||||
const staleAgents = agentRoster.filter(a => a.status === 'stale').length;
|
|
||||||
|
|
||||||
if (collapsed) {
|
if (collapsed) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-6 py-6 h-full bg-black/40 border-l border-white/5 shadow-2xl">
|
<div className="flex flex-col items-center gap-6 py-6 h-full bg-[linear-gradient(180deg,rgba(0,0,0,0.2),rgba(0,0,0,0.36))] shadow-[inset_10px_0_22px_-20px_rgba(0,0,0,0.9)]">
|
||||||
{/* Collapsed Agent Icons with ZFC Rings */}
|
{/* Collapsed Agent Icons with ZFC Rings */}
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{agentRoster.slice(0, 6).map(agent => (
|
{agentRoster.slice(0, 6).map(agent => (
|
||||||
<div key={agent.beadId} className="relative group cursor-help" title={`${agent.name} (${agent.status})`}>
|
<div key={agent.beadId} className="relative group cursor-help" title={`${agent.name} (${agent.status})`}>
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"absolute -inset-1 rounded-full blur-[2px] transition-opacity duration-500",
|
"absolute -inset-1 rounded-full blur-[2px] transition-opacity duration-500",
|
||||||
agent.status === 'active' ? 'bg-emerald-500/20 opacity-100 animate-pulse' :
|
agent.status === 'active' ? 'bg-[#7CB97A]/20 opacity-100 animate-pulse' :
|
||||||
agent.status === 'stale' ? 'bg-amber-500/10 opacity-50' : 'bg-rose-500/20 opacity-100'
|
agent.status === 'stale' ? 'bg-[#D4A574]/14 opacity-80' :
|
||||||
|
agent.status === 'stuck' ? 'bg-[#C97A7A]/20 opacity-100' : 'bg-[#A78A94]/18 opacity-90'
|
||||||
)} />
|
)} />
|
||||||
<Avatar className={cn(
|
<Avatar className={cn(
|
||||||
"h-9 w-9 ring-2 transition-all duration-300 relative z-10",
|
"h-9 w-9 ring-2 transition-all duration-300 relative z-10",
|
||||||
agent.status === 'active' ? 'ring-emerald-500/40' :
|
agent.status === 'active' ? 'ring-[#7CB97A]/45' :
|
||||||
agent.status === 'stale' ? 'ring-amber-500/20' : 'ring-rose-500/40'
|
agent.status === 'stale' ? 'ring-[#D4A574]/45' :
|
||||||
|
agent.status === 'stuck' ? 'ring-[#C97A7A]/45' : 'ring-[#A78A94]/40'
|
||||||
)}>
|
)}>
|
||||||
<AvatarFallback className="text-[10px] font-bold bg-[#1a1a1a] text-text-muted">
|
<AvatarFallback className="text-[10px] font-bold bg-[#1a1a1a] text-text-muted">
|
||||||
{getInitials(agent.name)}
|
{getInitials(agent.name)}
|
||||||
|
|
@ -191,14 +319,14 @@ export function ActivityPanel({ issues, collapsed = false }: ActivityPanelProps)
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-6 h-[1px] bg-white/10 mx-auto" />
|
<div className="w-6 h-[1px] bg-white/20 mx-auto" />
|
||||||
|
|
||||||
{/* Activity Pulses */}
|
{/* Activity Pulses */}
|
||||||
<div className="flex flex-col gap-2 opacity-40">
|
<div className="flex flex-col gap-2 opacity-40">
|
||||||
{activities.slice(0, 8).map((act) => (
|
{activities.slice(0, 8).map((act) => (
|
||||||
<div key={act.id} className={cn(
|
<div key={act.id} className={cn(
|
||||||
"w-1 h-1 rounded-full",
|
"w-1 h-1 rounded-full",
|
||||||
act.kind === 'created' ? 'bg-emerald-500 shadow-[0_0_4px_#10b981]' : 'bg-cyan-500 shadow-[0_0_4px_#06b6d4]'
|
getEventTone(act.kind).dotClass
|
||||||
)} />
|
)} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -207,15 +335,15 @@ export function ActivityPanel({ issues, collapsed = false }: ActivityPanelProps)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full bg-[#1a1a1a]/95 backdrop-blur-xl">
|
<div className="flex flex-col h-full bg-[radial-gradient(circle_at_8%_5%,rgba(91,168,160,0.16),transparent_30%),radial-gradient(circle_at_94%_88%,rgba(212,165,116,0.14),transparent_34%),rgba(26,26,28,0.96)] backdrop-blur-xl">
|
||||||
{/* AGENT ROSTER SECTION */}
|
{/* AGENT ROSTER SECTION */}
|
||||||
<div className="flex-shrink-0 p-4 border-b border-white/5 bg-white/[0.02]">
|
<div className="flex-shrink-0 p-4 bg-black/10 shadow-[0_16px_24px_-24px_rgba(0,0,0,0.9)]">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse shadow-[0_0_8px_#10b981]" />
|
<div className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse shadow-[0_0_8px_#10b981]" />
|
||||||
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-text-muted">Live Agents</h3>
|
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-text-muted">Live Agents</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] font-mono text-emerald-500/60 bg-emerald-500/5 px-2 py-0.5 rounded border border-emerald-500/10">
|
<div className="text-[10px] font-mono text-[#7CB97A]/80 bg-[#7CB97A]/15 px-2 py-0.5 rounded shadow-[0_10px_16px_-12px_rgba(0,0,0,0.8)]">
|
||||||
{activeAgents} ONLINE
|
{activeAgents} ONLINE
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -225,16 +353,16 @@ export function ActivityPanel({ issues, collapsed = false }: ActivityPanelProps)
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 gap-2">
|
<div className="grid grid-cols-1 gap-2">
|
||||||
{agentRoster.map(agent => (
|
{agentRoster.map(agent => (
|
||||||
<div
|
<div key={agent.beadId} className={cn(
|
||||||
key={agent.beadId}
|
'group flex items-center gap-3 p-2 rounded-xl transition-all duration-300 shadow-[0_12px_22px_-14px_rgba(0,0,0,0.85)]',
|
||||||
className="group flex items-center gap-3 p-2 rounded-xl bg-white/[0.03] border border-white/5 hover:border-white/10 hover:bg-white/[0.05] transition-all duration-300"
|
getAgentTone(agent.status).cardClass,
|
||||||
>
|
)}>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"absolute -inset-0.5 rounded-full blur-[1px] opacity-0 group-hover:opacity-100 transition-opacity",
|
"absolute -inset-0.5 rounded-full blur-[1px] opacity-0 group-hover:opacity-100 transition-opacity",
|
||||||
agent.status === 'active' ? 'bg-emerald-500/30' : 'bg-amber-500/30'
|
getAgentTone(agent.status).glowClass
|
||||||
)} />
|
)} />
|
||||||
<Avatar className="h-8 w-8 relative z-10 ring-1 ring-white/10">
|
<Avatar className={cn("h-8 w-8 relative z-10 ring-1", getAgentTone(agent.status).ringClass)}>
|
||||||
<AvatarFallback className="text-[10px] font-bold bg-[#252525]">
|
<AvatarFallback className="text-[10px] font-bold bg-[#252525]">
|
||||||
{getInitials(agent.name)}
|
{getInitials(agent.name)}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
|
|
@ -245,7 +373,7 @@ export function ActivityPanel({ issues, collapsed = false }: ActivityPanelProps)
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"text-[9px] uppercase tracking-wider font-bold",
|
"text-[9px] uppercase tracking-wider font-bold",
|
||||||
agent.status === 'active' ? 'text-emerald-500' : 'text-amber-500'
|
getAgentTone(agent.status).labelClass
|
||||||
)}>
|
)}>
|
||||||
{agent.status}
|
{agent.status}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -262,7 +390,7 @@ export function ActivityPanel({ issues, collapsed = false }: ActivityPanelProps)
|
||||||
|
|
||||||
{/* ACTIVITY FEED SECTION */}
|
{/* ACTIVITY FEED SECTION */}
|
||||||
<div className="flex-1 min-h-0 flex flex-col">
|
<div className="flex-1 min-h-0 flex flex-col">
|
||||||
<div className="p-4 flex items-center gap-2 border-b border-white/5">
|
<div className="p-4 flex items-center gap-2 shadow-[0_14px_24px_-24px_rgba(0,0,0,0.9)]">
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-text-muted/60"><path d="M22 12h-4l-3 9L9 3l-3 9H2"></path></svg>
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-text-muted/60"><path d="M22 12h-4l-3 9L9 3l-3 9H2"></path></svg>
|
||||||
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-text-muted">Telemetry Stream</h3>
|
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-text-muted">Telemetry Stream</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -280,19 +408,20 @@ export function ActivityPanel({ issues, collapsed = false }: ActivityPanelProps)
|
||||||
) : (
|
) : (
|
||||||
<div className="p-3 space-y-3">
|
<div className="p-3 space-y-3">
|
||||||
{activities.map((activity) => {
|
{activities.map((activity) => {
|
||||||
const eventInfo = getEventKindInfo(activity.kind);
|
const eventTone = getEventTone(activity.kind);
|
||||||
return (
|
return (
|
||||||
<div key={activity.id} className="group relative">
|
<div key={activity.id} className="group relative">
|
||||||
<div className="absolute -left-3 top-0 bottom-0 w-[1px] bg-white/5 group-hover:bg-white/10 transition-colors" />
|
<div className={cn(
|
||||||
|
"p-3 rounded-xl transition-all duration-300 shadow-[0_12px_22px_-14px_rgba(0,0,0,0.88)]",
|
||||||
<div className="p-3 rounded-xl bg-white/[0.02] border border-white/5 hover:border-white/10 transition-all duration-300">
|
eventTone.cardClass
|
||||||
|
)}>
|
||||||
<div className="flex items-center gap-2 mb-1.5">
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"w-1.5 h-1.5 rounded-full shrink-0",
|
"w-1.5 h-1.5 rounded-full shrink-0",
|
||||||
activity.kind === 'closed' ? 'bg-amber-500' : 'bg-emerald-500'
|
eventTone.dotClass
|
||||||
)} />
|
)} />
|
||||||
<span className={cn("text-[10px] font-bold uppercase tracking-wider", eventInfo.color)}>
|
<span className={cn("text-[10px] font-bold uppercase tracking-wider", eventTone.labelClass)}>
|
||||||
{eventInfo.label}
|
{eventTone.label}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[9px] text-text-muted/30 font-mono ml-auto">
|
<span className="text-[9px] text-text-muted/30 font-mono ml-auto">
|
||||||
{formatRelativeTime(activity.timestamp)}
|
{formatRelativeTime(activity.timestamp)}
|
||||||
|
|
@ -304,12 +433,12 @@ export function ActivityPanel({ issues, collapsed = false }: ActivityPanelProps)
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-[10px] font-mono text-teal-500/50">
|
<span className={cn("text-[10px] font-mono", eventTone.idClass)}>
|
||||||
{activity.beadId}
|
{activity.beadId}
|
||||||
</span>
|
</span>
|
||||||
{activity.actor && (
|
{activity.actor && (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<div className="w-3 h-3 rounded-full bg-white/5 border border-white/10 flex items-center justify-center text-[6px] font-bold">
|
<div className="w-3 h-3 rounded-full bg-white/10 shadow-[0_0_8px_rgba(0,0,0,0.45)] flex items-center justify-center text-[6px] font-bold">
|
||||||
{activity.actor[0].toUpperCase()}
|
{activity.actor[0].toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[9px] text-text-muted/60">{activity.actor}</span>
|
<span className="text-[9px] text-text-muted/60">{activity.actor}</span>
|
||||||
|
|
|
||||||
|
|
@ -77,11 +77,11 @@ function buildEpicTree(issues: BeadIssue[]): EpicNode[] {
|
||||||
|
|
||||||
function StatusIndicator({ status }: { status: string }) {
|
function StatusIndicator({ status }: { status: string }) {
|
||||||
const styles = {
|
const styles = {
|
||||||
blocked: 'bg-rose-500 shadow-[0_0_8px_#f43f5e]',
|
blocked: 'bg-[#C97A7A] shadow-[0_0_8px_rgba(201,122,122,0.45)]',
|
||||||
in_progress: 'bg-amber-500 shadow-[0_0_8px_#f59e0b]',
|
in_progress: 'bg-[#D4A574] shadow-[0_0_8px_rgba(212,165,116,0.45)]',
|
||||||
ready: 'bg-teal-500 shadow-[0_0_8px_#14b8a6]',
|
ready: 'bg-[#7CB97A] shadow-[0_0_8px_rgba(124,185,122,0.45)]',
|
||||||
done: 'bg-slate-500',
|
done: 'bg-[var(--status-closed)]',
|
||||||
empty: 'bg-white/10'
|
empty: 'bg-white/10',
|
||||||
}[status] || 'bg-slate-500';
|
}[status] || 'bg-slate-500';
|
||||||
|
|
||||||
return <div className={cn("w-1.5 h-1.5 rounded-full shrink-0", styles)} />;
|
return <div className={cn("w-1.5 h-1.5 rounded-full shrink-0", styles)} />;
|
||||||
|
|
@ -96,6 +96,9 @@ export function LeftPanel({
|
||||||
const { isDesktop, isTablet } = useResponsive();
|
const { isDesktop, isTablet } = useResponsive();
|
||||||
|
|
||||||
const epicTree = useMemo(() => buildEpicTree(issues), [issues]);
|
const epicTree = useMemo(() => buildEpicTree(issues), [issues]);
|
||||||
|
const featuredEpics = useMemo(() => epicTree.slice(0, 2), [epicTree]);
|
||||||
|
const standardEpics = useMemo(() => epicTree.slice(2, 6), [epicTree]);
|
||||||
|
const compactEpics = useMemo(() => epicTree.slice(6), [epicTree]);
|
||||||
|
|
||||||
const toggleEpic = (epicId: string) => {
|
const toggleEpic = (epicId: string) => {
|
||||||
setExpandedEpics(prev => {
|
setExpandedEpics(prev => {
|
||||||
|
|
@ -116,7 +119,7 @@ export function LeftPanel({
|
||||||
|
|
||||||
if (isTablet) {
|
if (isTablet) {
|
||||||
return (
|
return (
|
||||||
<div className="w-16 overflow-y-auto flex flex-col items-center py-4 gap-2 bg-[#1a1a1a]/95 backdrop-blur-xl border-r border-white/5">
|
<div className="flex w-16 flex-col items-center gap-2 overflow-y-auto bg-[var(--color-bg-card)]/96 py-4 shadow-[10px_0_28px_-16px_rgba(0,0,0,0.82)]">
|
||||||
{epicTree.map(({ epic, status }) => (
|
{epicTree.map(({ epic, status }) => (
|
||||||
<button
|
<button
|
||||||
key={epic.id}
|
key={epic.id}
|
||||||
|
|
@ -124,10 +127,10 @@ export function LeftPanel({
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-10 h-10 rounded-xl flex items-center justify-center text-xs font-bold transition-all duration-200 ring-1',
|
'w-10 h-10 rounded-xl flex items-center justify-center text-xs font-bold transition-all duration-200 ring-1',
|
||||||
selectedEpicId === epic.id
|
selectedEpicId === epic.id
|
||||||
? 'bg-white/10 ring-white/30 text-white'
|
? 'bg-[var(--color-bg-input)] ring-white/30 text-white'
|
||||||
: 'hover:bg-white/5 ring-transparent text-text-muted',
|
: 'ring-transparent text-[var(--color-text-muted)] hover:bg-white/5',
|
||||||
status === 'blocked' && 'ring-rose-500/50',
|
status === 'blocked' && 'ring-[#C97A7A]/50',
|
||||||
status === 'in_progress' && 'ring-amber-500/50'
|
status === 'in_progress' && 'ring-[#D4A574]/50'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{epic.id.slice(0, 2).toUpperCase()}
|
{epic.id.slice(0, 2).toUpperCase()}
|
||||||
|
|
@ -146,112 +149,165 @@ export function LeftPanel({
|
||||||
style={{ width: '20rem' }}
|
style={{ width: '20rem' }}
|
||||||
data-testid="left-panel"
|
data-testid="left-panel"
|
||||||
>
|
>
|
||||||
<div className="h-full bg-[#151515]/95 backdrop-blur-2xl border-r border-white/5 flex flex-col">
|
<div className="flex h-full flex-col bg-[radial-gradient(circle_at_4%_14%,rgba(212,165,116,0.38),transparent_44%),radial-gradient(circle_at_96%_86%,rgba(91,168,160,0.34),transparent_40%),linear-gradient(165deg,rgba(49,49,62,0.97),rgba(37,40,54,0.98))] shadow-[14px_0_34px_-18px_rgba(0,0,0,0.86)]">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="p-5 border-b border-white/5 flex items-center justify-between bg-white/[0.02]">
|
<div className="flex items-center justify-between bg-[linear-gradient(90deg,rgba(212,165,116,0.16),rgba(91,168,160,0.12))] p-5 shadow-[0_12px_22px_-18px_rgba(0,0,0,0.9)]">
|
||||||
<span className="text-xs font-bold uppercase tracking-[0.2em] text-text-muted">Workstreams</span>
|
<span className="text-xs font-bold uppercase tracking-[0.2em] text-[var(--color-text-muted)]">Workstreams</span>
|
||||||
<div className="flex gap-2 text-[10px] font-mono text-text-muted/40">
|
<div className="flex gap-2 text-[10px] font-mono text-[var(--color-text-muted)]/60">
|
||||||
<span>{epicTree.length} ACTIVE</span>
|
<span>{epicTree.length} ACTIVE</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tree */}
|
{/* Tree */}
|
||||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-3 space-y-3">
|
<div className="flex-1 overflow-y-auto custom-scrollbar p-3 space-y-4">
|
||||||
{epicTree.map(({ epic, children, stats, status }) => {
|
{[
|
||||||
const isExpanded = expandedEpics.has(epic.id);
|
{ label: 'Featured', items: featuredEpics, tier: 'featured' as const },
|
||||||
const isSelected = selectedEpicId === epic.id;
|
{ label: 'Active', items: standardEpics, tier: 'standard' as const },
|
||||||
|
{ label: 'Queue', items: compactEpics, tier: 'compact' as const },
|
||||||
|
].map((section) => (
|
||||||
|
<div key={section.label} className={cn(section.items.length === 0 && 'hidden')}>
|
||||||
|
<p className="mb-2 px-1 text-[10px] font-bold uppercase tracking-[0.16em] text-[#97A0AF]/75">
|
||||||
|
{section.label}
|
||||||
|
</p>
|
||||||
|
<div className={cn(section.tier === 'compact' ? 'space-y-1.5' : 'space-y-2.5')}>
|
||||||
|
{section.items.map(({ epic, children, stats, status }) => {
|
||||||
|
const isExpanded = expandedEpics.has(epic.id);
|
||||||
|
const isSelected = selectedEpicId === epic.id;
|
||||||
|
|
||||||
// Dynamic Styling based on Status
|
const statusStyle = {
|
||||||
const statusStyle = {
|
blocked:
|
||||||
blocked: 'border-rose-500/30 bg-rose-500/5 hover:bg-rose-500/10',
|
'bg-[radial-gradient(circle_at_100%_50%,rgba(201,122,122,0.3),transparent_58%),rgba(92,58,58,0.8)] hover:bg-[radial-gradient(circle_at_100%_50%,rgba(201,122,122,0.38),transparent_58%),rgba(106,64,64,0.85)]',
|
||||||
in_progress: 'border-amber-500/30 bg-amber-500/5 hover:bg-amber-500/10',
|
in_progress:
|
||||||
ready: 'border-teal-500/30 bg-teal-500/5 hover:bg-teal-500/10',
|
'bg-[radial-gradient(circle_at_100%_50%,rgba(212,165,116,0.34),transparent_58%),rgba(92,70,45,0.82)] hover:bg-[radial-gradient(circle_at_100%_50%,rgba(212,165,116,0.44),transparent_58%),rgba(108,82,51,0.88)]',
|
||||||
done: 'border-white/5 bg-white/[0.02] opacity-60',
|
ready:
|
||||||
empty: 'border-white/5 bg-transparent opacity-40'
|
'bg-[radial-gradient(circle_at_100%_50%,rgba(124,185,122,0.34),transparent_60%),rgba(54,84,55,0.84)] hover:bg-[radial-gradient(circle_at_100%_50%,rgba(124,185,122,0.44),transparent_60%),rgba(61,95,61,0.9)]',
|
||||||
}[status];
|
done:
|
||||||
|
'bg-[radial-gradient(circle_at_100%_50%,rgba(91,168,160,0.3),transparent_58%),rgba(52,72,77,0.78)] hover:bg-[radial-gradient(circle_at_100%_50%,rgba(91,168,160,0.38),transparent_58%),rgba(59,82,87,0.84)]',
|
||||||
|
empty:
|
||||||
|
'bg-[radial-gradient(circle_at_100%_50%,rgba(74,104,130,0.2),transparent_58%),rgba(44,49,65,0.76)] hover:bg-[radial-gradient(circle_at_100%_50%,rgba(74,104,130,0.28),transparent_58%),rgba(49,56,74,0.82)]',
|
||||||
|
}[status];
|
||||||
|
|
||||||
const activeStyle = isSelected ? 'ring-1 ring-white/20 shadow-lg scale-[1.02]' : '';
|
if (section.tier === 'compact') {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={epic.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onEpicSelect?.(epic.id === selectedEpicId ? null : epic.id)}
|
||||||
|
className={cn(
|
||||||
|
'w-full rounded-lg px-2.5 py-2 text-left transition-all duration-200',
|
||||||
|
'flex items-center justify-between gap-2',
|
||||||
|
statusStyle,
|
||||||
|
isSelected
|
||||||
|
? 'shadow-[0_14px_22px_-14px_rgba(0,0,0,0.88),0_0_0_1px_rgba(255,255,255,0.08)_inset]'
|
||||||
|
: 'shadow-[0_8px_16px_-12px_rgba(0,0,0,0.82)]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-mono text-[10px] text-[#C7D0DF]/70">{epic.id}</p>
|
||||||
|
<p className="truncate text-xs font-semibold text-white/90">{epic.title}</p>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 text-right">
|
||||||
|
<p className="text-[10px] font-mono text-[#C7D0DF]/70">{stats.total}</p>
|
||||||
|
<StatusIndicator status={status} />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
const isFeatured = section.tier === 'featured';
|
||||||
<div key={epic.id} className="group">
|
const cardPadding = isFeatured ? 'p-4' : 'p-3';
|
||||||
<button
|
const titleClass = isFeatured ? 'text-base' : 'text-sm';
|
||||||
type="button"
|
const activeStyle = isSelected
|
||||||
onClick={() => handleEpicClick(epic.id)}
|
? 'shadow-[0_24px_34px_-16px_rgba(0,0,0,0.9),0_0_0_1px_rgba(255,255,255,0.08)_inset] scale-[1.01]'
|
||||||
className={cn(
|
: 'shadow-[0_10px_20px_-14px_rgba(0,0,0,0.85)]';
|
||||||
'w-full flex flex-col p-3 rounded-xl text-left transition-all duration-300 border relative overflow-hidden',
|
|
||||||
statusStyle,
|
|
||||||
activeStyle
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Status Bar Indicator */}
|
|
||||||
<div className={cn(
|
|
||||||
"absolute left-0 top-0 bottom-0 w-1",
|
|
||||||
status === 'blocked' ? 'bg-rose-500' :
|
|
||||||
status === 'in_progress' ? 'bg-amber-500' :
|
|
||||||
status === 'ready' ? 'bg-teal-500' : 'bg-transparent'
|
|
||||||
)} />
|
|
||||||
|
|
||||||
<div className="pl-2.5 w-full">
|
return (
|
||||||
<div className="flex items-center justify-between w-full mb-1">
|
<div key={epic.id} className="group">
|
||||||
<span className="text-[10px] font-mono text-text-muted/70 tracking-tight">{epic.id}</span>
|
<button
|
||||||
{stats.blocked > 0 && (
|
type="button"
|
||||||
<span className="text-[9px] font-bold text-rose-400 bg-rose-500/10 px-1.5 rounded animate-pulse">
|
onClick={() => handleEpicClick(epic.id)}
|
||||||
{stats.blocked} BLOCKED
|
className={cn(
|
||||||
</span>
|
'w-full flex flex-col rounded-xl text-left transition-all duration-300 relative overflow-hidden',
|
||||||
|
cardPadding,
|
||||||
|
statusStyle,
|
||||||
|
activeStyle,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute left-0 top-0 bottom-0 w-1.5',
|
||||||
|
status === 'blocked'
|
||||||
|
? 'bg-[#C97A7A]'
|
||||||
|
: status === 'in_progress'
|
||||||
|
? 'bg-[#D4A574]'
|
||||||
|
: status === 'ready'
|
||||||
|
? 'bg-[#7CB97A]'
|
||||||
|
: 'bg-[#5BA8A0]',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="pl-3 w-full">
|
||||||
|
<div className="flex items-center justify-between w-full mb-1">
|
||||||
|
<span className="text-[10px] font-mono text-text-muted/70 tracking-tight">{epic.id}</span>
|
||||||
|
{stats.blocked > 0 && (
|
||||||
|
<span className="rounded bg-[color:rgba(201,122,122,0.24)] px-1.5 text-[9px] font-bold text-[#F0C9C9]">
|
||||||
|
{stats.blocked} BLOCKED
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={cn('truncate font-bold text-white/90 mb-2 leading-snug', titleClass)}>
|
||||||
|
{epic.title}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex h-1.5 w-full items-center gap-1 overflow-hidden rounded-full bg-black/20">
|
||||||
|
<div style={{ width: `${(stats.closed / (stats.total || 1)) * 100}%` }} className="h-full bg-[#5BA8A0]/75" />
|
||||||
|
<div style={{ width: `${(stats.in_progress / (stats.total || 1)) * 100}%` }} className="h-full bg-[#D4A574]" />
|
||||||
|
<div style={{ width: `${(stats.blocked / (stats.total || 1)) * 100}%` }} className="h-full bg-[#C97A7A]" />
|
||||||
|
<div style={{ width: `${(stats.ready / (stats.total || 1)) * 100}%` }} className="h-full bg-[#7CB97A]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between mt-1.5 text-[9px] font-mono text-text-muted/50">
|
||||||
|
<span>{Math.round(((stats.closed + stats.in_progress) / (stats.total || 1)) * 100)}% Done</span>
|
||||||
|
<span>{stats.total} Tasks</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isExpanded && children.length > 0 && (
|
||||||
|
<div className="ml-4 mt-2 space-y-1 pl-3">
|
||||||
|
{children.slice(0, 5).map((child) => (
|
||||||
|
<div
|
||||||
|
key={child.id}
|
||||||
|
className="group/child flex cursor-pointer items-center justify-between rounded px-2 py-1.5 transition-colors hover:bg-[rgba(212,165,116,0.16)]"
|
||||||
|
>
|
||||||
|
<span className="text-[10px] font-mono text-text-muted/60">{child.id}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[10px] text-text-muted/60 truncate max-w-[80px]">{child.title}</span>
|
||||||
|
<StatusIndicator status={child.status} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{children.length > 5 && (
|
||||||
|
<div className="px-2 py-1 text-[9px] text-text-muted/30 italic">+{children.length - 5} more</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
<div className="truncate text-sm font-bold text-white/90 mb-2 leading-snug">
|
})}
|
||||||
{epic.title}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Progress / Stats Bar */}
|
|
||||||
<div className="flex items-center gap-1 h-1.5 w-full bg-black/20 rounded-full overflow-hidden">
|
|
||||||
<div style={{ width: `${(stats.closed / stats.total) * 100}%` }} className="h-full bg-slate-500/40" />
|
|
||||||
<div style={{ width: `${(stats.in_progress / stats.total) * 100}%` }} className="h-full bg-amber-500" />
|
|
||||||
<div style={{ width: `${(stats.blocked / stats.total) * 100}%` }} className="h-full bg-rose-500" />
|
|
||||||
<div style={{ width: `${(stats.ready / stats.total) * 100}%` }} className="h-full bg-teal-500/60" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between mt-1.5 text-[9px] font-mono text-text-muted/50">
|
|
||||||
<span>{Math.round(((stats.closed + stats.in_progress) / (stats.total || 1)) * 100)}% Done</span>
|
|
||||||
<span>{stats.total} Tasks</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Sub-items (Tasks) */}
|
|
||||||
{isExpanded && children.length > 0 && (
|
|
||||||
<div className="ml-4 mt-2 space-y-1 border-l border-white/5 pl-3">
|
|
||||||
{children.slice(0, 5).map(child => (
|
|
||||||
<div
|
|
||||||
key={child.id}
|
|
||||||
className="px-2 py-1.5 rounded hover:bg-white/5 cursor-pointer flex items-center justify-between group/child transition-colors"
|
|
||||||
>
|
|
||||||
<span className="text-[10px] font-mono text-text-muted/60">{child.id}</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-[10px] text-text-muted/60 truncate max-w-[80px]">{child.title}</span>
|
|
||||||
<StatusIndicator status={child.status} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{children.length > 5 && (
|
|
||||||
<div className="px-2 py-1 text-[9px] text-text-muted/30 italic">+{children.length - 5} more</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
})}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="p-4 border-t border-white/5 bg-black/20">
|
<div className="bg-black/18 p-4 shadow-[0_-10px_22px_-18px_rgba(0,0,0,0.82)]">
|
||||||
<label className="flex items-center gap-3 cursor-pointer group px-2 py-1 rounded hover:bg-white/5 transition-colors">
|
<label className="group flex cursor-pointer items-center gap-3 rounded px-2 py-1 transition-colors hover:bg-white/5">
|
||||||
<div className={`w-3 h-3 rounded-full border ${selectedEpicId === null ? 'bg-teal-500 border-teal-500' : 'border-white/20'}`} />
|
<div className={`h-3 w-3 rounded-full ${selectedEpicId === null ? 'bg-[var(--status-ready)] shadow-[0_0_8px_rgba(124,185,122,0.7)]' : 'bg-white/25'}`} />
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"text-xs font-medium transition-colors",
|
"text-xs font-medium transition-colors",
|
||||||
selectedEpicId === null ? "text-teal-400" : "text-text-muted group-hover:text-text-secondary"
|
selectedEpicId === null ? "text-[#9BD2CB]" : "text-[var(--color-text-muted)] group-hover:text-[var(--color-text-secondary)]"
|
||||||
)}>
|
)}>
|
||||||
Global Scope
|
Global Scope
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,9 @@ export function RightPanel({ children, rail, isOpen: externalIsOpen }: RightPane
|
||||||
className="overflow-hidden transition-all duration-300 flex"
|
className="overflow-hidden transition-all duration-300 flex"
|
||||||
style={{
|
style={{
|
||||||
width: panelWidth,
|
width: panelWidth,
|
||||||
backgroundColor: 'var(--color-bg-card)',
|
background:
|
||||||
borderLeft: isOpen ? '1px solid rgba(255, 255, 255, 0.1)' : 'none',
|
'radial-gradient(circle_at_12%_8%,rgba(91,168,160,0.22),transparent_34%),radial-gradient(circle_at_88%_84%,rgba(212,165,116,0.2),transparent_30%),linear-gradient(180deg,rgba(50,50,58,0.98),rgba(40,42,54,0.98))',
|
||||||
|
boxShadow: isOpen ? '-24px 0 44px -26px rgba(0,0,0,0.85)' : 'none',
|
||||||
}}
|
}}
|
||||||
data-testid="right-panel-desktop"
|
data-testid="right-panel-desktop"
|
||||||
>
|
>
|
||||||
|
|
@ -45,7 +46,7 @@ export function RightPanel({ children, rail, isOpen: externalIsOpen }: RightPane
|
||||||
|
|
||||||
{/* Side Rail (Mini Activity - Only if provided) */}
|
{/* Side Rail (Mini Activity - Only if provided) */}
|
||||||
{rail && (
|
{rail && (
|
||||||
<div className="w-12 h-full flex-shrink-0 border-l border-white/10 bg-black/20">
|
<div className="w-12 h-full flex-shrink-0 bg-[linear-gradient(180deg,rgba(0,0,0,0.24),rgba(0,0,0,0.36))] shadow-[-10px_0_20px_-18px_rgba(0,0,0,0.9)]">
|
||||||
{rail}
|
{rail}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -104,8 +105,9 @@ export function RightPanel({ children, rail, isOpen: externalIsOpen }: RightPane
|
||||||
className="fixed top-0 right-0 h-full z-50 overflow-y-auto"
|
className="fixed top-0 right-0 h-full z-50 overflow-y-auto"
|
||||||
style={{
|
style={{
|
||||||
width: '17rem',
|
width: '17rem',
|
||||||
backgroundColor: 'var(--color-bg-card)',
|
background:
|
||||||
borderLeft: '1px solid rgba(255, 255, 255, 0.1)',
|
'radial-gradient(circle_at_12%_8%,rgba(91,168,160,0.22),transparent_34%),radial-gradient(circle_at_88%_84%,rgba(212,165,116,0.2),transparent_30%),linear-gradient(180deg,rgba(50,50,58,0.98),rgba(40,42,54,0.98))',
|
||||||
|
boxShadow: '-24px 0 44px -26px rgba(0,0,0,0.85)',
|
||||||
}}
|
}}
|
||||||
data-testid="right-panel-tablet"
|
data-testid="right-panel-tablet"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,9 @@ export function TopBar({ children }: TopBarProps) {
|
||||||
<header
|
<header
|
||||||
className="h-12 flex items-center justify-between px-4"
|
className="h-12 flex items-center justify-between px-4"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: 'var(--color-bg-card)',
|
background:
|
||||||
borderBottom: '1px solid var(--color-border-soft)',
|
'radial-gradient(circle_at_10%_50%,rgba(212,165,116,0.14),transparent_30%),radial-gradient(circle_at_90%_40%,rgba(91,168,160,0.14),transparent_30%),var(--color-bg-card)',
|
||||||
|
boxShadow: '0 14px 22px -20px rgba(0,0,0,0.85)',
|
||||||
}}
|
}}
|
||||||
data-testid="top-bar"
|
data-testid="top-bar"
|
||||||
>
|
>
|
||||||
|
|
@ -64,14 +65,13 @@ export function TopBar({ children }: TopBarProps) {
|
||||||
onClick={() => setView(tab.id)}
|
onClick={() => setView(tab.id)}
|
||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={isActive}
|
aria-selected={isActive}
|
||||||
className={`px-4 py-2 text-sm transition-colors rounded-t ${
|
className={`px-4 py-2 text-sm transition-colors rounded-md ${
|
||||||
isActive
|
isActive
|
||||||
? 'font-bold border-b-2'
|
? 'font-bold shadow-[inset_0_-2px_0_var(--color-accent-green),0_10px_18px_-14px_rgba(0,0,0,0.8)] bg-white/[0.03]'
|
||||||
: 'font-normal hover:text-[var(--color-text-primary)]'
|
: 'font-normal hover:text-[var(--color-text-primary)]'
|
||||||
}`}
|
}`}
|
||||||
style={{
|
style={{
|
||||||
color: isActive ? 'var(--color-text-primary)' : 'var(--color-text-secondary)',
|
color: isActive ? 'var(--color-text-primary)' : 'var(--color-text-secondary)',
|
||||||
borderColor: isActive ? 'var(--color-accent-green)' : 'transparent',
|
|
||||||
}}
|
}}
|
||||||
data-testid={`tab-${tab.id}`}
|
data-testid={`tab-${tab.id}`}
|
||||||
>
|
>
|
||||||
|
|
@ -92,7 +92,7 @@ export function TopBar({ children }: TopBarProps) {
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: 'var(--color-bg-input)',
|
backgroundColor: 'var(--color-bg-input)',
|
||||||
color: 'var(--color-text-primary)',
|
color: 'var(--color-text-primary)',
|
||||||
border: '1px solid var(--color-border-soft)',
|
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.08), 0 8px 14px -12px rgba(0,0,0,0.85)',
|
||||||
}}
|
}}
|
||||||
data-testid="filter-input"
|
data-testid="filter-input"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
import type { ReactNode, MouseEventHandler } from 'react';
|
import type { MouseEventHandler } from 'react';
|
||||||
import { cn } from '../../lib/utils';
|
import { Activity, Clock3, GitBranch, Link2, MessageCircle, Orbit } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
import type { SocialCard as SocialCardData, AgentStatus } from '../../lib/social-cards';
|
import type { SocialCard as SocialCardData, AgentStatus } from '../../lib/social-cards';
|
||||||
import { AgentAvatar } from '../shared/agent-avatar';
|
import { AgentAvatar } from '../shared/agent-avatar';
|
||||||
|
|
||||||
|
|
@ -9,55 +13,74 @@ interface SocialCardProps {
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
onClick?: MouseEventHandler<HTMLDivElement>;
|
onClick?: MouseEventHandler<HTMLDivElement>;
|
||||||
onJumpToGraph?: (id: string) => void;
|
onJumpToGraph?: (id: string) => void;
|
||||||
onJumpToKanban?: (id: string) => void;
|
onJumpToActivity?: (id: string) => void;
|
||||||
|
onOpenThread?: () => void;
|
||||||
|
description?: string;
|
||||||
|
updatedLabel?: string;
|
||||||
|
dependencyCount?: number;
|
||||||
|
commentCount?: number;
|
||||||
|
unreadCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// "Hard Style" Dependency Item (from TaskCardGrid inspiration)
|
type StatusTone = {
|
||||||
function DependencyItem({ id, type }: { id: string; type: 'blocked-by' | 'blocking' }) {
|
accent: string;
|
||||||
const styles = type === 'blocked-by'
|
glow: string;
|
||||||
? 'border-rose-500/20 hover:border-rose-500/40 hover:bg-rose-500/10'
|
badgeClass: string;
|
||||||
: 'border-amber-500/20 hover:border-amber-500/40 hover:bg-amber-500/10';
|
surface: string;
|
||||||
|
accentChip: string;
|
||||||
|
};
|
||||||
|
|
||||||
const dotColor = type === 'blocked-by' ? 'bg-rose-500' : 'bg-amber-500';
|
const STATUS_TONES: Record<SocialCardData['status'], StatusTone> = {
|
||||||
|
ready: {
|
||||||
|
accent: '#7CB97A',
|
||||||
|
glow: 'rgba(124,185,122,0.26)',
|
||||||
|
badgeClass: 'bg-[#7CB97A]/26 text-[#DCEED8] shadow-[0_8px_16px_-12px_rgba(0,0,0,0.8)]',
|
||||||
|
surface:
|
||||||
|
'radial-gradient(circle at 80% 78%, rgba(124,185,122,0.46), transparent 76%), radial-gradient(circle at 8% 6%, rgba(124,185,122,0.26), transparent 68%), linear-gradient(145deg, rgba(45,78,45,0.99), rgba(35,62,35,0.99))',
|
||||||
|
accentChip: 'bg-[#7CB97A]/18 text-[#D2E4CE] shadow-[0_8px_16px_-12px_rgba(0,0,0,0.8)]',
|
||||||
|
},
|
||||||
|
in_progress: {
|
||||||
|
accent: '#D4A574',
|
||||||
|
glow: 'rgba(212,165,116,0.28)',
|
||||||
|
badgeClass: 'bg-[#D4A574]/28 text-[#EED9C1] shadow-[0_8px_16px_-12px_rgba(0,0,0,0.8)]',
|
||||||
|
surface:
|
||||||
|
'radial-gradient(circle at 80% 78%, rgba(212,165,116,0.48), transparent 76%), radial-gradient(circle at 8% 6%, rgba(212,165,116,0.28), transparent 68%), linear-gradient(145deg, rgba(86,64,40,0.99), rgba(68,49,30,0.99))',
|
||||||
|
accentChip: 'bg-[#D4A574]/20 text-[#E0C6A7] shadow-[0_8px_16px_-12px_rgba(0,0,0,0.8)]',
|
||||||
|
},
|
||||||
|
blocked: {
|
||||||
|
accent: '#C97A7A',
|
||||||
|
glow: 'rgba(201,122,122,0.26)',
|
||||||
|
badgeClass: 'bg-[#C97A7A]/28 text-[#EDD3D3] shadow-[0_8px_16px_-12px_rgba(0,0,0,0.8)]',
|
||||||
|
surface:
|
||||||
|
'radial-gradient(circle at 80% 78%, rgba(201,122,122,0.46), transparent 76%), radial-gradient(circle at 8% 6%, rgba(201,122,122,0.27), transparent 68%), linear-gradient(145deg, rgba(76,46,46,0.99), rgba(60,36,36,0.99))',
|
||||||
|
accentChip: 'bg-[#C97A7A]/18 text-[#E1C0C0] shadow-[0_8px_16px_-12px_rgba(0,0,0,0.8)]',
|
||||||
|
},
|
||||||
|
closed: {
|
||||||
|
accent: 'var(--status-closed)',
|
||||||
|
glow: 'rgba(136,136,136,0.16)',
|
||||||
|
badgeClass: 'bg-[#888888]/26 text-[#CECECE] shadow-[0_8px_16px_-12px_rgba(0,0,0,0.8)]',
|
||||||
|
surface:
|
||||||
|
'radial-gradient(circle at 80% 78%, rgba(136,136,136,0.32), transparent 76%), radial-gradient(circle at 8% 6%, rgba(136,136,136,0.16), transparent 68%), linear-gradient(145deg, rgba(56,56,56,0.99), rgba(44,44,44,0.99))',
|
||||||
|
accentChip: 'bg-[#888888]/16 text-[#BEBEBE] shadow-[0_8px_16px_-12px_rgba(0,0,0,0.8)]',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderDependencyPreview(ids: string[], toneClass: string, label: string) {
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn(
|
<div className="min-w-0 rounded-lg bg-black/20 px-2 py-1.5 shadow-[0_10px_18px_-14px_rgba(0,0,0,0.85)]">
|
||||||
"flex items-center gap-2 px-2.5 py-2 rounded-md border bg-white/5 transition-all duration-200 cursor-default",
|
<p className={cn('mb-1 text-[10px] font-semibold uppercase tracking-[0.12em]', toneClass)}>{label}</p>
|
||||||
styles
|
<div className="flex flex-wrap gap-1">
|
||||||
)}>
|
{ids.slice(0, 2).map((id) => (
|
||||||
<div className={cn("w-1.5 h-1.5 rounded-full shadow-[0_0_8px_currentColor]", dotColor)} />
|
<span key={id} className="rounded-md bg-white/10 px-1.5 py-0.5 font-mono text-[10px] text-[#DCDCDC] shadow-[0_8px_12px_-12px_rgba(0,0,0,0.88)]">
|
||||||
<span className="font-mono text-[10px] text-text-secondary">{id}</span>
|
{id}
|
||||||
</div>
|
</span>
|
||||||
);
|
))}
|
||||||
}
|
{ids.length > 2 ? <span className="text-[10px] text-[#8E8E8E]">+{ids.length - 2}</span> : null}
|
||||||
|
</div>
|
||||||
function ActionButton({ icon, label, onClick }: { icon: ReactNode; label: string; onClick?: () => void }) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => { e.stopPropagation(); onClick?.(); }}
|
|
||||||
className="group flex items-center justify-center p-2 rounded-full hover:bg-white/10 text-text-muted hover:text-white transition-all active:scale-95"
|
|
||||||
title={label}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusIndicator({ status }: { status: string }) {
|
|
||||||
const color = {
|
|
||||||
ready: 'bg-teal-400 shadow-[0_0_10px_rgba(45,212,191,0.5)]',
|
|
||||||
in_progress: 'bg-emerald-400 shadow-[0_0_10px_rgba(52,211,153,0.5)]',
|
|
||||||
blocked: 'bg-rose-500 shadow-[0_0_10px_rgba(244,63,94,0.5)]',
|
|
||||||
closed: 'bg-slate-500',
|
|
||||||
}[status] || 'bg-slate-500';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className={cn("w-2 h-2 rounded-full animate-pulse", color)} />
|
|
||||||
<span className="text-[10px] font-bold uppercase tracking-widest text-text-muted/80">
|
|
||||||
{status.replace('_', ' ')}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -68,10 +91,15 @@ export function SocialCard({
|
||||||
selected = false,
|
selected = false,
|
||||||
onClick,
|
onClick,
|
||||||
onJumpToGraph,
|
onJumpToGraph,
|
||||||
onJumpToKanban,
|
onJumpToActivity,
|
||||||
|
onOpenThread,
|
||||||
|
description,
|
||||||
|
updatedLabel = 'just now',
|
||||||
|
dependencyCount,
|
||||||
|
commentCount,
|
||||||
|
unreadCount = 0,
|
||||||
}: SocialCardProps) {
|
}: SocialCardProps) {
|
||||||
const hasBlocks = data.blocks.length > 0;
|
const tone = STATUS_TONES[data.status];
|
||||||
const hasUnblocks = data.unblocks.length > 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -79,76 +107,76 @@ export function SocialCard({
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group relative flex flex-col p-6 gap-4 transition-all duration-300 ease-out",
|
'group relative flex h-full min-h-[18rem] cursor-pointer flex-col rounded-2xl px-4 py-4 text-left transition-all duration-200 ease-out',
|
||||||
"rounded-[2rem]", // Elegant roundness
|
'hover:-translate-y-0.5',
|
||||||
"bg-[#252525]/90 backdrop-blur-xl", // Glassy dark
|
selected && 'translate-y-[-2px]',
|
||||||
"border border-white/5 hover:border-white/10",
|
className,
|
||||||
"shadow-lg hover:shadow-2xl hover:-translate-y-1",
|
|
||||||
selected ? "ring-2 ring-amber-500/50 shadow-amber-900/20" : "",
|
|
||||||
className
|
|
||||||
)}
|
)}
|
||||||
|
style={{
|
||||||
|
background: tone.surface,
|
||||||
|
boxShadow: selected
|
||||||
|
? `0 24px 50px -18px ${tone.glow}, 0 10px 24px rgba(0,0,0,0.42), inset 0 1px 0 rgba(255,255,255,0.12)`
|
||||||
|
: `0 12px 24px -20px ${tone.glow}, 0 6px 14px rgba(0,0,0,0.38), inset 0 1px 0 rgba(255,255,255,0.06)`,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* Header: Status & ID */}
|
<div className="absolute inset-x-0 top-0 h-[4px]" style={{ backgroundColor: tone.accent }} />
|
||||||
<div className="flex items-center justify-between">
|
<div
|
||||||
<div className="flex items-center gap-3">
|
className="pointer-events-none absolute right-3 top-3 h-10 w-10 rounded-full blur-xl"
|
||||||
<span className="font-mono text-xs font-bold text-teal-500/90 tracking-tight">
|
style={{ backgroundColor: tone.glow }}
|
||||||
{data.id}
|
/>
|
||||||
</span>
|
<div className="mb-3 flex items-center justify-between gap-3">
|
||||||
<div className="h-3 w-[1px] bg-white/10" />
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<StatusIndicator status={data.status} />
|
<span className="truncate font-mono text-[11px] text-[#A8D0CB]">{data.id}</span>
|
||||||
|
{unreadCount > 0 ? (
|
||||||
|
<span className="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-[#E24A3A] px-1 text-[10px] font-semibold text-white">
|
||||||
|
{unreadCount}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-text-muted/40 font-mono">
|
<div className="flex items-center gap-2">
|
||||||
{new Date(data.lastActivity).toLocaleDateString()}
|
<Badge className={cn('rounded-full px-2 py-0.5 text-[10px] font-semibold', tone.badgeClass)}>
|
||||||
|
{data.status.replace('_', ' ')}
|
||||||
|
</Badge>
|
||||||
|
<Badge className="rounded-full bg-black/25 px-2 py-0.5 font-mono text-[10px] text-[#D0D0D0] shadow-[0_8px_16px_-12px_rgba(0,0,0,0.8)]">
|
||||||
|
{data.priority}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hero: Title */}
|
<h3 className="line-clamp-2 text-[1.7rem] font-semibold leading-[1.1] tracking-[-0.02em] text-white">
|
||||||
<h3 className="text-xl font-bold text-white leading-snug tracking-tight group-hover:text-amber-50 transition-colors">
|
|
||||||
{data.title}
|
{data.title}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{/* Content: Dependencies (Hard Style List) */}
|
<p className="mt-2 line-clamp-2 min-h-[2.6rem] text-sm leading-relaxed text-[#B8B8B8]">
|
||||||
{(hasBlocks || hasUnblocks) && (
|
{description || 'No summary provided yet.'}
|
||||||
<div className="flex flex-col gap-3 mt-2">
|
</p>
|
||||||
{/* Blocked By */}
|
|
||||||
{hasUnblocks && (
|
|
||||||
<div className="flex flex-col gap-1.5 p-2 rounded-xl bg-black/20 border border-white/5">
|
|
||||||
<span className="text-[9px] font-bold uppercase tracking-widest text-rose-400/70 pl-1">Blocked By</span>
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
{data.unblocks.slice(0, 3).map((id) => (
|
|
||||||
<DependencyItem key={id} id={id} type="blocked-by" />
|
|
||||||
))}
|
|
||||||
{data.unblocks.length > 3 && (
|
|
||||||
<div className="px-2 text-[10px] text-rose-400/50 italic">+{data.unblocks.length - 3} more</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Blocking */}
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
{hasBlocks && (
|
<span className="rounded-full bg-[#D4A574]/28 px-2 py-0.5 text-[10px] font-semibold text-[#F5DFC2] shadow-[0_8px_16px_-12px_rgba(0,0,0,0.82)]">
|
||||||
<div className="flex flex-col gap-1.5 p-2 rounded-xl bg-black/20 border border-white/5">
|
{data.blocks.length} blocking
|
||||||
<span className="text-[9px] font-bold uppercase tracking-widest text-amber-400/70 pl-1">Blocking</span>
|
</span>
|
||||||
<div className="flex flex-col gap-1.5">
|
<span className="rounded-full bg-[#E57373]/24 px-2 py-0.5 text-[10px] font-semibold text-[#F3C2C2] shadow-[0_8px_16px_-12px_rgba(0,0,0,0.82)]">
|
||||||
{data.blocks.slice(0, 3).map((id) => (
|
{data.unblocks.length} blocked by
|
||||||
<DependencyItem key={id} id={id} type="blocking" />
|
</span>
|
||||||
))}
|
</div>
|
||||||
{data.blocks.length > 3 && (
|
|
||||||
<div className="px-2 text-[10px] text-amber-400/50 italic">+{data.blocks.length - 3} more</div>
|
<div className="mt-3 grid gap-2 sm:grid-cols-2">
|
||||||
)}
|
{renderDependencyPreview(data.unblocks, 'text-[#D4A574]', 'Blocked By')}
|
||||||
</div>
|
{renderDependencyPreview(data.blocks, 'text-[#5BA8A0]', 'Unblocks')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
<div className="mt-auto flex items-end justify-between gap-3 pt-4">
|
||||||
|
<div className="space-y-1.5 text-xs text-[#9A9A9A]">
|
||||||
|
<p className="inline-flex items-center gap-1.5"><Clock3 className="h-3.5 w-3.5" />{updatedLabel}</p>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<p className="inline-flex items-center gap-1"><Link2 className="h-3.5 w-3.5" />{dependencyCount ?? data.blocks.length + data.unblocks.length}</p>
|
||||||
|
<p className="inline-flex items-center gap-1"><MessageCircle className="h-3.5 w-3.5" />{commentCount ?? 0}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Footer: Social Actions & Crew */}
|
<div className="flex items-center -space-x-2">
|
||||||
<div className="mt-auto pt-4 flex items-center justify-between border-t border-white/5">
|
|
||||||
|
|
||||||
{/* Crew (Left) */}
|
|
||||||
<div className="flex items-center -space-x-3 pl-2">
|
|
||||||
{data.agents.slice(0, 4).map((agent) => (
|
{data.agents.slice(0, 4).map((agent) => (
|
||||||
<div key={agent.name} className="relative transition-transform hover:scale-110 hover:z-10 ring-2 ring-[#252525] rounded-full">
|
<div key={`${data.id}-${agent.name}`} className="rounded-full ring-2 ring-[#2C2C2C]">
|
||||||
<AgentAvatar
|
<AgentAvatar
|
||||||
name={agent.name}
|
name={agent.name}
|
||||||
status={agent.status as AgentStatus}
|
status={agent.status as AgentStatus}
|
||||||
|
|
@ -157,29 +185,44 @@ export function SocialCard({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{data.agents.length === 0 && (
|
{data.agents.length === 0 ? <span className="text-xs text-[#808080]">No crew</span> : null}
|
||||||
<span className="text-xs text-text-muted/30 font-medium">No Crew</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Action Dock (Right) */}
|
<div className="mt-3 flex items-center justify-end gap-1 pt-2 shadow-[inset_0_10px_12px_-14px_rgba(0,0,0,0.88)]">
|
||||||
<div className="flex items-center gap-1 bg-black/20 rounded-full px-2 py-1 border border-white/5">
|
<button
|
||||||
<ActionButton
|
type="button"
|
||||||
icon={
|
onClick={(event) => {
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="18" cy="5" r="3"></circle><circle cx="6" cy="12" r="3"></circle><circle cx="18" cy="19" r="3"></circle><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"></line><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"></line></svg>
|
event.stopPropagation();
|
||||||
}
|
onJumpToGraph?.(data.id);
|
||||||
label="Graph"
|
}}
|
||||||
onClick={() => onJumpToGraph?.(data.id)}
|
className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-[#5BA8A0]/24 text-[#AFE2DC] shadow-[0_10px_16px_-12px_rgba(0,0,0,0.8)] transition-colors hover:bg-[#5BA8A0]/36"
|
||||||
/>
|
title="Jump to graph view"
|
||||||
<div className="w-[1px] h-4 bg-white/10" />
|
>
|
||||||
<ActionButton
|
<GitBranch className="h-3.5 w-3.5" />
|
||||||
icon={
|
</button>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><line x1="9" y1="3" x2="9" y2="21"></line></svg>
|
<button
|
||||||
}
|
type="button"
|
||||||
label="Kanban"
|
onClick={(event) => {
|
||||||
onClick={() => onJumpToKanban?.(data.id)}
|
event.stopPropagation();
|
||||||
/>
|
onJumpToActivity?.(data.id);
|
||||||
</div>
|
}}
|
||||||
|
className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-[#D4A574]/24 text-[#E8D0B3] shadow-[0_10px_16px_-12px_rgba(0,0,0,0.8)] transition-colors hover:bg-[#D4A574]/36"
|
||||||
|
title="Jump to activity view"
|
||||||
|
>
|
||||||
|
<Orbit className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onOpenThread?.();
|
||||||
|
}}
|
||||||
|
className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-[#7CB97A]/24 text-[#D2EACF] shadow-[0_10px_16px_-12px_rgba(0,0,0,0.8)] transition-colors hover:bg-[#7CB97A]/36"
|
||||||
|
title="Open thread"
|
||||||
|
>
|
||||||
|
<Activity className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
import { Clock3, Layers2, Sparkles, TriangleAlert } from 'lucide-react';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
import type { BeadIssue } from '../../lib/types';
|
import type { BeadIssue } from '../../lib/types';
|
||||||
import type { ProjectScopeOption } from '../../lib/project-scope';
|
import type { ProjectScopeOption } from '../../lib/project-scope';
|
||||||
import { buildSocialCards } from '../../lib/social-cards';
|
import { buildSocialCards } from '../../lib/social-cards';
|
||||||
import { SocialCard } from './social-card';
|
import { SocialCard } from './social-card';
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
interface SocialPageProps {
|
interface SocialPageProps {
|
||||||
issues: BeadIssue[];
|
issues: BeadIssue[];
|
||||||
|
|
@ -14,126 +16,168 @@ interface SocialPageProps {
|
||||||
projectScopeOptions?: ProjectScopeOption[];
|
projectScopeOptions?: ProjectScopeOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatRelative(timestamp: string): string {
|
||||||
|
const then = new Date(timestamp);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMins = Math.floor((now.getTime() - then.getTime()) / 60000);
|
||||||
|
|
||||||
|
if (diffMins < 1) return 'just now';
|
||||||
|
if (diffMins < 60) return `${diffMins}m ago`;
|
||||||
|
|
||||||
|
const diffHours = Math.floor(diffMins / 60);
|
||||||
|
if (diffHours < 24) return `${diffHours}h ago`;
|
||||||
|
|
||||||
|
const diffDays = Math.floor(diffHours / 24);
|
||||||
|
return `${diffDays}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_SCORE: Record<string, number> = {
|
||||||
|
blocked: 5,
|
||||||
|
in_progress: 4,
|
||||||
|
ready: 3,
|
||||||
|
open: 3,
|
||||||
|
deferred: 2,
|
||||||
|
closed: 1,
|
||||||
|
};
|
||||||
|
|
||||||
export function SocialPage({ issues, selectedId, onSelect, projectScopeOptions = [] }: SocialPageProps) {
|
export function SocialPage({ issues, selectedId, onSelect, projectScopeOptions = [] }: SocialPageProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const cards = useMemo(() => buildSocialCards(issues), [issues]);
|
const cards = useMemo(() => buildSocialCards(issues), [issues]);
|
||||||
|
|
||||||
const selectedTask = useMemo(() =>
|
const navigateWithParams = (updates: Record<string, string | null>) => {
|
||||||
cards.find(c => c.id === selectedId) || null,
|
const next = new URLSearchParams(searchParams.toString());
|
||||||
[cards, selectedId]);
|
for (const [key, value] of Object.entries(updates)) {
|
||||||
|
if (!value) {
|
||||||
|
next.delete(key);
|
||||||
|
} else {
|
||||||
|
next.set(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const query = next.toString();
|
||||||
|
router.push(query ? `/?${query}` : '/', { scroll: false });
|
||||||
|
};
|
||||||
|
|
||||||
const otherCards = useMemo(() =>
|
const issueById = useMemo(() => {
|
||||||
cards.filter(c => c.id !== selectedId),
|
const map = new Map<string, BeadIssue>();
|
||||||
[cards, selectedId]);
|
for (const issue of issues) {
|
||||||
|
map.set(issue.id, issue);
|
||||||
// Dashboard Metrics
|
}
|
||||||
const metrics = useMemo(() => {
|
return map;
|
||||||
return {
|
|
||||||
blocked: issues.filter(i => i.status === 'blocked'),
|
|
||||||
p0: issues.filter(i => i.priority === 0 && i.status !== 'closed'),
|
|
||||||
active: issues.filter(i => i.status === 'in_progress'),
|
|
||||||
ready: issues.filter(i => i.status === 'open' || i.status === 'ready'),
|
|
||||||
};
|
|
||||||
}, [issues]);
|
}, [issues]);
|
||||||
|
|
||||||
|
const orderedCards = useMemo(() => {
|
||||||
|
return [...cards].sort((a, b) => {
|
||||||
|
const scoreDiff = (STATUS_SCORE[b.status] ?? 0) - (STATUS_SCORE[a.status] ?? 0);
|
||||||
|
if (scoreDiff !== 0) {
|
||||||
|
return scoreDiff;
|
||||||
|
}
|
||||||
|
return b.lastActivity.getTime() - a.lastActivity.getTime();
|
||||||
|
});
|
||||||
|
}, [cards]);
|
||||||
|
|
||||||
|
const selectedCard = useMemo(
|
||||||
|
() => orderedCards.find((card) => card.id === selectedId) ?? null,
|
||||||
|
[orderedCards, selectedId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedIssue = selectedCard ? issueById.get(selectedCard.id) ?? null : null;
|
||||||
|
|
||||||
|
const metrics = useMemo(() => {
|
||||||
|
const blocked = cards.filter((card) => card.status === 'blocked').length;
|
||||||
|
const active = cards.filter((card) => card.status === 'in_progress').length;
|
||||||
|
const ready = cards.filter((card) => card.status === 'ready').length;
|
||||||
|
const urgent = cards.filter((card) => card.priority === 'P0').length;
|
||||||
|
|
||||||
|
return { blocked, active, ready, urgent };
|
||||||
|
}, [cards]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col bg-[#1a1a1a] overflow-hidden">
|
<div className="relative h-full overflow-y-auto bg-[#2D2D2D] custom-scrollbar">
|
||||||
<div className="absolute inset-0 bg-earthy-gradient opacity-20 pointer-events-none" />
|
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_10%_12%,rgba(90,70,50,0.42),transparent_34%),radial-gradient(circle_at_88%_82%,rgba(35,72,77,0.34),transparent_36%)]" />
|
||||||
|
<div className="relative mx-auto flex max-w-[1450px] flex-col gap-4 p-5">
|
||||||
<div className="flex-1 overflow-y-auto custom-scrollbar relative z-10">
|
<section className="rounded-2xl bg-[linear-gradient(160deg,rgba(57,57,66,0.95),rgba(46,49,60,0.95))] p-4 shadow-[0_24px_40px_-26px_rgba(0,0,0,0.82),inset_0_1px_0_rgba(255,255,255,0.05)]">
|
||||||
<div className="max-w-[1400px] mx-auto p-8 space-y-12">
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<div>
|
||||||
{/* STAGE AREA */}
|
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-[#8B8B8B]">Social Stream</p>
|
||||||
<section className="relative min-h-[350px] flex flex-col justify-center">
|
<h2 className="mt-1 text-3xl font-semibold tracking-tight text-white">Task Activity Command Feed</h2>
|
||||||
{selectedTask ? (
|
<p className="mt-1 text-sm text-[#B8B8B8]">Two-column live task stream with inline thread context.</p>
|
||||||
// FOCUSED TASK MODE
|
|
||||||
<div className="animate-in fade-in zoom-in-95 duration-500 ease-out">
|
|
||||||
<div className="mb-6 flex items-center gap-4 opacity-60">
|
|
||||||
<div className="h-[1px] flex-1 bg-gradient-to-r from-transparent via-teal-500/30 to-transparent" />
|
|
||||||
<span className="text-[10px] font-bold uppercase tracking-[0.3em] text-teal-400">Active Module</span>
|
|
||||||
<div className="h-[1px] flex-1 bg-gradient-to-r from-transparent via-teal-500/30 to-transparent" />
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-center">
|
|
||||||
<SocialCard
|
|
||||||
data={selectedTask}
|
|
||||||
selected={true}
|
|
||||||
className="w-full max-w-3xl scale-105 shadow-soft-2xl ring-1 ring-teal-500/20"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
// DASHBOARD MODE (System Overview)
|
|
||||||
<div className="w-full grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
||||||
{/* 1. Projects Overview */}
|
|
||||||
<div className="md:col-span-2 p-8 rounded-[2rem] bg-white/[0.03] border border-white/5 backdrop-blur-md relative overflow-hidden group hover:border-white/10 transition-colors">
|
|
||||||
<div className="flex items-start justify-between mb-6">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xl font-bold text-white tracking-tight">System Overview</h2>
|
|
||||||
<p className="text-sm text-text-muted/60 mt-1">Multi-project command scope</p>
|
|
||||||
</div>
|
|
||||||
<div className="px-3 py-1 rounded-full bg-white/5 text-xs font-mono text-text-muted border border-white/5">
|
|
||||||
v2.0
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-3">
|
|
||||||
{projectScopeOptions.slice(0, 5).map(p => (
|
|
||||||
<div key={p.key} className="flex items-center gap-2 px-4 py-2 rounded-xl bg-black/20 border border-white/5 hover:border-teal-500/30 transition-colors cursor-pointer group/pill">
|
|
||||||
<div className="w-2 h-2 rounded-full bg-teal-500/50 group-hover/pill:bg-teal-400" />
|
|
||||||
<span className="text-sm font-medium text-text-secondary group-hover/pill:text-white">{p.name}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 2. Critical Alerts */}
|
|
||||||
<div className="p-8 rounded-[2rem] bg-gradient-to-br from-rose-500/10 to-transparent border border-rose-500/20 backdrop-blur-md flex flex-col relative overflow-hidden">
|
|
||||||
<div className="absolute top-0 right-0 p-4 opacity-20">
|
|
||||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="currentColor" className="text-rose-500"><path d="M12 2L1 21h22M12 6l7.53 13H4.47M11 10v4h2v-4m-2 6v2h2v-2"/></svg>
|
|
||||||
</div>
|
|
||||||
<h3 className="text-sm font-bold uppercase tracking-widest text-rose-400 mb-4">Critical Attention</h3>
|
|
||||||
<div className="space-y-3 flex-1">
|
|
||||||
{metrics.blocked.length > 0 ? (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-4xl font-bold text-white">{metrics.blocked.length}</span>
|
|
||||||
<span className="text-sm text-rose-200/80 leading-tight">Blocked<br/>Modules</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-emerald-400 font-medium">All systems nominal</div>
|
|
||||||
)}
|
|
||||||
{metrics.p0.length > 0 && (
|
|
||||||
<div className="text-xs text-rose-300/60 font-mono mt-2">
|
|
||||||
{metrics.p0.length} P0 Priority Items
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* LIBRARY */}
|
|
||||||
<section className="space-y-6">
|
|
||||||
<div className="flex items-center justify-between px-4 pb-2 border-b border-white/5">
|
|
||||||
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-text-muted/60 py-2">Module Stream</h3>
|
|
||||||
<div className="flex gap-4 text-[10px] font-mono text-text-muted/40">
|
|
||||||
<span>{metrics.active.length} ACTIVE</span>
|
|
||||||
<span>{metrics.ready.length} READY</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<div className="rounded-full bg-[#404856] px-3 py-1 text-[#D8D8D8] shadow-[0_10px_18px_-14px_rgba(0,0,0,0.8)]">{projectScopeOptions.length} scopes</div>
|
||||||
|
<div className="rounded-full bg-[#404856] px-3 py-1 text-[#D8D8D8] shadow-[0_10px_18px_-14px_rgba(0,0,0,0.8)]">{cards.length} tasks</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 grid gap-2 sm:grid-cols-4">
|
||||||
|
<div className="rounded-xl bg-[#7CB97A]/24 px-3 py-2 text-xs font-semibold text-[#DDF0DA] shadow-[0_12px_20px_-16px_rgba(0,0,0,0.82)]">{metrics.ready} ready</div>
|
||||||
|
<div className="rounded-xl bg-[#D4A574]/24 px-3 py-2 text-xs font-semibold text-[#F0DEC8] shadow-[0_12px_20px_-16px_rgba(0,0,0,0.82)]">{metrics.active} in progress</div>
|
||||||
|
<div className="rounded-xl bg-[#C97A7A]/24 px-3 py-2 text-xs font-semibold text-[#F3D2D2] shadow-[0_12px_20px_-16px_rgba(0,0,0,0.82)]">{metrics.blocked} blocked</div>
|
||||||
|
<div className="rounded-xl bg-[#E24A3A]/24 px-3 py-2 text-xs font-semibold text-[#F7CBC6] shadow-[0_12px_20px_-16px_rgba(0,0,0,0.82)]">{metrics.urgent} P0</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-6 pb-20">
|
{selectedCard && selectedIssue ? (
|
||||||
{otherCards.map((card) => (
|
<section className="rounded-2xl bg-[radial-gradient(circle_at_100%_50%,rgba(91,168,160,0.2),transparent_45%),rgba(54,57,66,0.94)] p-3 shadow-[0_16px_30px_-18px_rgba(0,0,0,0.72),inset_0_1px_0_rgba(255,255,255,0.04)]">
|
||||||
<SocialCard
|
<div className="mb-2 flex items-center justify-between gap-3">
|
||||||
key={card.id}
|
<div className="flex items-center gap-2 text-[#DDEDEC]">
|
||||||
data={card}
|
<Sparkles className="h-4 w-4 text-[#5BA8A0]" />
|
||||||
selected={false}
|
<p className="text-sm font-semibold">Focused thread context</p>
|
||||||
onClick={() => onSelect(card.id)}
|
</div>
|
||||||
className="hover:translate-y-[-4px] transition-transform duration-300"
|
<p className="text-xs text-[#8B8B8B]">{selectedCard.id}</p>
|
||||||
/>
|
</div>
|
||||||
))}
|
<div className="grid gap-3 md:grid-cols-[1fr_auto_auto_auto]">
|
||||||
|
<p className="line-clamp-2 text-sm text-[#D8D8D8]">{selectedIssue.description ?? selectedIssue.title}</p>
|
||||||
|
<p className="inline-flex items-center gap-1 text-xs text-[#9E9E9E]"><Clock3 className="h-3.5 w-3.5" />{formatRelative(selectedIssue.updated_at)}</p>
|
||||||
|
<p className="inline-flex items-center gap-1 text-xs text-[#9E9E9E]"><Layers2 className="h-3.5 w-3.5" />{selectedIssue.dependencies.length} deps</p>
|
||||||
|
{selectedIssue.status === 'blocked' ? (
|
||||||
|
<p className="inline-flex items-center gap-1 text-xs text-[#E1BC8F]"><TriangleAlert className="h-3.5 w-3.5" />Needs unblock</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-[#7CB97A]">Healthy flow</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
) : null}
|
||||||
|
|
||||||
|
<section className="grid grid-cols-1 gap-4 pb-6 xl:grid-cols-2">
|
||||||
|
{orderedCards.map((card) => {
|
||||||
|
const issue = issueById.get(card.id);
|
||||||
|
const commentCount = typeof issue?.metadata?.commentCount === 'number' ? issue.metadata.commentCount : 0;
|
||||||
|
const unreadCount = typeof issue?.metadata?.unreadCount === 'number' ? issue.metadata.unreadCount : 0;
|
||||||
|
const description = issue?.description ?? undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SocialCard
|
||||||
|
key={card.id}
|
||||||
|
data={card}
|
||||||
|
selected={selectedId === card.id}
|
||||||
|
onClick={() => onSelect(card.id)}
|
||||||
|
onJumpToGraph={(id) => {
|
||||||
|
navigateWithParams({
|
||||||
|
view: 'graph',
|
||||||
|
task: id,
|
||||||
|
swarm: null,
|
||||||
|
panel: 'open',
|
||||||
|
drawer: 'closed',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onJumpToActivity={(id) => {
|
||||||
|
navigateWithParams({
|
||||||
|
view: 'activity',
|
||||||
|
task: id,
|
||||||
|
panel: 'open',
|
||||||
|
drawer: 'closed',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onOpenThread={() => onSelect(card.id)}
|
||||||
|
description={description ?? undefined}
|
||||||
|
updatedLabel={issue ? formatRelative(issue.updated_at) : 'just now'}
|
||||||
|
dependencyCount={issue?.dependencies.length ?? card.blocks.length + card.unblocks.length}
|
||||||
|
commentCount={commentCount}
|
||||||
|
unreadCount={unreadCount}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue