fix(ui3): final polish - media player layout, functional sidebars, agent visibility

This commit is contained in:
zenchantlive 2026-02-17 00:10:28 -08:00
parent f7bcca7a8e
commit c79dfff0c6
5 changed files with 135 additions and 150 deletions

View file

@ -1 +1 @@
95088
97492

View file

@ -53,11 +53,13 @@ function extractAgentName(issue: BeadIssue): string | null {
// Build agent roster - filter out dead agents unless none are active
function buildAgentRoster(issues: BeadIssue[]): AgentRosterEntry[] {
const agentIssues = issues.filter(issue =>
issue.labels.includes(AGENT_LABEL) || issue.labels.some(l => l.startsWith('gt:agent'))
issue.labels.includes(AGENT_LABEL) ||
issue.labels.some(l => l.startsWith('gt:agent')) ||
issue.labels.includes('agent')
);
const roster = agentIssues.map(issue => {
const name = extractAgentName(issue) || issue.id;
const name = extractAgentName(issue) || issue.title.replace('Agent: ', '') || issue.id;
const status = deriveAgentStatus(issue.updated_at);
return {
@ -71,16 +73,8 @@ function buildAgentRoster(issues: BeadIssue[]): AgentRosterEntry[] {
return statusOrder[a.status] - statusOrder[b.status];
});
// Filter: if there are active agents, show only active + stale (max 5)
// If no active, show stale + stuck (max 3)
// Dead agents never show unless it's the only thing
const activeCount = roster.filter(a => a.status === 'active').length;
if (activeCount > 0) {
return roster.filter(a => a.status !== 'dead').slice(0, 5);
} else {
return roster.filter(a => a.status !== 'dead').slice(0, 3);
}
// Show all non-dead agents, or at least the most recent ones
return roster.filter(a => a.status !== 'dead' || a.lastSeen).slice(0, 10);
}
// Format relative time

View file

@ -61,18 +61,14 @@ export function LeftPanel({
};
const handleEpicClick = (epicId: string) => {
onEpicSelect?.(epicId);
onEpicSelect?.(epicId === selectedEpicId ? null : epicId); // Toggle selection
toggleEpic(epicId);
};
if (isTablet) {
return (
<div
className="w-12 overflow-y-auto flex flex-col items-center py-3 gap-2"
style={{
backgroundColor: 'var(--color-bg-card)',
borderRight: '1px solid rgba(255, 255, 255, 0.1)',
}}
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"
data-testid="left-panel"
>
{epicTree.map(({ epic }) => (
@ -81,13 +77,12 @@ export function LeftPanel({
type="button"
onClick={() => handleEpicClick(epic.id)}
className={cn(
'w-9 h-9 rounded flex items-center justify-center text-xs font-medium transition-colors',
'w-10 h-10 rounded-xl flex items-center justify-center text-xs font-bold transition-all duration-200',
selectedEpicId === epic.id
? 'bg-[var(--color-accent-green)]/20 text-[var(--color-accent-green)]'
: 'hover:bg-white/5'
? 'bg-teal-500/20 text-teal-400 ring-1 ring-teal-500/30 shadow-[0_0_10px_rgba(45,212,191,0.2)]'
: 'hover:bg-white/5 text-text-muted hover:text-white'
)}
style={{ color: selectedEpicId === epic.id ? undefined : 'var(--color-text-muted-dark)' }}
title={epic.id}
title={epic.title || epic.id}
>
{epic.id.slice(0, 2).toUpperCase()}
</button>
@ -99,135 +94,107 @@ export function LeftPanel({
return (
<div
className={cn(
'flex flex-col h-full overflow-hidden',
'flex flex-col h-full overflow-hidden transition-all duration-300',
!isDesktop && 'hidden lg:flex'
)}
style={{
width: '13rem',
backgroundColor: 'var(--color-bg-card)',
borderRight: '1px solid rgba(255, 255, 255, 0.1)',
width: '18rem', // Wider panel
}}
data-testid="left-panel"
>
<div className="p-3 border-b border-white/10">
<span
className="text-xs font-medium uppercase tracking-wider"
style={{ color: 'var(--color-text-muted-dark)' }}
>
Channels
</span>
</div>
<div className="h-full bg-[#1a1a1a]/95 backdrop-blur-xl border-r border-white/5 flex flex-col">
{/* Header */}
<div className="p-4 border-b border-white/5 flex items-center justify-between">
<span className="text-xs font-bold uppercase tracking-[0.2em] text-text-muted">Channels</span>
<span className="text-[10px] font-mono text-text-muted/40">{epicTree.length}</span>
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar">
{epicTree.map(({ epic, children }) => {
const isExpanded = expandedEpics.has(epic.id);
const isSelected = selectedEpicId === epic.id;
const childCount = children.length;
{/* Tree */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-2">
{epicTree.map(({ epic, children }) => {
const isExpanded = expandedEpics.has(epic.id);
const isSelected = selectedEpicId === epic.id;
const childCount = children.length;
return (
<div key={epic.id} className="select-none">
<button
type="button"
onClick={() => handleEpicClick(epic.id)}
className={cn(
'w-full flex items-center gap-2 px-3 py-2 text-left transition-colors',
'hover:bg-white/5 focus:outline-none focus:bg-white/5'
)}
style={{
color: isSelected
? 'var(--color-accent-green)'
: 'var(--color-text-secondary)',
}}
data-testid={`epic-${epic.id}`}
>
<span
className="text-xs transition-transform inline-block"
style={{
transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)',
}}
return (
<div key={epic.id} className="mb-1">
<button
type="button"
onClick={() => handleEpicClick(epic.id)}
className={cn(
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-all duration-200 group',
isSelected
? 'bg-teal-500/10 text-teal-400 border border-teal-500/20'
: 'hover:bg-white/5 text-text-secondary hover:text-white border border-transparent'
)}
>
</span>
<span className="flex-1 truncate text-sm">
{epic.id}
</span>
{childCount > 0 && (
<span
className="text-xs px-1.5 py-0.5 rounded"
style={{
backgroundColor: 'rgba(255,255,255,0.08)',
color: 'var(--color-text-muted-dark)',
}}
>
{childCount}
<div className={cn(
"w-1 h-4 rounded-full transition-colors",
isSelected ? "bg-teal-500 shadow-[0_0_8px_#14b8a6]" : "bg-white/10 group-hover:bg-white/30"
)} />
<span className="flex-1 truncate text-sm font-medium tracking-tight">
{epic.title || epic.id}
</span>
)}
</button>
{childCount > 0 && (
<span className={cn(
"text-[10px] font-mono px-1.5 py-0.5 rounded transition-colors",
isSelected ? "bg-teal-500/20 text-teal-300" : "bg-white/5 text-text-muted group-hover:bg-white/10"
)}>
{childCount}
</span>
)}
</button>
{isExpanded && childCount > 0 && (
<div className="pl-6">
{children.map(child => {
const childSelected = selectedEpicId === child.id;
return (
<button
{/* Sub-items (Agents/Tasks usually, but here listed as children) */}
{isExpanded && childCount > 0 && (
<div className="ml-4 mt-1 pl-3 border-l border-white/5 space-y-0.5">
{children.slice(0, 5).map(child => (
<div
key={child.id}
type="button"
onClick={() => onEpicSelect?.(child.id)}
className={cn(
'w-full flex items-center gap-2 px-3 py-1.5 text-left transition-colors',
'hover:bg-white/5 focus:outline-none focus:bg-white/5'
)}
style={{
color: childSelected
? 'var(--color-accent-green)'
: 'var(--color-text-muted-dark)',
}}
data-testid={`bead-${child.id}`}
className="px-3 py-1.5 text-xs text-text-muted/60 truncate hover:text-text-muted cursor-default"
>
<span className="text-xs opacity-60"></span>
<span className="flex-1 truncate text-xs">
{child.id}
</span>
</button>
);
})}
</div>
)}
{child.title}
</div>
))}
{childCount > 5 && (
<div className="px-3 py-1 text-[10px] text-text-muted/30 italic">
+{childCount - 5} more
</div>
)}
</div>
)}
</div>
);
})}
{epicTree.length === 0 && (
<div className="p-8 text-center opacity-40 flex flex-col items-center">
<div className="text-2xl mb-2">📡</div>
<p className="text-xs font-mono">NO_CHANNELS</p>
</div>
);
})}
)}
</div>
{epicTree.length === 0 && (
<div
className="p-4 text-sm text-center"
style={{ color: 'var(--color-text-muted-dark)' }}
>
No epics found
</div>
)}
</div>
<div
className="border-t border-white/10 p-3"
style={{ backgroundColor: 'var(--color-bg-base)' }}
>
<span
className="text-xs font-medium uppercase tracking-wider"
style={{ color: 'var(--color-text-muted-dark)' }}
>
Scope
</span>
<div className="mt-2 flex flex-col gap-1.5">
<label
className="flex items-center gap-2 cursor-pointer"
style={{ color: 'var(--color-text-secondary)' }}
>
<input
type="checkbox"
defaultChecked
className="rounded border-white/20 accent-[var(--color-accent-green)]"
/>
<span className="text-xs">All Projects</span>
{/* Footer Scope */}
<div className="p-4 border-t border-white/5 bg-black/20">
<label className="flex items-center gap-3 cursor-pointer group">
<div className="relative flex items-center justify-center">
<input
type="checkbox"
defaultChecked
onChange={() => onEpicSelect?.(null)} // Clear selection
checked={selectedEpicId === null}
className="peer appearance-none w-4 h-4 rounded border border-white/20 bg-transparent checked:bg-teal-500 checked:border-teal-500 transition-all"
/>
<svg className="absolute w-3 h-3 text-white opacity-0 peer-checked:opacity-100 pointer-events-none" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
</div>
<span className="text-xs font-medium text-text-secondary group-hover:text-white transition-colors">
Global Scope (All)
</span>
</label>
</div>
</div>
@ -235,4 +202,4 @@ export function LeftPanel({
);
}
export default LeftPanel;
export default LeftPanel;

View file

@ -27,7 +27,7 @@ export interface UnifiedShellProps {
export function UnifiedShell({
issues,
}: UnifiedShellProps) {
const { view, taskId, setTaskId, swarmId, setSwarmId, graphTab, setGraphTab, panel, drawer, setDrawer } = useUrlState();
const { view, taskId, setTaskId, swarmId, setSwarmId, graphTab, setGraphTab, panel, drawer, setDrawer, epicId, setEpicId } = useUrlState();
const socialCards = useMemo(() => buildSocialCards(issues), [issues]);
const swarmCards = useMemo(() => buildSwarmCards(issues), [issues]);
@ -80,10 +80,19 @@ export function UnifiedShell({
const rightPanelWidth = isChatOpen ? '26rem' : '17rem';
const renderMiddleContent = () => {
// Filter issues by Epic if selected (Global Filter)
const filteredIssues = epicId
? issues.filter(issue => {
if (issue.issue_type === 'epic') return issue.id === epicId;
const parent = issue.dependencies.find(d => d.type === 'parent');
return parent?.target === epicId;
})
: issues;
if (view === 'graph') {
return (
<GraphView
beads={issues}
beads={filteredIssues}
selectedId={taskId ?? undefined}
onSelect={handleGraphSelect}
graphTab={graphTab}
@ -96,7 +105,7 @@ export function UnifiedShell({
if (view === 'social') {
return (
<SocialPage
issues={issues}
issues={filteredIssues}
selectedId={taskId ?? undefined}
onSelect={handleCardSelect}
/>
@ -106,7 +115,7 @@ export function UnifiedShell({
if (view === 'swarm') {
return (
<SwarmPage
issues={issues}
issues={filteredIssues}
selectedId={swarmId ?? undefined}
onSelect={handleCardSelect}
/>
@ -121,14 +130,19 @@ export function UnifiedShell({
{/* TOP BAR: 3rem fixed */}
<TopBar />
{/* MAIN AREA: CSS Grid [13rem | 1fr | RightPanel] */}
{/* MAIN AREA: CSS Grid [18rem | 1fr | RightPanel] */}
{/* Increased Left Panel width to 18rem per redesign request */}
<div
className="flex-1 grid overflow-hidden transition-all duration-300"
style={{ gridTemplateColumns: `13rem 1fr ${rightPanelWidth}` }}
style={{ gridTemplateColumns: `18rem 1fr ${rightPanelWidth}` }}
data-testid="main-area"
>
{/* LEFT PANEL: 13rem channel tree */}
<LeftPanel issues={issues} />
{/* LEFT PANEL: 18rem channel tree */}
<LeftPanel
issues={issues}
selectedEpicId={epicId}
onEpicSelect={setEpicId}
/>
{/* MIDDLE CONTENT: flex-1 */}
<div className="relative overflow-hidden bg-black/10 shadow-inner" data-testid="middle-content">
@ -145,4 +159,4 @@ export function UnifiedShell({
<MobileNav />
</div>
);
}
}

View file

@ -17,6 +17,8 @@ export interface UrlState {
setSwarmId: (id: string | null) => void;
agentId: string | null;
setAgentId: (id: string | null) => void;
epicId: string | null;
setEpicId: (id: string | null) => void;
panel: PanelState;
togglePanel: () => void;
drawer: DrawerState;
@ -41,6 +43,7 @@ export function parseUrlState(searchParams: URLSearchParams): {
taskId: string | null;
swarmId: string | null;
agentId: string | null;
epicId: string | null;
panel: PanelState;
drawer: DrawerState;
graphTab: GraphTabType;
@ -53,6 +56,7 @@ export function parseUrlState(searchParams: URLSearchParams): {
const taskId = searchParams.get('task');
const swarmId = searchParams.get('swarm');
const agentId = searchParams.get('agent');
const epicId = searchParams.get('epic');
const panelParam = searchParams.get('panel');
const panel: PanelState = panelParam && VALID_PANELS.includes(panelParam as PanelState)
@ -69,7 +73,7 @@ export function parseUrlState(searchParams: URLSearchParams): {
? (graphTabParam as GraphTabType)
: DEFAULT_GRAPH_TAB;
return { view, taskId, swarmId, agentId, panel, drawer, graphTab };
return { view, taskId, swarmId, agentId, epicId, panel, drawer, graphTab };
}
export function buildUrlParams(
@ -117,6 +121,10 @@ export function useUrlState(): UrlState {
updateUrl({ agent: id, panel: id ? 'open' : null });
}, [updateUrl]);
const setEpicId = useCallback((id: string | null) => {
updateUrl({ epic: id });
}, [updateUrl]);
const togglePanel = useCallback(() => {
const newPanel = state.panel === 'open' ? 'closed' : 'open';
updateUrl({ panel: newPanel });
@ -131,7 +139,7 @@ export function useUrlState(): UrlState {
}, [updateUrl]);
const clearSelection = useCallback(() => {
updateUrl({ task: null, swarm: null, panel: 'closed', drawer: 'closed' });
updateUrl({ task: null, swarm: null, epic: null, panel: 'closed', drawer: 'closed' });
}, [updateUrl]);
return {
@ -143,6 +151,8 @@ export function useUrlState(): UrlState {
setSwarmId,
agentId: state.agentId,
setAgentId,
epicId: state.epicId,
setEpicId,
panel: state.panel,
togglePanel,
drawer: state.drawer,