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:
David Kaya
2026-03-21 21:34:51 +01:00
co-authored by Copilot
parent f940b9d153
commit 0cb87d8b66
2 changed files with 215 additions and 53 deletions
+205 -53
View File
@@ -1,13 +1,21 @@
import { useState } from 'react';
import { useMemo, useState } from 'react';
import {
ArrowLeftRight,
ChevronDown,
ChevronRight,
FolderOpen,
GitFork,
ListOrdered,
Lock,
MessageSquare,
Plus,
Settings,
Sparkles,
Users,
type LucideIcon,
} from 'lucide-react';
import type { OrchestrationMode, PatternDefinition } from '@shared/domain/pattern';
import type { ProjectRecord } from '@shared/domain/project';
import type { SessionRecord } from '@shared/domain/session';
import type { WorkspaceState } from '@shared/domain/workspace';
@@ -21,66 +29,191 @@ interface SidebarProps {
onOpenSettings: () => void;
}
function statusDot(status: SessionRecord['status']) {
if (status === 'running') return 'bg-blue-400';
if (status === 'error') return 'bg-red-400';
return 'bg-zinc-600';
/* ── Mode icon + accent colour mapping ─────────────────────── */
const modeVisuals: Record<OrchestrationMode, { icon: LucideIcon; color: string }> = {
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({
project,
sessions,
patterns,
selectedSessionId,
onSessionSelect,
}: {
project: ProjectRecord;
sessions: SessionRecord[];
patterns: PatternDefinition[];
selectedSessionId?: string;
onSessionSelect: (sessionId: string) => void;
}) {
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 (
<div>
<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)}
type="button"
>
{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="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>
{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 ? (
<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) => {
const isActive = selectedSessionId === session.id;
return (
<button
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-[13px] transition ${
isActive
? 'bg-[var(--color-accent-muted)] text-indigo-200'
: '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>
);
})
sessions.map((session) => (
<SessionItem
isActive={selectedSessionId === session.id}
key={session.id}
onSelect={() => onSessionSelect(session.id)}
pattern={patternMap.get(session.patternId)}
session={session}
/>
))
)}
</div>
)}
@@ -88,6 +221,8 @@ function ProjectGroup({
);
}
/* ── Sidebar ───────────────────────────────────────────────── */
export function Sidebar({
workspace,
onAddProject,
@@ -100,45 +235,61 @@ export function Sidebar({
<div className="flex h-full flex-col">
{/* 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 gap-2">
<div className="flex size-7 items-center justify-center rounded-lg bg-indigo-600 text-[11px] font-bold text-white">
K
<div className="flex items-center gap-2.5">
<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">
<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>
<span className="text-sm font-semibold text-zinc-100">kopaya</span>
</div>
<div className="flex items-center gap-1">
<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}
title="Settings"
type="button"
>
<Settings className="size-4" />
</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>
{/* 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 */}
<div className="flex-1 overflow-y-auto px-2 py-2">
{workspace.projects.length === 0 ? (
<div className="flex flex-col items-center gap-3 px-4 py-10 text-center">
<FolderOpen className="size-8 text-zinc-700" />
<div className="flex flex-col items-center gap-4 px-4 py-10 text-center">
<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>
<p className="text-sm text-zinc-400">No projects yet</p>
<p className="mt-1 text-[12px] text-zinc-600">
Add a project folder to start orchestrating
<p className="text-[13px] font-medium text-zinc-300">No projects yet</p>
<p className="mt-1 text-[12px] leading-relaxed text-zinc-500">
Add a project folder to start<br />orchestrating AI agents
</p>
</div>
<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}
type="button"
>
@@ -151,6 +302,7 @@ export function Sidebar({
<ProjectGroup
key={project.id}
onSessionSelect={onSessionSelect}
patterns={workspace.patterns}
project={project}
selectedSessionId={workspace.selectedSessionId}
sessions={workspace.sessions.filter((s) => s.projectId === project.id)}
@@ -164,12 +316,12 @@ export function Sidebar({
{workspace.projects.length > 0 && (
<div className="border-t border-[var(--color-border)] px-3 py-2">
<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}
type="button"
>
<Plus className="size-3.5" />
Add project
<FolderOpen className="size-3.5" />
Add another project
</button>
</div>
)}
+10
View File
@@ -103,6 +103,16 @@ textarea {
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-content {
line-height: 1.7;