mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-29 14:07:13 +02:00
feat: redesign sidebar with richer session cards and visual polish
- Session cards now show orchestration mode icon (colored per type: indigo=single, amber=sequential, emerald=concurrent, sky=handoff, violet=group-chat) giving at-a-glance pattern visibility - Add relative timestamps (just now, 2m ago, yesterday, etc.) - Running sessions show animated pulse on left accent bar and text status label instead of static dot - Multi-agent sessions display agent count badge (Users icon + count) - Error sessions show explicit red Error label - Project headers show running session count as live badge and total count as pill; folder icon highlights on hover - Upgrade Kopaya logo from letter badge to gradient icon with Sparkles, add BETA tag - Move New Session button to prominent dashed-border CTA below header - Better empty state with composite folder+plus icon treatment and indigo Add Project button - Add sidebar-pulse CSS keyframe animation for running indicators Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1,13 +1,21 @@
|
|||||||
import { useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
|
ArrowLeftRight,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
|
GitFork,
|
||||||
|
ListOrdered,
|
||||||
|
Lock,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Plus,
|
Plus,
|
||||||
Settings,
|
Settings,
|
||||||
|
Sparkles,
|
||||||
|
Users,
|
||||||
|
type LucideIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
import type { OrchestrationMode, PatternDefinition } from '@shared/domain/pattern';
|
||||||
import type { ProjectRecord } from '@shared/domain/project';
|
import type { ProjectRecord } from '@shared/domain/project';
|
||||||
import type { SessionRecord } from '@shared/domain/session';
|
import type { SessionRecord } from '@shared/domain/session';
|
||||||
import type { WorkspaceState } from '@shared/domain/workspace';
|
import type { WorkspaceState } from '@shared/domain/workspace';
|
||||||
@@ -21,66 +29,191 @@ interface SidebarProps {
|
|||||||
onOpenSettings: () => void;
|
onOpenSettings: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusDot(status: SessionRecord['status']) {
|
/* ── Mode icon + accent colour mapping ─────────────────────── */
|
||||||
if (status === 'running') return 'bg-blue-400';
|
|
||||||
if (status === 'error') return 'bg-red-400';
|
const modeVisuals: Record<OrchestrationMode, { icon: LucideIcon; color: string }> = {
|
||||||
return 'bg-zinc-600';
|
single: { icon: MessageSquare, color: 'text-indigo-400' },
|
||||||
|
sequential: { icon: ListOrdered, color: 'text-amber-400' },
|
||||||
|
concurrent: { icon: GitFork, color: 'text-emerald-400' },
|
||||||
|
handoff: { icon: ArrowLeftRight, color: 'text-sky-400' },
|
||||||
|
'group-chat': { icon: Users, color: 'text-violet-400' },
|
||||||
|
magentic: { icon: Lock, color: 'text-zinc-500' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── Relative time helper ──────────────────────────────────── */
|
||||||
|
|
||||||
|
function relativeTime(iso: string): string {
|
||||||
|
const diff = Date.now() - new Date(iso).getTime();
|
||||||
|
const secs = Math.floor(diff / 1000);
|
||||||
|
if (secs < 60) return 'just now';
|
||||||
|
const mins = Math.floor(secs / 60);
|
||||||
|
if (mins < 60) return `${mins}m ago`;
|
||||||
|
const hrs = Math.floor(mins / 60);
|
||||||
|
if (hrs < 24) return `${hrs}h ago`;
|
||||||
|
const days = Math.floor(hrs / 24);
|
||||||
|
if (days === 1) return 'yesterday';
|
||||||
|
if (days < 7) return `${days}d ago`;
|
||||||
|
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Session item ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function SessionItem({
|
||||||
|
session,
|
||||||
|
pattern,
|
||||||
|
isActive,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
session: SessionRecord;
|
||||||
|
pattern?: PatternDefinition;
|
||||||
|
isActive: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
}) {
|
||||||
|
const isRunning = session.status === 'running';
|
||||||
|
const isError = session.status === 'error';
|
||||||
|
const mode = pattern?.mode ?? 'single';
|
||||||
|
const visual = modeVisuals[mode];
|
||||||
|
const ModeIcon = visual.icon;
|
||||||
|
const agentCount = pattern?.agents.length ?? 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`group relative flex w-full items-start gap-2.5 rounded-lg px-2.5 py-2 text-left transition-all duration-150 ${
|
||||||
|
isActive
|
||||||
|
? 'bg-indigo-500/10 ring-1 ring-indigo-500/25'
|
||||||
|
: 'hover:bg-zinc-800/60'
|
||||||
|
} ${isRunning ? 'sidebar-running' : ''}`}
|
||||||
|
onClick={onSelect}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{/* Running left accent bar */}
|
||||||
|
{isRunning && (
|
||||||
|
<span className="absolute inset-y-1.5 left-0 w-[3px] rounded-full bg-blue-400 sidebar-pulse" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mode icon */}
|
||||||
|
<span
|
||||||
|
className={`mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-md ${
|
||||||
|
isActive ? 'bg-indigo-500/15' : 'bg-zinc-800/80'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ModeIcon className={`size-3.5 ${isActive ? 'text-indigo-400' : visual.color}`} />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className={`truncate text-[13px] font-medium leading-tight ${
|
||||||
|
isActive ? 'text-indigo-100' : 'text-zinc-200 group-hover:text-zinc-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{session.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-1 flex items-center gap-2">
|
||||||
|
{/* Agent count badge for multi-agent patterns */}
|
||||||
|
{agentCount > 1 && (
|
||||||
|
<span className="inline-flex items-center gap-0.5 text-[10px] text-zinc-500">
|
||||||
|
<Users className="size-2.5" />
|
||||||
|
{agentCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status indicator */}
|
||||||
|
{isRunning && (
|
||||||
|
<span className="inline-flex items-center gap-1 text-[10px] font-medium text-blue-400">
|
||||||
|
<span className="size-1.5 rounded-full bg-blue-400 sidebar-pulse" />
|
||||||
|
Running
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isError && (
|
||||||
|
<span className="inline-flex items-center gap-1 text-[10px] font-medium text-red-400">
|
||||||
|
<span className="size-1.5 rounded-full bg-red-400" />
|
||||||
|
Error
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Relative time */}
|
||||||
|
<span className="ml-auto text-[10px] text-zinc-600 group-hover:text-zinc-500">
|
||||||
|
{relativeTime(session.updatedAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Project group ─────────────────────────────────────────── */
|
||||||
|
|
||||||
function ProjectGroup({
|
function ProjectGroup({
|
||||||
project,
|
project,
|
||||||
sessions,
|
sessions,
|
||||||
|
patterns,
|
||||||
selectedSessionId,
|
selectedSessionId,
|
||||||
onSessionSelect,
|
onSessionSelect,
|
||||||
}: {
|
}: {
|
||||||
project: ProjectRecord;
|
project: ProjectRecord;
|
||||||
sessions: SessionRecord[];
|
sessions: SessionRecord[];
|
||||||
|
patterns: PatternDefinition[];
|
||||||
selectedSessionId?: string;
|
selectedSessionId?: string;
|
||||||
onSessionSelect: (sessionId: string) => void;
|
onSessionSelect: (sessionId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [expanded, setExpanded] = useState(true);
|
const [expanded, setExpanded] = useState(true);
|
||||||
|
|
||||||
|
const patternMap = useMemo(() => {
|
||||||
|
const map = new Map<string, PatternDefinition>();
|
||||||
|
for (const p of patterns) map.set(p.id, p);
|
||||||
|
return map;
|
||||||
|
}, [patterns]);
|
||||||
|
|
||||||
|
const runningCount = sessions.filter((s) => s.status === 'running').length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-[13px] font-medium text-zinc-400 transition hover:bg-zinc-800/60 hover:text-zinc-200"
|
className="group flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-[13px] font-semibold text-zinc-400 transition hover:bg-zinc-800/40 hover:text-zinc-200"
|
||||||
onClick={() => setExpanded(!expanded)}
|
onClick={() => setExpanded(!expanded)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{expanded ? (
|
{expanded ? (
|
||||||
<ChevronDown className="size-3.5 shrink-0" />
|
<ChevronDown className="size-3 shrink-0 text-zinc-500" />
|
||||||
) : (
|
) : (
|
||||||
<ChevronRight className="size-3.5 shrink-0" />
|
<ChevronRight className="size-3 shrink-0 text-zinc-500" />
|
||||||
)}
|
)}
|
||||||
<FolderOpen className="size-3.5 shrink-0 text-zinc-500" />
|
<FolderOpen className="size-3.5 shrink-0 text-zinc-500 transition group-hover:text-indigo-400" />
|
||||||
<span className="truncate">{project.name}</span>
|
<span className="truncate">{project.name}</span>
|
||||||
<span className="ml-auto text-[11px] text-zinc-600">{sessions.length}</span>
|
|
||||||
|
<div className="ml-auto flex items-center gap-1.5">
|
||||||
|
{runningCount > 0 && (
|
||||||
|
<span className="flex items-center gap-1 rounded-full bg-blue-500/10 px-1.5 py-0.5 text-[10px] font-medium text-blue-400">
|
||||||
|
<span className="size-1.5 rounded-full bg-blue-400 sidebar-pulse" />
|
||||||
|
{runningCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="rounded-full bg-zinc-800 px-1.5 py-0.5 text-[10px] font-medium text-zinc-500">
|
||||||
|
{sessions.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div className="ml-3 mt-0.5 space-y-0.5 border-l border-zinc-800 pl-3">
|
<div className="ml-2 mt-0.5 space-y-0.5 border-l border-zinc-800/60 pl-2">
|
||||||
{sessions.length === 0 ? (
|
{sessions.length === 0 ? (
|
||||||
<div className="px-2 py-1.5 text-[12px] text-zinc-600">No sessions yet</div>
|
<div className="px-3 py-3 text-center text-[12px] text-zinc-600">
|
||||||
|
No sessions yet
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
sessions.map((session) => {
|
sessions.map((session) => (
|
||||||
const isActive = selectedSessionId === session.id;
|
<SessionItem
|
||||||
return (
|
isActive={selectedSessionId === session.id}
|
||||||
<button
|
key={session.id}
|
||||||
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-[13px] transition ${
|
onSelect={() => onSessionSelect(session.id)}
|
||||||
isActive
|
pattern={patternMap.get(session.patternId)}
|
||||||
? 'bg-[var(--color-accent-muted)] text-indigo-200'
|
session={session}
|
||||||
: 'text-zinc-300 hover:bg-zinc-800/60 hover:text-zinc-100'
|
/>
|
||||||
}`}
|
))
|
||||||
key={session.id}
|
|
||||||
onClick={() => onSessionSelect(session.id)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<MessageSquare className="size-3.5 shrink-0 text-zinc-500" />
|
|
||||||
<span className="truncate">{session.title}</span>
|
|
||||||
<span className={`ml-auto size-2 shrink-0 rounded-full ${statusDot(session.status)}`} />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -88,6 +221,8 @@ function ProjectGroup({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Sidebar ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
export function Sidebar({
|
export function Sidebar({
|
||||||
workspace,
|
workspace,
|
||||||
onAddProject,
|
onAddProject,
|
||||||
@@ -100,45 +235,61 @@ export function Sidebar({
|
|||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
{/* Header — extra top padding clears the title bar overlay zone */}
|
{/* Header — extra top padding clears the title bar overlay zone */}
|
||||||
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-4 pb-3 pt-12">
|
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-4 pb-3 pt-12">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="flex size-7 items-center justify-center rounded-lg bg-indigo-600 text-[11px] font-bold text-white">
|
<div className="relative flex size-8 items-center justify-center rounded-xl bg-gradient-to-br from-indigo-500 to-violet-600 text-[12px] font-bold text-white shadow-lg shadow-indigo-500/20">
|
||||||
K
|
<Sparkles className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-semibold text-zinc-100">kopaya</span>
|
||||||
|
<span className="ml-1.5 rounded bg-zinc-800 px-1 py-0.5 text-[9px] font-medium text-zinc-500">
|
||||||
|
BETA
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-semibold text-zinc-100">kopaya</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
className="flex size-8 items-center justify-center rounded-lg text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-200"
|
className="flex size-8 items-center justify-center rounded-lg text-zinc-500 transition hover:bg-zinc-800 hover:text-zinc-300"
|
||||||
onClick={onOpenSettings}
|
onClick={onOpenSettings}
|
||||||
title="Settings"
|
title="Settings"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Settings className="size-4" />
|
<Settings className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
className="flex size-8 items-center justify-center rounded-lg bg-indigo-600 text-white transition hover:bg-indigo-500"
|
|
||||||
onClick={onNewSession}
|
|
||||||
title="New session"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Plus className="size-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* New session CTA */}
|
||||||
|
<div className="px-3 pt-3 pb-1">
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center justify-center gap-2 rounded-lg border border-dashed border-zinc-700 bg-zinc-800/30 px-3 py-2 text-[13px] font-medium text-zinc-400 transition hover:border-indigo-500/40 hover:bg-indigo-500/5 hover:text-indigo-300"
|
||||||
|
onClick={onNewSession}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
New Session
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Project + Session Tree */}
|
{/* Project + Session Tree */}
|
||||||
<div className="flex-1 overflow-y-auto px-2 py-2">
|
<div className="flex-1 overflow-y-auto px-2 py-2">
|
||||||
{workspace.projects.length === 0 ? (
|
{workspace.projects.length === 0 ? (
|
||||||
<div className="flex flex-col items-center gap-3 px-4 py-10 text-center">
|
<div className="flex flex-col items-center gap-4 px-4 py-10 text-center">
|
||||||
<FolderOpen className="size-8 text-zinc-700" />
|
<div className="relative">
|
||||||
|
<div className="flex size-14 items-center justify-center rounded-2xl bg-zinc-800/50 ring-1 ring-zinc-700/50">
|
||||||
|
<FolderOpen className="size-7 text-zinc-600" />
|
||||||
|
</div>
|
||||||
|
<div className="absolute -bottom-1 -right-1 flex size-6 items-center justify-center rounded-full bg-indigo-600 ring-2 ring-[var(--color-surface-1)]">
|
||||||
|
<Plus className="size-3 text-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-zinc-400">No projects yet</p>
|
<p className="text-[13px] font-medium text-zinc-300">No projects yet</p>
|
||||||
<p className="mt-1 text-[12px] text-zinc-600">
|
<p className="mt-1 text-[12px] leading-relaxed text-zinc-500">
|
||||||
Add a project folder to start orchestrating
|
Add a project folder to start<br />orchestrating AI agents
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="mt-1 rounded-lg bg-zinc-800 px-3 py-1.5 text-[13px] font-medium text-zinc-200 transition hover:bg-zinc-700"
|
className="rounded-lg bg-indigo-600 px-4 py-2 text-[13px] font-medium text-white transition hover:bg-indigo-500"
|
||||||
onClick={onAddProject}
|
onClick={onAddProject}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
@@ -151,6 +302,7 @@ export function Sidebar({
|
|||||||
<ProjectGroup
|
<ProjectGroup
|
||||||
key={project.id}
|
key={project.id}
|
||||||
onSessionSelect={onSessionSelect}
|
onSessionSelect={onSessionSelect}
|
||||||
|
patterns={workspace.patterns}
|
||||||
project={project}
|
project={project}
|
||||||
selectedSessionId={workspace.selectedSessionId}
|
selectedSessionId={workspace.selectedSessionId}
|
||||||
sessions={workspace.sessions.filter((s) => s.projectId === project.id)}
|
sessions={workspace.sessions.filter((s) => s.projectId === project.id)}
|
||||||
@@ -164,12 +316,12 @@ export function Sidebar({
|
|||||||
{workspace.projects.length > 0 && (
|
{workspace.projects.length > 0 && (
|
||||||
<div className="border-t border-[var(--color-border)] px-3 py-2">
|
<div className="border-t border-[var(--color-border)] px-3 py-2">
|
||||||
<button
|
<button
|
||||||
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-[13px] text-zinc-500 transition hover:bg-zinc-800/60 hover:text-zinc-300"
|
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-[12px] text-zinc-500 transition hover:bg-zinc-800/60 hover:text-zinc-300"
|
||||||
onClick={onAddProject}
|
onClick={onAddProject}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Plus className="size-3.5" />
|
<FolderOpen className="size-3.5" />
|
||||||
Add project
|
Add another project
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -103,6 +103,16 @@ textarea {
|
|||||||
max-height: 200px;
|
max-height: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Sidebar running-session pulse animation */
|
||||||
|
@keyframes sidebar-pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-pulse {
|
||||||
|
animation: sidebar-pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
/* Markdown prose styles for chat messages */
|
/* Markdown prose styles for chat messages */
|
||||||
.markdown-content {
|
.markdown-content {
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
|
|||||||
Reference in New Issue
Block a user