feat: sidebar redesign — activity feed, grouped nav, AI split (#107)

* docs: add 5 sidebar icon color concepts for UX review

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(ui): add semantic icon colors and updated icons to sidebar nav

Swap generic icons for more descriptive alternatives (Network, Wrench,
FileOutput, Library, Code2, Lightbulb) and assign each nav item a unique
semantic color for instant visual landmarks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(ui): default Sessions page to Active tab, reorder tabs

Active sessions are what engineers care about most. Tab order is now
Active, Prepared, Completed, All.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: add sidebar grouping and AI naming concept mockups

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: add sidebar redesign context and decision summary

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: add sidebar redesign spec and implementation plan

Design spec covers: activity zone with daily stats + session feed,
nav grouping (Resolve/Build/Insights), AI split (FlowPilot + Flow Assist),
pinned flows removal. Implementation plan has 5 chunks, 12 tasks, 39 steps.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add sidebar stats Pydantic schemas

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: add failing tests for sidebar stats endpoint

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add sidebar stats endpoint with daily stats and activity feed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add sidebar API client, stats bar, activity feed components

New components: SidebarStatsBar, SidebarActivityFeed, ActivityItem.
New API client for sidebar stats endpoint. Pulse-dot CSS animation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: restructure sidebar with stats bar, activity feed, and grouped nav

Dashboard-first layout with Resolve/Build/Insights groups.
AI split: FlowPilot (Resolve) + Flow Assist (Build).
Stats bar: Resolved/Active/In Session daily counters.
Activity feed: active sessions with CW ticket #, recent completions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: remove pinned flows frontend (PinnedFlowsSection, store, API, pin buttons)

Removed: PinnedFlowsSection component, pinnedFlowsStore, pinnedFlows API client.
Cleaned: pin buttons from TreeGridView, TreeListView, TreeTableView.
Cleaned: favorites section from QuickStartPage, pin props from TreeLibraryPage.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add FlowAssistPage placeholder and /flow-assist route

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: real-time sidebar stats via session-changed events

Sidebar now refreshes stats when sessions are created or completed,
not just on page navigation. Uses window event bus pattern (same as
folder-changed events in codebase).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: live-ticking In Session timer using active session start times

SidebarStatsBar now computes active session elapsed time client-side
from started_at timestamps, ticking every 60s. Backend only returns
completed session minutes to avoid double-counting.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: sidebar In Session timer ticks every second and shows seconds

Timer now uses 1s interval (not 60s) and displays seconds when under
a minute so it matches the session timer in the flow UI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: trigger PR environment redeploy

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* debug: add console.log to SidebarStatsBar for timer investigation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: parse sidebar timestamps as UTC (append Z suffix)

Backend returns naive UTC timestamps without timezone indicator.
JS Date() treats bare ISO strings as local time, causing the timer
to compute negative elapsed time (future timestamps). Appending 'Z'
forces UTC parsing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: rename 'In Session' to 'Total Time' for clarity

Makes it clear the timer is an aggregate of all sessions today,
not just the current one.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #107.
This commit is contained in:
chihlasm
2026-03-16 01:35:16 -04:00
committed by GitHub
parent 46865882c6
commit 357f8e2d08
29 changed files with 3836 additions and 514 deletions

View File

@@ -15,12 +15,13 @@ interface NavItemProps {
icon: LucideIcon
label: string
badge?: number | 'dot'
iconColor?: string
matchPaths?: string[]
collapsed?: boolean
children?: NavSubItem[]
}
export function NavItem({ href, icon: Icon, label, badge, matchPaths, collapsed, children }: NavItemProps) {
export function NavItem({ href, icon: Icon, label, badge, iconColor, matchPaths, collapsed, children }: NavItemProps) {
const location = useLocation()
const fullPath = location.pathname + location.search
const isActive = matchPaths
@@ -49,7 +50,7 @@ export function NavItem({ href, icon: Icon, label, badge, matchPaths, collapsed,
{isActive && (
<div className="absolute left-0 top-1/2 h-6 w-[3px] -translate-y-1/2 rounded-r-full bg-gradient-brand" />
)}
<Icon size={18} className={cn('shrink-0', isActive ? 'opacity-100' : 'opacity-70')} />
<Icon size={18} className={cn('shrink-0', isActive ? 'opacity-100' : 'opacity-70')} style={iconColor ? { color: iconColor } : undefined} />
{badge !== undefined && badge !== 0 && badge !== 'dot' && (
<span className="absolute -right-0.5 -top-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[0.5rem] font-bold text-primary-foreground">
{badge}
@@ -78,7 +79,7 @@ export function NavItem({ href, icon: Icon, label, badge, matchPaths, collapsed,
<div className="absolute left-0 top-1/2 h-6 w-[3px] -translate-y-1/2 rounded-r-full bg-gradient-brand" />
)}
<Icon size={18} className={cn('shrink-0', isActive ? 'opacity-100' : 'opacity-70')} />
<Icon size={18} className={cn('shrink-0', isActive ? 'opacity-100' : 'opacity-70')} style={iconColor ? { color: iconColor } : undefined} />
<span className="truncate">{label}</span>
{/* Badge */}

View File

@@ -1,50 +1,57 @@
import { useEffect, useState } from 'react'
import { LayoutGrid, Box, PenLine, Clock, FileText, Bookmark, BarChart3, Settings, PanelLeftClose, PanelLeftOpen, MessageSquareText, BotMessageSquare, BookOpen, Sparkles, Terminal } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { useLocation } from 'react-router-dom'
import {
LayoutGrid, Network, Wrench, Clock, FileOutput, BarChart3,
Settings, PanelLeftClose, PanelLeftOpen, MessageSquareText,
BookOpen, Lightbulb, Code2, Library, Brain, WandSparkles,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
import { usePinnedFlowsStore } from '@/store/pinnedFlowsStore'
import { PinnedFlowsSection } from '@/components/sidebar/PinnedFlowsSection'
import { sidebarApi } from '@/api'
import type { SidebarStatsResponse } from '@/api/sidebar'
import { SidebarStatsBar } from '@/components/sidebar/SidebarStatsBar'
import { SidebarActivityFeed } from '@/components/sidebar/SidebarActivityFeed'
import { NavItem } from './NavItem'
import { sessionsApi, treesApi } from '@/api'
// Semantic icon colors — each nav item gets a unique color for visual landmarks
const NAV_COLORS = {
dashboard: '#22d3ee', // cyan-400
flows: '#a78bfa', // violet-400
editor: '#f59e0b', // amber-500
sessions: '#34d399', // emerald-400
exports: '#60a5fa', // blue-400
flowPilot: '#e879f9', // fuchsia-400
flowAssist:'#f472b6', // pink-400
stepLib: '#fb923c', // orange-400
scripts: '#2dd4bf', // teal-400
kb: '#fb7185', // rose-400
analytics: '#38bdf8', // sky-400
guides: '#a3e635', // lime-400
feedback: '#818cf8', // indigo-400
} as const
export function Sidebar() {
const sidebarCollapsed = useUserPreferencesStore(s => s.sidebarCollapsed)
const toggleSidebar = useUserPreferencesStore(s => s.toggleSidebar)
const location = useLocation()
const pinnedItems = usePinnedFlowsStore((s) => s.items)
const loadPinned = usePinnedFlowsStore((s) => s.load)
const unpinFlow = usePinnedFlowsStore((s) => s.unpin)
const [stats, setStats] = useState<SidebarStatsResponse | null>(null)
const [activeSessionCount, setActiveSessionCount] = useState(0)
const [treeCounts, setTreeCounts] = useState({ total: 0, troubleshooting: 0, procedural: 0, maintenance: 0 })
// Load pinned flows on mount
useEffect(() => {
loadPinned()
}, [loadPinned])
// Fetch sidebar data on mount
useEffect(() => {
const fetchData = async () => {
try {
const [activeSessions, allTrees] = await Promise.all([
sessionsApi.list({ completed: false, size: 50 }).catch(() => []),
treesApi.list({ sort_by: 'name' }).catch(() => []),
])
setActiveSessionCount(activeSessions.length)
const total = allTrees.length
const troubleshooting = allTrees.filter(t => t.tree_type === 'troubleshooting').length
const procedural = allTrees.filter(t => t.tree_type === 'procedural').length
const maintenance = allTrees.filter(t => t.tree_type === 'maintenance').length
setTreeCounts({ total, troubleshooting, procedural, maintenance })
} catch {
// Silently handle errors
}
}
fetchData()
const refreshStats = useCallback(() => {
sidebarApi.getStats().then(setStats).catch(() => {})
}, [])
// Fetch sidebar stats — refreshes on navigation
useEffect(() => {
refreshStats()
}, [location.pathname, refreshStats])
// Refresh when sessions are created or completed
useEffect(() => {
window.addEventListener('session-changed', refreshStats)
return () => window.removeEventListener('session-changed', refreshStats)
}, [refreshStats])
const handleSidebarWheel = (e: React.WheelEvent<HTMLElement>) => {
const sidebar = e.currentTarget
const canSidebarScroll = sidebar.scrollHeight > sidebar.clientHeight
@@ -76,50 +83,81 @@ export function Sidebar() {
<>
{/* Collapsed: icon-only nav */}
<div className="flex flex-col items-center px-1.5 py-3 space-y-1">
<NavItem href="/" icon={LayoutGrid} label="Dashboard" collapsed />
<NavItem href="/trees" icon={Box} label="All Flows" matchPaths={['/trees', '/flows']} collapsed />
<NavItem href="/my-trees" icon={PenLine} label="Flow Editor" collapsed />
<NavItem href="/sessions" icon={Clock} label="Sessions" badge={activeSessionCount || undefined} collapsed />
<NavItem href="/shares" icon={FileText} label="Exports" collapsed />
<NavItem href="/assistant" icon={BotMessageSquare} label="AI Assistant" collapsed />
<NavItem href="/step-library" icon={Bookmark} label="Step Library" collapsed />
<NavItem href="/scripts" icon={Terminal} label="Script Library" collapsed />
<NavItem href="/kb-accelerator" icon={Sparkles} label="KB Accelerator" collapsed />
<NavItem href="/analytics" icon={BarChart3} label="Analytics" collapsed />
<NavItem href="/guides" icon={BookOpen} label="User Guides" collapsed />
<NavItem href="/feedback" icon={MessageSquareText} label="Feedback" collapsed />
<NavItem href="/" icon={LayoutGrid} label="Dashboard" iconColor={NAV_COLORS.dashboard} collapsed />
<NavItem href="/sessions" icon={Clock} label="Sessions" badge={stats?.active_count || undefined} iconColor={NAV_COLORS.sessions} collapsed />
<NavItem href="/trees" icon={Network} label="All Flows" matchPaths={['/trees', '/flows']} iconColor={NAV_COLORS.flows} collapsed />
<NavItem href="/assistant" icon={Brain} label="FlowPilot" iconColor={NAV_COLORS.flowPilot} collapsed />
<NavItem href="/scripts" icon={Code2} label="Script Library" iconColor={NAV_COLORS.scripts} collapsed />
<NavItem href="/my-trees" icon={Wrench} label="Flow Editor" iconColor={NAV_COLORS.editor} collapsed />
<NavItem href="/flow-assist" icon={WandSparkles} label="Flow Assist" iconColor={NAV_COLORS.flowAssist} collapsed />
<NavItem href="/step-library" icon={Library} label="Step Library" iconColor={NAV_COLORS.stepLib} collapsed />
<NavItem href="/kb-accelerator" icon={Lightbulb} label="KB Accelerator" iconColor={NAV_COLORS.kb} collapsed />
<NavItem href="/shares" icon={FileOutput} label="Exports" iconColor={NAV_COLORS.exports} collapsed />
<NavItem href="/analytics" icon={BarChart3} label="Analytics" iconColor={NAV_COLORS.analytics} collapsed />
<NavItem href="/guides" icon={BookOpen} label="User Guides" iconColor={NAV_COLORS.guides} collapsed />
<NavItem href="/feedback" icon={MessageSquareText} label="Feedback" iconColor={NAV_COLORS.feedback} collapsed />
</div>
</>
) : (
<>
{/* Pinned Flows */}
<PinnedFlowsSection flows={pinnedItems} onUnpin={unpinFlow} />
{/* Stats Bar */}
<SidebarStatsBar
resolved={stats?.resolved_today ?? 0}
active={stats?.active_count ?? 0}
completedMinutes={stats?.total_session_minutes_today ?? 0}
activeSessionStartTimes={stats?.active_sessions.map(s => s.started_at) ?? []}
/>
{/* Activity Feed */}
<SidebarActivityFeed
activeSessions={stats?.active_sessions ?? []}
recentCompletions={stats?.recent_completions ?? []}
totalActive={stats?.active_count ?? 0}
/>
<div style={{ borderBottom: '1px solid var(--glass-border)' }} />
{/* Primary Navigation */}
{/* Navigation */}
<div className="px-3 py-2 space-y-0.5">
<NavItem href="/" icon={LayoutGrid} label="Dashboard" />
{/* Dashboard (standalone) */}
<NavItem href="/" icon={LayoutGrid} label="Dashboard" iconColor={NAV_COLORS.dashboard} />
{/* Resolve */}
<div className="font-label text-[0.5625rem] uppercase tracking-[0.12em] text-[#5a6170] px-3 pt-3 pb-1">
Resolve
</div>
<NavItem href="/sessions" icon={Clock} label="Sessions" badge={stats?.active_count || undefined} iconColor={NAV_COLORS.sessions} />
<NavItem
href="/trees"
icon={Box}
icon={Network}
label="All Flows"
badge={treeCounts.total || undefined}
badge={stats?.tree_counts.total || undefined}
iconColor={NAV_COLORS.flows}
matchPaths={['/trees', '/flows']}
children={[
{ href: '/trees?type=troubleshooting', label: 'Troubleshooting', count: treeCounts.troubleshooting || undefined },
{ href: '/trees?type=procedural', label: 'Projects', count: treeCounts.procedural || undefined },
{ href: '/trees?type=maintenance', label: 'Maintenance', count: treeCounts.maintenance || undefined },
{ href: '/trees?type=troubleshooting', label: 'Troubleshooting', count: stats?.tree_counts.troubleshooting || undefined },
{ href: '/trees?type=procedural', label: 'Projects', count: stats?.tree_counts.procedural || undefined },
{ href: '/trees?type=maintenance', label: 'Maintenance', count: stats?.tree_counts.maintenance || undefined },
]}
/>
<NavItem href="/my-trees" icon={PenLine} label="Flow Editor" />
<NavItem href="/sessions" icon={Clock} label="Sessions" badge={activeSessionCount || undefined} />
<NavItem href="/shares" icon={FileText} label="Exports" />
<NavItem href="/assistant" icon={BotMessageSquare} label="AI Assistant" />
<NavItem href="/step-library" icon={Bookmark} label="Step Library" />
<NavItem href="/scripts" icon={Terminal} label="Script Library" />
<NavItem href="/kb-accelerator" icon={Sparkles} label="KB Accelerator" />
<NavItem href="/analytics" icon={BarChart3} label="Analytics" />
<NavItem href="/assistant" icon={Brain} label="FlowPilot" iconColor={NAV_COLORS.flowPilot} />
<NavItem href="/scripts" icon={Code2} label="Script Library" iconColor={NAV_COLORS.scripts} />
{/* Build */}
<div className="font-label text-[0.5625rem] uppercase tracking-[0.12em] text-[#5a6170] px-3 pt-3 pb-1">
Build
</div>
<NavItem href="/my-trees" icon={Wrench} label="Flow Editor" iconColor={NAV_COLORS.editor} />
<NavItem href="/flow-assist" icon={WandSparkles} label="Flow Assist" iconColor={NAV_COLORS.flowAssist} />
<NavItem href="/step-library" icon={Library} label="Step Library" iconColor={NAV_COLORS.stepLib} />
<NavItem href="/kb-accelerator" icon={Lightbulb} label="KB Accelerator" iconColor={NAV_COLORS.kb} />
{/* Insights */}
<div className="font-label text-[0.5625rem] uppercase tracking-[0.12em] text-[#5a6170] px-3 pt-3 pb-1">
Insights
</div>
<NavItem href="/shares" icon={FileOutput} label="Exports" iconColor={NAV_COLORS.exports} />
<NavItem href="/analytics" icon={BarChart3} label="Analytics" iconColor={NAV_COLORS.analytics} />
</div>
</>
)}
@@ -137,8 +175,8 @@ export function Sidebar() {
>
{!sidebarCollapsed && (
<>
<NavItem href="/guides" icon={BookOpen} label="User Guides" />
<NavItem href="/feedback" icon={MessageSquareText} label="Feedback" />
<NavItem href="/guides" icon={BookOpen} label="User Guides" iconColor={NAV_COLORS.guides} />
<NavItem href="/feedback" icon={MessageSquareText} label="Feedback" iconColor={NAV_COLORS.feedback} />
<NavItem href="/account" icon={Settings} label="Account" />
</>
)}

View File

@@ -1,5 +1,5 @@
import { Link } from 'react-router-dom'
import { Pencil, Globe, Lock, Trash2, GitBranch, FileText, Wrench, Star, Download, ClipboardList } from 'lucide-react'
import { Pencil, Globe, Lock, Trash2, GitBranch, FileText, Wrench, Download, ClipboardList } from 'lucide-react'
import type { TreeListItem } from '@/types'
import { TagBadges } from '@/components/common/TagBadges'
import { StaggerList } from '@/components/common/StaggerList'
@@ -16,9 +16,6 @@ interface TreeGridViewProps {
onDeleteTree: (tree: TreeListItem) => void
onForkTree?: (treeId: string) => void
onExportTree?: (treeId: string) => void
pinnedTreeIds?: Set<string>
onTogglePin?: (treeId: string) => void
pinLoadingTreeIds?: Set<string>
}
export function TreeGridView({
@@ -29,9 +26,6 @@ export function TreeGridView({
onDeleteTree,
onForkTree,
onExportTree,
pinnedTreeIds,
onTogglePin,
pinLoadingTreeIds,
}: TreeGridViewProps) {
const { canEditTree, canDeleteTree } = usePermissions()
@@ -64,26 +58,6 @@ export function TreeGridView({
)}
</div>
<div className="flex items-center gap-2">
{onTogglePin && (
<button
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
onTogglePin(tree.id)
}}
disabled={pinLoadingTreeIds?.has(tree.id)}
aria-label={pinnedTreeIds?.has(tree.id) ? 'Remove from favorites' : 'Add to favorites'}
className={cn(
'rounded-md p-1 transition-colors',
pinnedTreeIds?.has(tree.id)
? 'text-amber-400 hover:text-amber-300'
: 'text-muted-foreground/40 hover:text-amber-400',
pinLoadingTreeIds?.has(tree.id) && 'opacity-50 pointer-events-none'
)}
>
<Star size={14} fill={pinnedTreeIds?.has(tree.id) ? 'currentColor' : 'none'} />
</button>
)}
{tree.is_public ? (
<span title="Public tree">
<Globe className="h-4 w-4 text-muted-foreground" />

View File

@@ -1,5 +1,5 @@
import { Link } from 'react-router-dom'
import { Pencil, Globe, Lock, GitBranch, FileText, Trash2, Wrench, Star, Download, ClipboardList } from 'lucide-react'
import { Pencil, Globe, Lock, GitBranch, FileText, Trash2, Wrench, Download, ClipboardList } from 'lucide-react'
import type { TreeListItem } from '@/types'
import { TagBadges } from '@/components/common/TagBadges'
import { cn } from '@/lib/utils'
@@ -15,9 +15,6 @@ interface TreeListViewProps {
onDeleteTree: (tree: TreeListItem) => void
onForkTree?: (treeId: string) => void
onExportTree?: (treeId: string) => void
pinnedTreeIds?: Set<string>
onTogglePin?: (treeId: string) => void
pinLoadingTreeIds?: Set<string>
}
export function TreeListView({
@@ -28,9 +25,6 @@ export function TreeListView({
onDeleteTree,
onForkTree,
onExportTree,
pinnedTreeIds,
onTogglePin,
pinLoadingTreeIds,
}: TreeListViewProps) {
const { canEditTree } = usePermissions()
@@ -99,26 +93,6 @@ export function TreeListView({
</div>
<div className="flex items-center gap-2">
{onTogglePin && (
<button
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
onTogglePin(tree.id)
}}
disabled={pinLoadingTreeIds?.has(tree.id)}
aria-label={pinnedTreeIds?.has(tree.id) ? 'Remove from favorites' : 'Add to favorites'}
className={cn(
'shrink-0 rounded-md p-1 transition-colors',
pinnedTreeIds?.has(tree.id)
? 'text-amber-400 hover:text-amber-300'
: 'text-muted-foreground/40 hover:text-amber-400',
pinLoadingTreeIds?.has(tree.id) && 'opacity-50 pointer-events-none'
)}
>
<Star size={16} fill={pinnedTreeIds?.has(tree.id) ? 'currentColor' : 'none'} />
</button>
)}
{onExportTree && (
<button
type="button"

View File

@@ -1,6 +1,6 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { Pencil, Globe, Lock, ChevronUp, ChevronDown, GitBranch, FileText, Trash2, Wrench, Star, Download, ClipboardList } from 'lucide-react'
import { Pencil, Globe, Lock, ChevronUp, ChevronDown, GitBranch, FileText, Trash2, Wrench, Download, ClipboardList } from 'lucide-react'
import type { TreeListItem } from '@/types'
import { TagBadges } from '@/components/common/TagBadges'
import { cn } from '@/lib/utils'
@@ -17,9 +17,6 @@ interface TreeTableViewProps {
onSortChange?: (sortBy: string) => void
onForkTree?: (treeId: string) => void
onExportTree?: (treeId: string) => void
pinnedTreeIds?: Set<string>
onTogglePin?: (treeId: string) => void
pinLoadingTreeIds?: Set<string>
}
type SortColumn = 'name' | 'category' | 'version' | 'usage' | 'updated'
@@ -33,9 +30,6 @@ export function TreeTableView({
onSortChange,
onForkTree,
onExportTree,
pinnedTreeIds,
onTogglePin,
pinLoadingTreeIds,
}: TreeTableViewProps) {
const { canEditTree } = usePermissions()
const [sortColumn, setSortColumn] = useState<SortColumn | null>(null)
@@ -83,11 +77,6 @@ export function TreeTableView({
<table className="w-full">
<thead className="bg-accent/50 sticky top-0 z-10">
<tr className="border-b border-border">
{onTogglePin && (
<th className="w-10 px-2 py-3 text-center">
<Star size={14} className="inline text-muted-foreground" />
</th>
)}
<th
className="px-4 py-3 text-left text-sm font-medium text-muted-foreground cursor-pointer hover:text-foreground"
onClick={() => handleSort('name')}
@@ -147,28 +136,6 @@ export function TreeTableView({
<tbody className="bg-transparent">
{trees.map((tree) => (
<tr key={tree.id} className="border-b border-border last:border-0 hover:bg-accent/50">
{onTogglePin && (
<td className="w-10 px-2 py-3 text-center">
<button
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
onTogglePin(tree.id)
}}
disabled={pinLoadingTreeIds?.has(tree.id)}
aria-label={pinnedTreeIds?.has(tree.id) ? 'Remove from favorites' : 'Add to favorites'}
className={cn(
'rounded-md p-1 transition-colors',
pinnedTreeIds?.has(tree.id)
? 'text-amber-400 hover:text-amber-300'
: 'text-muted-foreground/40 hover:text-amber-400',
pinLoadingTreeIds?.has(tree.id) && 'opacity-50 pointer-events-none'
)}
>
<Star size={14} fill={pinnedTreeIds?.has(tree.id) ? 'currentColor' : 'none'} />
</button>
</td>
)}
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<span className="font-medium text-foreground truncate max-w-[200px]">

View File

@@ -0,0 +1,105 @@
import { useNavigate } from 'react-router-dom'
import { getTreeNavigatePath } from '@/lib/routing'
import { cn } from '@/lib/utils'
interface ActivityItemProps {
sessionId: string
treeName: string
treeId: string
treeType: 'troubleshooting' | 'procedural' | 'maintenance'
status: 'active' | 'paused' | 'recent'
ticketNumber?: string | null
timestamp?: string | null
}
function formatRelativeTime(dateString: string): string {
const now = Date.now()
const then = new Date(dateString).getTime()
const diffMinutes = Math.floor((now - then) / 60000)
if (diffMinutes < 1) return 'just now'
if (diffMinutes < 60) return `${diffMinutes}m ago`
const diffHours = Math.floor(diffMinutes / 60)
if (diffHours < 24) return `${diffHours}h ago`
return 'yesterday'
}
export function ActivityItem({
sessionId,
treeName,
treeId,
treeType,
status,
ticketNumber,
timestamp,
}: ActivityItemProps) {
const navigate = useNavigate()
const handleClick = () => {
navigate(getTreeNavigatePath(treeId, treeType), {
state: { sessionId },
})
}
const isRecent = status === 'recent'
return (
<button
onClick={handleClick}
className={cn(
'flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-left transition-colors',
'hover:bg-[rgba(255,255,255,0.03)]',
isRecent ? 'text-[#6b7280] text-[0.72rem]' : 'text-[#e2e8f0] text-[0.8rem]'
)}
title={`${treeName}${ticketNumber ? ` (${ticketNumber})` : ''} — click to resume`}
aria-label={
status === 'active'
? `Active session: ${treeName}`
: status === 'paused'
? `Paused session: ${treeName}`
: `Recent session: ${treeName}`
}
>
{/* Status dot */}
{status === 'active' && (
<span
className="h-[7px] w-[7px] shrink-0 rounded-full"
style={{
background: '#34d399',
boxShadow: '0 0 6px rgba(52,211,153,0.5)',
animation: 'pulse-dot 2s ease-in-out infinite',
}}
aria-label="Active session"
/>
)}
{status === 'paused' && (
<span
className="h-[7px] w-[7px] shrink-0 rounded-full"
style={{
background: '#f59e0b',
boxShadow: '0 0 4px rgba(245,158,11,0.3)',
}}
aria-label="Paused session"
/>
)}
{status === 'recent' && (
<span className="h-1 w-1 shrink-0 rounded-full bg-[#3d4350]" />
)}
{/* Flow name */}
<span className="flex-1 truncate">{treeName}</span>
{/* Ticket number or timestamp */}
{ticketNumber && !isRecent && (
<span className="shrink-0 font-label text-[0.5625rem] text-[#60a5fa]">
{ticketNumber}
</span>
)}
{isRecent && timestamp && (
<span className="shrink-0 font-label text-[0.5625rem] text-[#5a6170]">
{formatRelativeTime(timestamp)}
</span>
)}
</button>
)
}

View File

@@ -1,98 +0,0 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { ChevronDown, ChevronRight, Pin } from 'lucide-react'
import { getTreeNavigatePath } from '@/lib/routing'
import { cn } from '@/lib/utils'
import type { PinnedFlow } from '@/api/pinnedFlows'
interface PinnedFlowsSectionProps {
flows: PinnedFlow[]
onUnpin: (treeId: string) => void
}
const TRUNCATE_COUNT = 5
export function PinnedFlowsSection({ flows, onUnpin }: PinnedFlowsSectionProps) {
const navigate = useNavigate()
const [collapsed, setCollapsed] = useState(false)
const [showAll, setShowAll] = useState(false)
const handleToggleCollapse = () => {
if (collapsed) {
setShowAll(false) // Reset to truncated on re-expand
}
setCollapsed(!collapsed)
}
const visibleFlows = showAll ? flows : flows.slice(0, TRUNCATE_COUNT)
const hasMore = flows.length > TRUNCATE_COUNT
const handleFlowClick = (flow: PinnedFlow) => {
setShowAll(false) // Collapse back to 5 on navigation
navigate(getTreeNavigatePath(flow.tree_id, flow.tree_type))
}
return (
<div className="px-3 py-2">
<button
onClick={handleToggleCollapse}
className="flex w-full items-center gap-1 px-3 mb-1 font-heading text-[0.6875rem] font-bold uppercase tracking-[0.04em] text-muted-foreground hover:text-foreground transition-colors"
>
{collapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
Pinned
{flows.length > 0 && (
<span className="ml-auto text-[0.625rem] font-normal">{flows.length}</span>
)}
</button>
<div
className="overflow-hidden transition-[max-height] duration-250 ease-out"
style={{
maxHeight: collapsed ? 0 : showAll
? `${flows.length * 36 + 40}px`
: `${Math.min(flows.length, TRUNCATE_COUNT) * 36 + 40}px`,
}}
>
<div className="space-y-0.5">
{flows.length === 0 ? (
<p className="px-3 py-2 text-xs text-muted-foreground">
Pin your most-used flows here
</p>
) : (
<>
{visibleFlows.map(flow => (
<button
key={flow.tree_id}
onClick={() => handleFlowClick(flow)}
onContextMenu={(e) => {
e.preventDefault()
onUnpin(flow.tree_id)
}}
className={cn(
'group flex w-full items-center gap-2.5 rounded-lg px-3 py-1.5 text-[0.8125rem] font-medium transition-colors',
'text-muted-foreground hover:bg-[var(--sidebar-hover)] hover:text-foreground'
)}
title={`${flow.tree_name} (right-click to unpin)`}
>
<span className="text-sm shrink-0">
{flow.tree_type === 'procedural' ? '📋' : flow.tree_type === 'maintenance' ? '🛠️' : '🔧'}
</span>
<span className="truncate flex-1 text-left">{flow.tree_name}</span>
<Pin size={12} className="shrink-0 opacity-0 group-hover:opacity-40 transition-opacity" />
</button>
))}
{hasMore && (
<button
onClick={() => setShowAll(!showAll)}
className="w-full px-3 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors text-left"
>
{showAll ? 'Show less' : `Show more (${flows.length})`}
</button>
)}
</>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,80 @@
import { Clock } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { ActivityItem } from './ActivityItem'
import type { SidebarActiveSession, SidebarRecentSession } from '@/api/sidebar'
interface SidebarActivityFeedProps {
activeSessions: SidebarActiveSession[]
recentCompletions: SidebarRecentSession[]
totalActive: number
}
export function SidebarActivityFeed({
activeSessions,
recentCompletions,
totalActive,
}: SidebarActivityFeedProps) {
const navigate = useNavigate()
const hasActivity = activeSessions.length > 0 || recentCompletions.length > 0
return (
<div className="px-3 pb-1">
{/* Header */}
<div className="flex items-center gap-1.5 px-2.5 py-1 mb-0.5">
<Clock size={10} style={{ color: '#34d399' }} />
<span className="font-label text-[0.5625rem] uppercase tracking-[0.12em] text-[#5a6170]">
Activity
</span>
</div>
{!hasActivity ? (
<p className="px-2.5 py-2 text-xs text-muted-foreground">
No activity today
</p>
) : (
<div className="space-y-0.5">
{/* Active sessions */}
{activeSessions.map((session) => (
<ActivityItem
key={session.session_id}
sessionId={session.session_id}
treeName={session.tree_name}
treeId={session.tree_id}
treeType={session.tree_type}
status="active"
ticketNumber={session.ticket_number || session.psa_ticket_id}
/>
))}
{/* Overflow link */}
{totalActive > 5 && (
<button
onClick={() => navigate('/sessions')}
className="w-full px-2.5 py-1 text-left text-[0.6875rem] text-muted-foreground hover:text-foreground transition-colors"
>
View all in Sessions
</button>
)}
{/* Divider between active and recent */}
{activeSessions.length > 0 && recentCompletions.length > 0 && (
<div className="mx-2.5 my-1" style={{ height: '1px', background: 'rgba(255,255,255,0.03)' }} />
)}
{/* Recent completions */}
{recentCompletions.map((session) => (
<ActivityItem
key={session.session_id}
sessionId={session.session_id}
treeName={session.tree_name}
treeId={session.tree_id}
treeType={session.tree_type}
status="recent"
timestamp={session.completed_at}
/>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,93 @@
import { useEffect, useState } from 'react'
interface SidebarStatsBarProps {
resolved: number
active: number
/** Minutes from completed sessions today (server-computed) */
completedMinutes: number
/** Start times of currently active sessions (ISO strings) */
activeSessionStartTimes: string[]
}
function formatDuration(totalSeconds: number): string {
if (totalSeconds < 60) return `${totalSeconds}s`
const totalMinutes = Math.floor(totalSeconds / 60)
if (totalMinutes < 60) return `${totalMinutes}m`
const h = Math.floor(totalMinutes / 60)
const m = totalMinutes % 60
return m > 0 ? `${h}h ${m}m` : `${h}h`
}
function parseUTCTimestamp(st: string): number {
// Backend returns naive UTC timestamps without 'Z' suffix.
// JS Date() treats bare strings as local time, so append Z to force UTC.
return new Date(st.endsWith('Z') ? st : st + 'Z').getTime()
}
function calcActiveSeconds(startTimes: string[]): number {
const now = Date.now()
return startTimes.reduce((sum, st) => {
const elapsed = Math.floor((now - parseUTCTimestamp(st)) / 1000)
return sum + Math.max(0, elapsed)
}, 0)
}
export function SidebarStatsBar({ resolved, active, completedMinutes, activeSessionStartTimes }: SidebarStatsBarProps) {
const [liveSeconds, setLiveSeconds] = useState(() => calcActiveSeconds(activeSessionStartTimes))
// Tick every second to keep the timer in sync with the session timer
useEffect(() => {
setLiveSeconds(calcActiveSeconds(activeSessionStartTimes))
if (activeSessionStartTimes.length === 0) return
const interval = setInterval(() => {
setLiveSeconds(calcActiveSeconds(activeSessionStartTimes))
}, 1000)
return () => clearInterval(interval)
}, [activeSessionStartTimes])
const totalSeconds = (completedMinutes * 60) + liveSeconds
return (
<div
className="flex gap-0.5 px-3 pt-2 pb-1"
role="group"
aria-label="Today's stats"
>
<div className="flex-1 rounded-md bg-[rgba(255,255,255,0.02)] px-1 py-1.5 text-center">
<div
className="font-label text-sm font-semibold leading-none"
style={{ color: '#34d399' }}
aria-label={`${resolved} resolved today`}
>
{resolved}
</div>
<div className="mt-1 font-label text-[7px] uppercase tracking-[0.1em] text-[#3d4350]">
Resolved
</div>
</div>
<div className="flex-1 rounded-md bg-[rgba(255,255,255,0.02)] px-1 py-1.5 text-center">
<div
className="font-label text-sm font-semibold leading-none"
style={{ color: '#22d3ee' }}
aria-label={`${active} active sessions`}
>
{active}
</div>
<div className="mt-1 font-label text-[7px] uppercase tracking-[0.1em] text-[#3d4350]">
Active
</div>
</div>
<div className="flex-1 rounded-md bg-[rgba(255,255,255,0.02)] px-1 py-1.5 text-center">
<div
className="font-label text-sm font-semibold leading-none text-muted-foreground"
aria-label={`${formatDuration(totalSeconds)} total session time today`}
>
{formatDuration(totalSeconds)}
</div>
<div className="mt-1 font-label text-[7px] uppercase tracking-[0.1em] text-[#3d4350]">
Total Time
</div>
</div>
</div>
)
}