fix(ui3): final polish - media player layout, functional sidebars, agent visibility
This commit is contained in:
parent
f7bcca7a8e
commit
c79dfff0c6
5 changed files with 135 additions and 150 deletions
|
|
@ -1 +1 @@
|
|||
95088
|
||||
97492
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue