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:
105
frontend/src/components/sidebar/ActivityItem.tsx
Normal file
105
frontend/src/components/sidebar/ActivityItem.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
80
frontend/src/components/sidebar/SidebarActivityFeed.tsx
Normal file
80
frontend/src/components/sidebar/SidebarActivityFeed.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
93
frontend/src/components/sidebar/SidebarStatsBar.tsx
Normal file
93
frontend/src/components/sidebar/SidebarStatsBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user