chore: checkpoint related UI improvements and supporting components
Various supporting changes made during the assign archetypes feature development: - Added contextual-right-panel.tsx and swarm-command-feed.tsx - Updated activity-panel.tsx with new features - UI improvements to left-panel, mobile-nav - Test updates for url-state-integration, mobile-nav, top-bar - Package.json updates for dependencies - Global CSS refinements These changes support the main assign archetypes feature but are not directly part of its core functionality.
This commit is contained in:
parent
30f5f67216
commit
fbfe393f6d
14 changed files with 280 additions and 85 deletions
|
|
@ -9,7 +9,7 @@
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "node --test tests/bootstrap.test.mjs && node --import tsx --test tests/components/shared/base-card.test.tsx && node --import tsx --test tests/components/shared/agent-avatar.test.tsx && node --import tsx --test tests/components/sessions/sessions-header.test.ts && node --import tsx --test tests/components/sessions/agent-station-logic.test.ts && node --import tsx --test tests/lib/parser.test.ts && node --import tsx --test tests/lib/pathing.test.ts && node --import tsx --test tests/components/shared/left-panel.test.tsx && node --import tsx --test tests/components/shared/top-bar.test.tsx && node --import tsx --test tests/components/shared/mobile-nav.test.tsx && node --import tsx --test tests/components/swarm/swarm-card.test.tsx && node --import tsx --test tests/hooks/url-state-integration.test.ts",
|
"test": "node --test tests/bootstrap.test.mjs && node --import tsx --test tests/components/shared/base-card.test.tsx && node --import tsx --test tests/components/shared/agent-avatar.test.tsx && node --import tsx --test tests/components/sessions/sessions-header.test.ts && node --import tsx --test tests/components/sessions/agent-station-logic.test.ts && node --import tsx --test tests/lib/parser.test.ts && node --import tsx --test tests/lib/pathing.test.ts && node --import tsx --test tests/components/shared/left-panel.test.tsx && node --import tsx --test tests/components/shared/top-bar.test.tsx && node --import tsx --test tests/components/shared/mobile-nav.test.tsx && node --import tsx --test tests/components/swarm/swarm-card.test.tsx && node --import tsx --test tests/hooks/url-state-integration.test.ts && node --import tsx --test tests/hooks/use-graph-analysis.test.ts && node --import tsx --test tests/components/graph/smart-dag.test.tsx && node --import tsx --test tests/components/unified-shell.test.tsx && node --import tsx --test tests/components/graph/graph-node-labels.test.tsx && node --import tsx --test tests/components/graph/graph-node-assign.test.tsx",
|
||||||
"video": "remotion preview src/video/index.ts",
|
"video": "remotion preview src/video/index.ts",
|
||||||
"video:render": "remotion render src/video/index.ts Main out/video.mp4",
|
"video:render": "remotion render src/video/index.ts Main out/video.mp4",
|
||||||
"video:thumbnail": "remotion still src/video/index.ts Main out/thumbnail.png --frame=60"
|
"video:thumbnail": "remotion still src/video/index.ts Main out/thumbnail.png --frame=60"
|
||||||
|
|
|
||||||
|
|
@ -84,8 +84,10 @@
|
||||||
|
|
||||||
/* ========== RADI ========== */
|
/* ========== RADI ========== */
|
||||||
--radius-sm: 0.375rem;
|
--radius-sm: 0.375rem;
|
||||||
--radius-card: 1.5rem; /* rounded-3xl for soft feel */
|
--radius-card: 1.5rem;
|
||||||
--radius-xl: 1.5rem; /* rounded-3xl */
|
/* rounded-3xl for soft feel */
|
||||||
|
--radius-xl: 1.5rem;
|
||||||
|
/* rounded-3xl */
|
||||||
--radius-modal: 1rem;
|
--radius-modal: 1rem;
|
||||||
--radius-pill: 9999px;
|
--radius-pill: 9999px;
|
||||||
|
|
||||||
|
|
@ -98,7 +100,7 @@
|
||||||
/* ========== TYPOGRAPHY ========== */
|
/* ========== TYPOGRAPHY ========== */
|
||||||
--font-ui-stack: var(--ui-font-sans);
|
--font-ui-stack: var(--ui-font-sans);
|
||||||
--font-mono-stack: var(--ui-font-mono);
|
--font-mono-stack: var(--ui-font-mono);
|
||||||
|
|
||||||
--font-size-h1: 2rem;
|
--font-size-h1: 2rem;
|
||||||
--font-size-h2: 1.5rem;
|
--font-size-h2: 1.5rem;
|
||||||
--font_size-h3: 1.125rem;
|
--font_size-h3: 1.125rem;
|
||||||
|
|
@ -131,11 +133,9 @@
|
||||||
--sidebar-right-width: 17.5rem;
|
--sidebar-right-width: 17.5rem;
|
||||||
--topbar-height: 3.75rem;
|
--topbar-height: 3.75rem;
|
||||||
|
|
||||||
--glass-base: linear-gradient(
|
--glass-base: linear-gradient(180deg,
|
||||||
180deg,
|
color-mix(in srgb, var(--ui-bg-card) 72%, black),
|
||||||
color-mix(in srgb, var(--ui-bg-card) 72%, black),
|
color-mix(in srgb, var(--ui-bg-panel) 78%, black));
|
||||||
color-mix(in srgb, var(--ui-bg-panel) 78%, black)
|
|
||||||
);
|
|
||||||
--edge-top: color-mix(in srgb, var(--ui-border-strong) 80%, white 20%);
|
--edge-top: color-mix(in srgb, var(--ui-border-strong) 80%, white 20%);
|
||||||
--edge-bottom: color-mix(in srgb, var(--ui-border-soft) 75%, black 25%);
|
--edge-bottom: color-mix(in srgb, var(--ui-border-soft) 75%, black 25%);
|
||||||
--elevation-ambient: 0 20px 40px -16px rgba(0, 0, 0, 0.78);
|
--elevation-ambient: 0 20px 40px -16px rgba(0, 0, 0, 0.78);
|
||||||
|
|
@ -387,8 +387,9 @@ body {
|
||||||
transform: translateX(100%);
|
transform: translateX(100%);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
to {
|
to {
|
||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -16,7 +16,7 @@ type AgentTone = {
|
||||||
glowClass: string;
|
glowClass: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type EventTone = {
|
export type EventTone = {
|
||||||
label: string;
|
label: string;
|
||||||
labelClass: string;
|
labelClass: string;
|
||||||
dotClass: string;
|
dotClass: string;
|
||||||
|
|
@ -42,11 +42,11 @@ const AGENT_LABEL = 'gt:agent';
|
||||||
// Determine agent status based on last activity
|
// Determine agent status based on last activity
|
||||||
function deriveAgentStatus(lastSeenAt: string | null): AgentStatus {
|
function deriveAgentStatus(lastSeenAt: string | null): AgentStatus {
|
||||||
if (!lastSeenAt) return 'dead';
|
if (!lastSeenAt) return 'dead';
|
||||||
|
|
||||||
const lastSeen = new Date(lastSeenAt);
|
const lastSeen = new Date(lastSeenAt);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const minutesSince = (now.getTime() - lastSeen.getTime()) / (1000 * 60);
|
const minutesSince = (now.getTime() - lastSeen.getTime()) / (1000 * 60);
|
||||||
|
|
||||||
if (minutesSince < 15) return 'active';
|
if (minutesSince < 15) return 'active';
|
||||||
if (minutesSince < 30) return 'stale';
|
if (minutesSince < 30) return 'stale';
|
||||||
if (minutesSince < 60) return 'stuck';
|
if (minutesSince < 60) return 'stuck';
|
||||||
|
|
@ -57,25 +57,25 @@ function deriveAgentStatus(lastSeenAt: string | null): AgentStatus {
|
||||||
function extractAgentName(issue: BeadIssue): string | null {
|
function extractAgentName(issue: BeadIssue): string | null {
|
||||||
const agentMatch = issue.title.match(/Agent:\s*(\S+)/i);
|
const agentMatch = issue.title.match(/Agent:\s*(\S+)/i);
|
||||||
if (agentMatch) return agentMatch[1];
|
if (agentMatch) return agentMatch[1];
|
||||||
|
|
||||||
const agentLabel = issue.labels.find(l => l.startsWith('agent:'));
|
const agentLabel = issue.labels.find(l => l.startsWith('agent:'));
|
||||||
if (agentLabel) return agentLabel.replace('agent:', '');
|
if (agentLabel) return agentLabel.replace('agent:', '');
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build agent roster - filter out dead agents unless none are active
|
// Build agent roster - filter out dead agents unless none are active
|
||||||
function buildAgentRoster(issues: BeadIssue[]): AgentRosterEntry[] {
|
function buildAgentRoster(issues: BeadIssue[]): AgentRosterEntry[] {
|
||||||
const agentIssues = issues.filter(issue =>
|
const agentIssues = issues.filter(issue =>
|
||||||
issue.labels.includes(AGENT_LABEL) ||
|
issue.labels.includes(AGENT_LABEL) ||
|
||||||
issue.labels.some(l => l.startsWith('gt:agent')) ||
|
issue.labels.some(l => l.startsWith('gt:agent')) ||
|
||||||
issue.labels.includes('agent')
|
issue.labels.includes('agent')
|
||||||
);
|
);
|
||||||
|
|
||||||
const roster = agentIssues.map(issue => {
|
const roster = agentIssues.map(issue => {
|
||||||
const name = extractAgentName(issue) || issue.title.replace('Agent: ', '') || issue.id;
|
const name = extractAgentName(issue) || issue.title.replace('Agent: ', '') || issue.id;
|
||||||
const status = deriveAgentStatus(issue.updated_at);
|
const status = deriveAgentStatus(issue.updated_at);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
status,
|
status,
|
||||||
|
|
@ -92,19 +92,19 @@ function buildAgentRoster(issues: BeadIssue[]): AgentRosterEntry[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format relative time
|
// Format relative time
|
||||||
function formatRelativeTime(timestamp: string): string {
|
export function formatRelativeTime(timestamp: string): string {
|
||||||
const date = new Date(timestamp);
|
const date = new Date(timestamp);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const diffMs = now.getTime() - date.getTime();
|
const diffMs = now.getTime() - date.getTime();
|
||||||
const diffMins = Math.floor(diffMs / (1000 * 60));
|
const diffMins = Math.floor(diffMs / (1000 * 60));
|
||||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
if (diffMins < 1) return 'just now';
|
if (diffMins < 1) return 'just now';
|
||||||
if (diffMins < 60) return `${diffMins}m ago`;
|
if (diffMins < 60) return `${diffMins}m ago`;
|
||||||
if (diffHours < 24) return `${diffHours}h ago`;
|
if (diffHours < 24) return `${diffHours}h ago`;
|
||||||
if (diffDays < 7) return `${diffDays}d ago`;
|
if (diffDays < 7) return `${diffDays}d ago`;
|
||||||
|
|
||||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -140,7 +140,7 @@ function getAgentTone(status: AgentStatus): AgentTone {
|
||||||
}
|
}
|
||||||
|
|
||||||
// reopened=blue, closed=amber, created/opened=green, others semantic
|
// reopened=blue, closed=amber, created/opened=green, others semantic
|
||||||
function getEventTone(kind: string): EventTone {
|
export function getEventTone(kind: string): EventTone {
|
||||||
const normalized = kind.toLowerCase();
|
const normalized = kind.toLowerCase();
|
||||||
const byKind: Record<string, EventTone> = {
|
const byKind: Record<string, EventTone> = {
|
||||||
created: {
|
created: {
|
||||||
|
|
@ -240,16 +240,16 @@ function getEventTone(kind: string): EventTone {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getInitials(name: string): string {
|
export function getInitials(name: string): string {
|
||||||
return name.split(/[-_\s]/).map(p => p[0]).join('').toUpperCase().slice(0, 2);
|
return name.split(/[-_\s]/).map(p => p[0]).join('').toUpperCase().slice(0, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ActivityPanel({ issues, collapsed = false, projectRoot }: ActivityPanelProps) {
|
export function ActivityPanel({ issues, collapsed = false, projectRoot }: ActivityPanelProps) {
|
||||||
const [activities, setActivities] = useState<ActivityEvent[]>([]);
|
const [activities, setActivities] = useState<ActivityEvent[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
const agentRoster = useMemo(() => buildAgentRoster(issues), [issues]);
|
const agentRoster = useMemo(() => buildAgentRoster(issues), [issues]);
|
||||||
|
|
||||||
// Fetch activity history
|
// Fetch activity history
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchActivity() {
|
async function fetchActivity() {
|
||||||
|
|
@ -265,15 +265,15 @@ export function ActivityPanel({ issues, collapsed = false, projectRoot }: Activi
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchActivity();
|
fetchActivity();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Subscribe to real-time activity
|
// Subscribe to real-time activity
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log('[ActivityPanel] Connecting to SSE for:', projectRoot);
|
console.log('[ActivityPanel] Connecting to SSE for:', projectRoot);
|
||||||
const source = new EventSource(`/api/events?projectRoot=${encodeURIComponent(projectRoot)}`);
|
const source = new EventSource(`/api/events?projectRoot=${encodeURIComponent(projectRoot)}`);
|
||||||
|
|
||||||
const onActivity = (event: MessageEvent) => {
|
const onActivity = (event: MessageEvent) => {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
|
|
@ -286,9 +286,9 @@ export function ActivityPanel({ issues, collapsed = false, projectRoot }: Activi
|
||||||
// Ignore parse errors
|
// Ignore parse errors
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
source.addEventListener('activity', onActivity as EventListener);
|
source.addEventListener('activity', onActivity as EventListener);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
console.log('[ActivityPanel] Closing SSE connection');
|
console.log('[ActivityPanel] Closing SSE connection');
|
||||||
source.removeEventListener('activity', onActivity as EventListener);
|
source.removeEventListener('activity', onActivity as EventListener);
|
||||||
|
|
@ -307,14 +307,14 @@ export function ActivityPanel({ issues, collapsed = false, projectRoot }: Activi
|
||||||
<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-[#7CB97A]/20 opacity-100 animate-pulse' :
|
agent.status === 'active' ? 'bg-[#7CB97A]/20 opacity-100 animate-pulse' :
|
||||||
agent.status === 'stale' ? 'bg-[#D4A574]/14 opacity-80' :
|
agent.status === 'stale' ? 'bg-[#D4A574]/14 opacity-80' :
|
||||||
agent.status === 'stuck' ? 'bg-[#C97A7A]/20 opacity-100' : 'bg-[#A78A94]/18 opacity-90'
|
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-[#7CB97A]/45' :
|
agent.status === 'active' ? 'ring-[#7CB97A]/45' :
|
||||||
agent.status === 'stale' ? 'ring-[#D4A574]/45' :
|
agent.status === 'stale' ? 'ring-[#D4A574]/45' :
|
||||||
agent.status === 'stuck' ? 'ring-[#C97A7A]/45' : 'ring-[#A78A94]/40'
|
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)}
|
||||||
|
|
@ -323,17 +323,17 @@ export function ActivityPanel({ issues, collapsed = false, projectRoot }: Activi
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-6 h-[1px] bg-white/20 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",
|
||||||
getEventTone(act.kind).dotClass
|
getEventTone(act.kind).dotClass
|
||||||
)} />
|
)} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -352,7 +352,7 @@ export function ActivityPanel({ issues, collapsed = false, projectRoot }: Activi
|
||||||
{activeAgents} ONLINE
|
{activeAgents} ONLINE
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{agentRoster.length === 0 ? (
|
{agentRoster.length === 0 ? (
|
||||||
<p className="text-xs text-text-muted/40 italic text-center py-4">No agents broadcasting</p>
|
<p className="text-xs text-text-muted/40 italic text-center py-4">No agents broadcasting</p>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -392,14 +392,14 @@ export function ActivityPanel({ issues, collapsed = false, projectRoot }: Activi
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 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 shadow-[0_14px_24px_-24px_rgba(0,0,0,0.9)]">
|
<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>
|
||||||
|
|
||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="flex-1">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="p-10 flex flex-col items-center gap-3">
|
<div className="p-10 flex flex-col items-center gap-3">
|
||||||
|
|
@ -432,11 +432,11 @@ export function ActivityPanel({ issues, collapsed = false, projectRoot }: Activi
|
||||||
{formatRelativeTime(activity.timestamp)}
|
{formatRelativeTime(activity.timestamp)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs font-medium text-text-secondary leading-snug line-clamp-2 mb-2 group-hover:text-text-primary transition-colors">
|
<p className="text-xs font-medium text-text-secondary leading-snug line-clamp-2 mb-2 group-hover:text-text-primary transition-colors">
|
||||||
{activity.beadTitle}
|
{activity.beadTitle}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className={cn("text-[10px] font-mono", eventTone.idClass)}>
|
<span className={cn("text-[10px] font-mono", eventTone.idClass)}>
|
||||||
{activity.beadId}
|
{activity.beadId}
|
||||||
|
|
|
||||||
32
src/components/activity/contextual-right-panel.tsx
Normal file
32
src/components/activity/contextual-right-panel.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import type { BeadIssue } from '../../lib/types';
|
||||||
|
import { ActivityPanel } from './activity-panel';
|
||||||
|
import { SwarmCommandFeed } from './swarm-command-feed';
|
||||||
|
|
||||||
|
export interface ContextualRightPanelProps {
|
||||||
|
epicId?: string | null;
|
||||||
|
issues: BeadIssue[];
|
||||||
|
projectRoot: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContextualRightPanel({ epicId, issues, projectRoot }: ContextualRightPanelProps) {
|
||||||
|
if (epicId) {
|
||||||
|
return (
|
||||||
|
<SwarmCommandFeed
|
||||||
|
epicId={epicId}
|
||||||
|
issues={issues}
|
||||||
|
projectRoot={projectRoot}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to Global feed
|
||||||
|
return (
|
||||||
|
<ActivityPanel
|
||||||
|
issues={issues}
|
||||||
|
projectRoot={projectRoot}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
170
src/components/activity/swarm-command-feed.tsx
Normal file
170
src/components/activity/swarm-command-feed.tsx
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState, useMemo } from 'react';
|
||||||
|
import type { BeadIssue } from '../../lib/types';
|
||||||
|
import type { ActivityEvent } from '../../lib/activity';
|
||||||
|
import { useArchetypes } from '../../hooks/use-archetypes';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||||
|
import { getEventTone, formatRelativeTime, getInitials } from './activity-panel';
|
||||||
|
|
||||||
|
export interface SwarmCommandFeedProps {
|
||||||
|
epicId: string;
|
||||||
|
issues: BeadIssue[];
|
||||||
|
projectRoot: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SwarmCommandFeed({ epicId, issues, projectRoot }: SwarmCommandFeedProps) {
|
||||||
|
const [activities, setActivities] = useState<ActivityEvent[]>([]);
|
||||||
|
const { archetypes } = useArchetypes(projectRoot);
|
||||||
|
|
||||||
|
// 1. Compute Contextual Tasks
|
||||||
|
const contextBeads = useMemo(() => {
|
||||||
|
return issues.filter(issue => {
|
||||||
|
const parent = issue.dependencies.find(d => d.type === 'parent');
|
||||||
|
return parent?.target === epicId;
|
||||||
|
});
|
||||||
|
}, [issues, epicId]);
|
||||||
|
const contextBeadIds = useMemo(() => new Set(contextBeads.map(b => b.id)), [contextBeads]);
|
||||||
|
|
||||||
|
// 2. Compute "Active Squad Roster" (Unique assignees working on in_progress tasks for THIS epic)
|
||||||
|
const rosterEntries = useMemo(() => {
|
||||||
|
const activeAssignees = new Set<string>();
|
||||||
|
const entries: { assignee: string, currentTask: string, archetype?: any }[] = [];
|
||||||
|
|
||||||
|
contextBeads.forEach(b => {
|
||||||
|
if (b.status === 'in_progress' && b.assignee && !activeAssignees.has(b.assignee)) {
|
||||||
|
activeAssignees.add(b.assignee);
|
||||||
|
const assigneeStr = b.assignee.toLowerCase();
|
||||||
|
const matchedArchetype = archetypes.find(a =>
|
||||||
|
assigneeStr.includes(a.id.toLowerCase()) ||
|
||||||
|
assigneeStr.includes(a.name.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
entries.push({
|
||||||
|
assignee: b.assignee,
|
||||||
|
currentTask: b.title,
|
||||||
|
archetype: matchedArchetype
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return entries;
|
||||||
|
}, [contextBeads, archetypes]);
|
||||||
|
|
||||||
|
// 3. Subscribe to real-time activity, filtering ONLY for this epic's children
|
||||||
|
useEffect(() => {
|
||||||
|
const source = new EventSource(`/api/events?projectRoot=${encodeURIComponent(projectRoot)}`);
|
||||||
|
|
||||||
|
const onActivity = (event: MessageEvent) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data) as ActivityEvent;
|
||||||
|
// ONLY accept events for beads that belong to this Epic
|
||||||
|
if (data?.beadId && contextBeadIds.has(data.beadId)) {
|
||||||
|
setActivities(prev => [data, ...prev].slice(0, 100)); // Keep a healthy buffer for terminal feel
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore parse errors
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
source.addEventListener('activity', onActivity as EventListener);
|
||||||
|
return () => {
|
||||||
|
source.removeEventListener('activity', onActivity as EventListener);
|
||||||
|
source.close();
|
||||||
|
};
|
||||||
|
}, [projectRoot, contextBeadIds]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full bg-[#050a10] border-l border-[var(--ui-border-soft)]">
|
||||||
|
{/* SQUAD ROSTER SECTION */}
|
||||||
|
<div className="flex-shrink-0 p-4 bg-[#0a111a] shadow-[0_16px_24px_-24px_rgba(0,0,0,0.9)] z-10">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse shadow-[0_0_8px_#10b981]" />
|
||||||
|
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-[var(--ui-text-primary)]">Active Squad</h3>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] font-mono text-[#7CB97A]/80 bg-[#7CB97A]/15 px-2 py-0.5 rounded border border-[#7CB97A]/30">
|
||||||
|
{rosterEntries.length} DEPLOYED
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rosterEntries.length === 0 ? (
|
||||||
|
<div className="text-xs text-[var(--ui-text-muted)] italic text-center py-4 border border-dashed border-white/5 rounded-lg">
|
||||||
|
No agents currently operating on this Epic.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-2">
|
||||||
|
{rosterEntries.map((agent, i) => (
|
||||||
|
<div key={i} className="flex gap-3 p-2.5 bg-[#0f1824] border border-[var(--ui-border-soft)] rounded-xl items-center shadow-lg transition-all hover:border-[var(--ui-accent-info)]/30">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute -inset-0.5 rounded-full blur-[2px] opacity-70 bg-emerald-500/20" />
|
||||||
|
<Avatar className="h-9 w-9 relative z-10 ring-2 ring-emerald-500/40">
|
||||||
|
<AvatarFallback className="text-[10px] font-bold" style={{ backgroundColor: agent.archetype?.color ? `\${agent.archetype.color}20` : '#252525', color: agent.archetype?.color || '#fff' }}>
|
||||||
|
{getInitials(agent.assignee)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</div>
|
||||||
|
<div className="flex-col flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-bold text-[var(--ui-text-primary)] truncate">{agent.assignee}</div>
|
||||||
|
<div className="text-[10px] text-[var(--ui-accent-warning)] truncate font-mono mt-0.5">
|
||||||
|
> {agent.currentTask}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* STREAMING LOG / TERMINAL SECTION */}
|
||||||
|
<div className="flex-1 min-h-0 flex flex-col pt-2 bg-black/40">
|
||||||
|
<div className="px-4 py-2 flex items-center justify-between border-b border-[var(--ui-border-soft)]/50">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className="text-cyan-500"><path d="M4 17l6-6-6-6M12 19h8"></path></svg>
|
||||||
|
<h3 className="text-[10px] font-mono font-bold uppercase tracking-[0.2em] text-[var(--ui-text-muted)]">Live Command Feed</h3>
|
||||||
|
</div>
|
||||||
|
<div className="text-[9px] font-mono text-[var(--ui-text-muted)]/50 uppercase">Tailing Logs</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="flex-1 p-3">
|
||||||
|
{activities.length === 0 ? (
|
||||||
|
<div className="p-10 text-center opacity-30 flex flex-col items-center gap-2">
|
||||||
|
<div className="w-1 h-4 bg-cyan-500 animate-pulse" />
|
||||||
|
<p className="text-[10px] font-mono uppercase tracking-widest text-cyan-500">Waiting for agent signals...</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{activities.map((activity) => {
|
||||||
|
const eventTone = getEventTone(activity.kind);
|
||||||
|
return (
|
||||||
|
<div key={activity.id} className="group flex gap-3 p-1.5 rounded bg-transparent hover:bg-white/5 transition-colors items-start">
|
||||||
|
<div className={cn("text-[9px] font-mono whitespace-nowrap pt-0.5", eventTone.idClass)}>
|
||||||
|
[{formatRelativeTime(activity.timestamp)}]
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0 flex flex-col">
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
{activity.actor && (
|
||||||
|
<span className="text-[10px] font-bold text-white uppercase">{activity.actor.split(' ')[0]}</span>
|
||||||
|
)}
|
||||||
|
<span className={cn("text-[10px] font-mono", eventTone.labelClass)}>
|
||||||
|
{eventTone.label.toLowerCase()}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-zinc-400 font-mono truncate max-w-[120px]">
|
||||||
|
{activity.beadId}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-zinc-300 leading-snug break-words">
|
||||||
|
{activity.beadTitle}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -533,6 +533,7 @@ export function DependencyGraphPage({
|
||||||
isCycleNode: cycleNodeIdSet.has(issue.id),
|
isCycleNode: cycleNodeIdSet.has(issue.id),
|
||||||
isDimmed: selectedId ? !chainNodeIds.has(issue.id) : false,
|
isDimmed: selectedId ? !chainNodeIds.has(issue.id) : false,
|
||||||
blockerTooltipLines: externalBlockerNames.get(issue.id) ?? blockerTooltipMap.get(issue.id) ?? [],
|
blockerTooltipLines: externalBlockerNames.get(issue.id) ?? blockerTooltipMap.get(issue.id) ?? [],
|
||||||
|
labels: issue.labels,
|
||||||
},
|
},
|
||||||
position: { x: 0, y: 0 },
|
position: { x: 0, y: 0 },
|
||||||
sourcePosition: Position.Right,
|
sourcePosition: Position.Right,
|
||||||
|
|
|
||||||
|
|
@ -187,11 +187,10 @@ export function LeftPanel({ issues, selectedEpicId, onEpicSelect, filters, onFil
|
||||||
const views: Array<{ id: ViewType; label: string }> = [
|
const views: Array<{ id: ViewType; label: string }> = [
|
||||||
{ id: 'social', label: 'Social' },
|
{ id: 'social', label: 'Social' },
|
||||||
{ id: 'graph', label: 'Graph' },
|
{ id: 'graph', label: 'Graph' },
|
||||||
{ id: 'swarm', label: 'Swarm' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="flex h-full flex-col bg-[var(--ui-bg-shell)] shadow-[inset_-1px_0_0_rgba(0,0,0,0.55),24px_0_40px_-34px_rgba(0,0,0,0.98)]" data-testid="left-panel">
|
<aside className="flex h-full min-h-0 overflow-hidden flex-col bg-[var(--ui-bg-shell)] shadow-[inset_-1px_0_0_rgba(0,0,0,0.55),24px_0_40px_-34px_rgba(0,0,0,0.98)]" data-testid="left-panel">
|
||||||
<div className="px-4 py-3 shadow-[0_14px_24px_-20px_rgba(0,0,0,0.92)]">
|
<div className="px-4 py-3 shadow-[0_14px_24px_-20px_rgba(0,0,0,0.92)]">
|
||||||
<div className="mb-3 flex items-center gap-1 rounded-xl bg-[#101c2b] p-1 shadow-[0_12px_24px_-18px_rgba(0,0,0,0.88)]">
|
<div className="mb-3 flex items-center gap-1 rounded-xl bg-[#101c2b] p-1 shadow-[0_12px_24px_-18px_rgba(0,0,0,0.88)]">
|
||||||
{views.map((item) => {
|
{views.map((item) => {
|
||||||
|
|
@ -405,21 +404,23 @@ export function LeftPanel({ issues, selectedEpicId, onEpicSelect, filters, onFil
|
||||||
|
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
<div className="ml-8 mt-1 space-y-1 pl-3">
|
<div className="ml-8 mt-1 space-y-1 pl-3">
|
||||||
{matchedChildren.slice(0, 7).map((task) => (
|
{matchedChildren.map((task) => (
|
||||||
<button
|
<button
|
||||||
key={task.id}
|
key={task.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onEpicSelect?.(epic.id)}
|
onClick={() => onEpicSelect?.(epic.id)}
|
||||||
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs text-[var(--ui-text-muted)] transition-colors hover:bg-[#112133] hover:text-[var(--ui-text-primary)]"
|
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs text-[var(--ui-text-muted)] transition-colors hover:bg-[#112133] hover:text-[var(--ui-text-primary)]"
|
||||||
>
|
>
|
||||||
<span className={cn('h-1.5 w-1.5 rounded-full', statusDot(task.status))} />
|
<span className={cn('h-1.5 w-1.5 rounded-full flex-shrink-0', statusDot(task.status))} />
|
||||||
<span className="min-w-0 flex-1 truncate">{task.title}</span>
|
<span className="min-w-0 flex-1 truncate">{task.title}</span>
|
||||||
<span className="font-mono text-[10px] text-[var(--ui-text-muted)]">{task.id}</span>
|
{task.assignee ? (
|
||||||
|
<span className="flex-shrink-0 px-1.5 py-0.5 rounded text-[8px] font-bold uppercase bg-white/10 text-[var(--ui-text-primary)]">
|
||||||
|
{task.assignee.slice(0, 2)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span className="font-mono text-[10px] text-[var(--ui-text-muted)] flex-shrink-0">{task.id}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{matchedChildren.length > 7 ? (
|
|
||||||
<p className="px-1.5 py-0.5 text-[10px] text-[var(--ui-text-muted)]">+ {matchedChildren.length - 7} more</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import { useUrlState, ViewType } from '../../hooks/use-url-state';
|
||||||
const tabs: { id: ViewType; label: string; icon: string }[] = [
|
const tabs: { id: ViewType; label: string; icon: string }[] = [
|
||||||
{ id: 'social', label: 'Social', icon: '≡' },
|
{ id: 'social', label: 'Social', icon: '≡' },
|
||||||
{ id: 'graph', label: 'Graph', icon: '◊' },
|
{ id: 'graph', label: 'Graph', icon: '◊' },
|
||||||
{ id: 'swarm', label: 'Swarm', icon: '≋' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export function MobileNav() {
|
export function MobileNav() {
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ export function SwarmMissionPicker({ issues }: { issues: BeadIssue[] }) {
|
||||||
const views: Array<{ id: ViewType; label: string }> = [
|
const views: Array<{ id: ViewType; label: string }> = [
|
||||||
{ id: 'social', label: 'Social' },
|
{ id: 'social', label: 'Social' },
|
||||||
{ id: 'graph', label: 'Graph' },
|
{ id: 'graph', label: 'Graph' },
|
||||||
{ id: 'swarm', label: 'Swarm' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Filter issues to find epics (Missions)
|
// Filter issues to find epics (Missions)
|
||||||
|
|
@ -62,8 +61,8 @@ export function SwarmMissionPicker({ issues }: { issues: BeadIssue[] }) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={m.id}
|
key={m.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setView('swarm');
|
setView('graph');
|
||||||
setSwarmId(m.id);
|
setSwarmId(m.id);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,12 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import dynamic from 'next/dynamic';
|
|
||||||
import { Loader2, AlertCircle, Bot, Zap } from 'lucide-react';
|
import { Loader2, AlertCircle, Bot, Zap } from 'lucide-react';
|
||||||
import type { BeadIssue } from '../../lib/types';
|
import type { BeadIssue } from '../../lib/types';
|
||||||
import type { AgentArchetype } from '../../lib/types-swarm';
|
import type { AgentArchetype } from '../../lib/types-swarm';
|
||||||
|
|
||||||
const SpecializedAgentDagLazy = dynamic(
|
import { WorkflowGraph } from '../shared/workflow-graph';
|
||||||
() => import('./specialized-agent-dag').then((m) => m.SpecializedAgentDag),
|
|
||||||
{
|
|
||||||
ssr: false,
|
|
||||||
loading: () => (
|
|
||||||
<div className="flex items-center justify-center p-8 w-full h-full min-h-[200px]">
|
|
||||||
<Loader2 className="animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
interface TelemetryGridProps {
|
interface TelemetryGridProps {
|
||||||
epicId: string;
|
epicId: string;
|
||||||
|
|
@ -99,11 +89,12 @@ export function TelemetryGrid({ epicId, issues, archetypes }: TelemetryGridProps
|
||||||
<span className="text-xs font-semibold tracking-wide uppercase text-[var(--ui-text-primary)]">Agent Flow</span>
|
<span className="text-xs font-semibold tracking-wide uppercase text-[var(--ui-text-primary)]">Agent Flow</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 w-full h-full">
|
<div className="flex-1 w-full h-full">
|
||||||
<SpecializedAgentDagLazy
|
<WorkflowGraph
|
||||||
beads={beads}
|
beads={beads}
|
||||||
archetypes={archetypes}
|
archetypes={archetypes}
|
||||||
selectedId={selectedBeadId}
|
selectedId={selectedBeadId || undefined}
|
||||||
onSelect={setSelectedBeadId}
|
onSelect={setSelectedBeadId}
|
||||||
|
hideClosed={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useSearchParams, useRouter } from 'next/navigation';
|
import { useSearchParams, useRouter } from 'next/navigation';
|
||||||
|
|
||||||
export type ViewType = 'social' | 'graph' | 'swarm' | 'activity';
|
export type ViewType = 'social' | 'graph' | 'activity';
|
||||||
export type PanelState = 'open' | 'closed';
|
export type PanelState = 'open' | 'closed';
|
||||||
export type DrawerState = 'open' | 'closed';
|
export type DrawerState = 'open' | 'closed';
|
||||||
export type GraphTabType = 'flow' | 'overview';
|
export type GraphTabType = 'flow' | 'overview';
|
||||||
|
|
@ -43,7 +43,7 @@ const DEFAULT_RIGHT_PANEL: PanelState = 'open';
|
||||||
const DEFAULT_DRAWER: DrawerState = 'closed';
|
const DEFAULT_DRAWER: DrawerState = 'closed';
|
||||||
const DEFAULT_GRAPH_TAB: GraphTabType = 'flow';
|
const DEFAULT_GRAPH_TAB: GraphTabType = 'flow';
|
||||||
|
|
||||||
const VALID_VIEWS: ViewType[] = ['social', 'graph', 'swarm', 'activity'];
|
const VALID_VIEWS: ViewType[] = ['social', 'graph', 'activity'];
|
||||||
const VALID_PANELS: PanelState[] = ['open', 'closed'];
|
const VALID_PANELS: PanelState[] = ['open', 'closed'];
|
||||||
const VALID_DRAWERS: DrawerState[] = ['open', 'closed'];
|
const VALID_DRAWERS: DrawerState[] = ['open', 'closed'];
|
||||||
const VALID_GRAPH_TABS: GraphTabType[] = ['flow', 'overview'];
|
const VALID_GRAPH_TABS: GraphTabType[] = ['flow', 'overview'];
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ describe('Mobile Navigation - Hamburger Menu', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders three tab buttons: Social, Graph, Swarm', async () => {
|
it('renders tab buttons: Social, Graph', async () => {
|
||||||
try {
|
try {
|
||||||
const mod = await import('../../../src/components/shared/mobile-nav');
|
const mod = await import('../../../src/components/shared/mobile-nav');
|
||||||
assert.ok(mod.MobileNav, 'MobileNav should exist');
|
assert.ok(mod.MobileNav, 'MobileNav should exist');
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ describe('TopBar Component Contract', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('TopBar View Tabs', () => {
|
describe('TopBar View Tabs', () => {
|
||||||
it('renders three view tabs: Social, Graph, Swarm', async () => {
|
it('renders view tabs: Social, Graph', async () => {
|
||||||
try {
|
try {
|
||||||
const mod = await import('../../../src/components/shared/top-bar');
|
const mod = await import('../../../src/components/shared/top-bar');
|
||||||
assert.ok(mod.TopBar, 'TopBar should exist');
|
assert.ok(mod.TopBar, 'TopBar should exist');
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { describe, it } from 'node:test';
|
import { describe, it } from 'node:test';
|
||||||
import assert from 'node:assert';
|
import assert from 'node:assert';
|
||||||
import { parseUrlState, buildUrlParams, type ViewType, type GraphTabType } from '../../src/hooks/use-url-state';
|
import { parseUrlState, buildUrlParams } from '../../src/hooks/use-url-state';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* URL State Integration Tests - bb-ui2.22
|
* URL State Integration Tests - bb-ui2.22
|
||||||
|
|
@ -80,23 +80,24 @@ describe('URL State Integration - bb-ui2.22', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Valid URL Patterns - Swarm View', () => {
|
describe('Deprecated Swarm View Fallback', () => {
|
||||||
it('/?view=swarm - swarm view default', () => {
|
it('/?view=swarm - falls back to social (swarm view deprecated)', () => {
|
||||||
const sp = createMockSearchParams({ view: 'swarm' });
|
const sp = createMockSearchParams({ view: 'swarm' });
|
||||||
const state = parseUrlState(sp);
|
const state = parseUrlState(sp);
|
||||||
assert.strictEqual(state.view, 'swarm');
|
assert.strictEqual(state.view, 'social');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('/?view=swarm&swarm=bb-buff - specific swarm selected', () => {
|
it('/?view=swarm&swarm=bb-buff - falls back to social but preserves swarmId', () => {
|
||||||
const sp = createMockSearchParams({ view: 'swarm', swarm: 'bb-buff' });
|
const sp = createMockSearchParams({ view: 'swarm', swarm: 'bb-buff' });
|
||||||
const state = parseUrlState(sp);
|
const state = parseUrlState(sp);
|
||||||
assert.strictEqual(state.view, 'swarm');
|
assert.strictEqual(state.view, 'social');
|
||||||
assert.strictEqual(state.swarmId, 'bb-buff');
|
assert.strictEqual(state.swarmId, 'bb-buff');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('/?view=swarm&swarm=bb-buff&panel=open - swarm with panel open', () => {
|
it('/?view=swarm&swarm=bb-buff&panel=open - falls back to social with panel open', () => {
|
||||||
const sp = createMockSearchParams({ view: 'swarm', swarm: 'bb-buff', panel: 'open' });
|
const sp = createMockSearchParams({ view: 'swarm', swarm: 'bb-buff', panel: 'open' });
|
||||||
const state = parseUrlState(sp);
|
const state = parseUrlState(sp);
|
||||||
|
assert.strictEqual(state.view, 'social');
|
||||||
assert.strictEqual(state.swarmId, 'bb-buff');
|
assert.strictEqual(state.swarmId, 'bb-buff');
|
||||||
assert.strictEqual(state.panel, 'open');
|
assert.strictEqual(state.panel, 'open');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue