Files
resolutionflow/frontend/src/components/assistant/ChatSidebar.tsx
Michael Chihlas e8ba74ed6d
All checks were successful
Mirror to GitHub / mirror (push) Successful in 6m5s
CI / frontend (pull_request) Successful in 11m59s
CI / e2e (pull_request) Successful in 10m7s
CI / backend (pull_request) Successful in 16m22s
feat(escalations): distinguishable notifications, async AI, richer sidebar
Three improvements driven by live wedge testing.

1) Notification title now includes a problem snippet and PSA ticket
   suffix when present:
     "Escalation from Jane · #12345: Outlook is failing to sync email…"
   Replaces the prior "Session escalated by Jane" copy that made every
   escalation from the same junior look identical in the bell panel.
   Snippet is trimmed to 70 chars with ellipsis. handoff_manager now
   passes psa_ticket_id through in the notify() payload so this works
   for both /escalate and /handoff entry points.

2) AI enrichment (assessment + enhanced escalation_package) moved to
   a FastAPI BackgroundTask. The escalating engineer no longer waits
   on 15-25s of Sonnet latency — handoff creation returns as soon as
   snapshot, status flip, dual-write, documentation, PSA push, and
   notify() are committed. enrich_escalation_async opens its own DB
   session, runs both AI calls, updates handoff.ai_assessment +
   session.escalation_package, commits, and publishes a new
   `handoff_assessment_ready` event on the escalation bus. Frontend
   doesn't yet listen for that event — the magic-moment screen still
   shows a placeholder ("AI assessment is still generating. Reopen
   this view in a few seconds…") which is honest about the state.
   Live polling / auto-refresh on the bus event is the natural next
   step.

3) ChatSidebar entries now surface the problem summary as a secondary
   line and tag PSA-linked sessions with a monospace #ticket badge plus
   an "Escalated" pill on in-transit sessions. ChatListItem grew
   problem_summary, psa_ticket_id, and status fields; loadChats
   populates them from listSessions. The user couldn't tell their own
   sessions apart in the sidebar because they all rendered as "New
   Chat" with no distinguishing detail — this fixes that for any
   session, escalated or not.

Test plan
- Backend full suite: 1103 passed in 255.85s with -n auto.
- Frontend tsc -b clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 00:34:32 -04:00

271 lines
8.9 KiB
TypeScript

import { useState } from 'react'
import { Plus, Pin, Trash2, MessageSquare, History, X } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { ChatListItem } from '@/types/assistant-chat'
interface ChatSidebarProps {
chats: ChatListItem[]
activeChatId: string | null
onSelectChat: (id: string) => void
onNewChat: () => void
onDeleteChat: (id: string) => void
onTogglePin: (id: string, pinned: boolean) => void
mobileOpen?: boolean
onMobileClose?: () => void
collapsed?: boolean
onToggleCollapse?: () => void
}
export function ChatSidebar({
chats,
activeChatId,
onSelectChat,
onNewChat,
onDeleteChat,
onTogglePin,
mobileOpen = false,
onMobileClose,
collapsed = false,
onToggleCollapse,
}: ChatSidebarProps) {
const pinnedChats = chats.filter(c => c.pinned)
const unpinnedChats = chats.filter(c => !c.pinned)
const handleSelectChat = (id: string) => {
onSelectChat(id)
onMobileClose?.()
}
const handleNewChat = () => {
onNewChat()
onMobileClose?.()
}
// When collapsed on desktop, render nothing — parent renders the top bar
if (collapsed && !mobileOpen) {
return null
}
return (
<>
{/* Mobile overlay */}
{mobileOpen && (
<div className="fixed inset-0 z-40 bg-black/50 sm:hidden" onClick={onMobileClose} />
)}
<div
className={cn(
'w-72 shrink-0 flex flex-col border-r h-full',
'fixed inset-y-0 left-0 z-50 sm:static sm:z-auto',
'transition-transform duration-200',
mobileOpen ? 'translate-x-0' : '-translate-x-full sm:translate-x-0',
)}
style={{ background: 'var(--color-bg-sidebar)' }}
>
{/* Header */}
<div className="px-3 py-3 border-b shrink-0 flex items-center gap-2" style={{ borderColor: 'var(--color-border-default)' }}>
<button
onClick={handleNewChat}
className="flex-1 flex items-center justify-center gap-2 bg-primary text-white font-semibold text-sm rounded-lg px-4 py-2.5 hover:brightness-110 active:scale-[0.98] transition-all"
>
<Plus size={16} />
New Chat
</button>
{onToggleCollapse && (
<button
onClick={onToggleCollapse}
className="hidden sm:flex p-1.5 rounded-lg text-muted-foreground hover:text-heading hover:bg-elevated transition-colors"
title="Collapse to top bar"
>
<X size={16} />
</button>
)}
</div>
{/* Chat list */}
<div className="flex-1 overflow-y-auto py-2">
{pinnedChats.length > 0 && (
<div className="px-3 mb-1">
<span className="font-sans text-[0.625rem] uppercase tracking-widest text-muted-foreground">
Pinned
</span>
</div>
)}
{pinnedChats.map(chat => (
<ChatItem
key={chat.id}
chat={chat}
isActive={chat.id === activeChatId}
onSelect={() => handleSelectChat(chat.id)}
onDelete={() => onDeleteChat(chat.id)}
onTogglePin={() => onTogglePin(chat.id, !chat.pinned)}
/>
))}
{pinnedChats.length > 0 && unpinnedChats.length > 0 && (
<div className="mx-3 my-2 border-b" style={{ borderColor: 'var(--color-border-default)' }} />
)}
{unpinnedChats.map(chat => (
<ChatItem
key={chat.id}
chat={chat}
isActive={chat.id === activeChatId}
onSelect={() => handleSelectChat(chat.id)}
onDelete={() => onDeleteChat(chat.id)}
onTogglePin={() => onTogglePin(chat.id, !chat.pinned)}
/>
))}
{chats.length === 0 && (
<div className="px-4 py-8 text-center text-muted-foreground text-sm">
No conversations yet
</div>
)}
</div>
</div>
</>
)
}
/** Collapsed top bar — rendered by the parent page above the chat area */
export function ChatSidebarCollapsedBar({
chats,
activeChatId,
onNewChat,
onExpand,
}: {
chats: ChatListItem[]
activeChatId: string | null
onNewChat: () => void
onExpand: () => void
}) {
return (
<div
className="flex items-center gap-2 px-3 py-2 border-b shrink-0"
style={{ background: 'var(--color-bg-sidebar)', borderColor: 'var(--color-border-default)' }}
>
<button
onClick={onNewChat}
className="flex items-center gap-1.5 bg-primary text-white font-semibold text-xs rounded-md px-3 py-1.5 hover:brightness-110 active:scale-[0.98] transition-all"
>
<Plus size={14} />
New
</button>
<button
onClick={onExpand}
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-muted-foreground hover:text-heading hover:bg-elevated transition-colors"
title="Show chat history"
>
<History size={14} />
<span>History</span>
{chats.length > 0 && (
<span className="text-[10px] bg-elevated rounded-full px-1.5 py-0.5 font-medium">{chats.length}</span>
)}
</button>
<div className="flex-1" />
{activeChatId && (
<span className="text-xs text-muted-foreground truncate max-w-[300px]">
{chats.find(c => c.id === activeChatId)?.title}
</span>
)}
</div>
)
}
function ChatItem({
chat,
isActive,
onSelect,
onDelete,
onTogglePin,
}: {
chat: ChatListItem
isActive: boolean
onSelect: () => void
onDelete: () => void
onTogglePin: () => void
}) {
const [confirming, setConfirming] = useState(false)
return (
<div
onClick={confirming ? e => e.stopPropagation() : onSelect}
className={cn(
'group flex items-center gap-2 px-3 py-2.5 mx-1.5 rounded-lg cursor-pointer transition-colors',
confirming
? 'bg-danger-dim border border-danger/20'
: isActive
? 'bg-accent-dim text-foreground'
: 'text-muted-foreground hover:bg-input hover:text-foreground'
)}
>
<MessageSquare size={14} className="shrink-0" />
<div className="flex-1 min-w-0">
{confirming ? (
<div className="flex items-center gap-2">
<span className="text-[0.75rem] text-danger font-medium">Delete?</span>
<button
onClick={e => { e.stopPropagation(); onDelete(); setConfirming(false) }}
className="text-[0.6875rem] font-medium text-danger hover:text-danger px-1.5 py-0.5 rounded bg-danger/15 hover:bg-danger/25 transition-colors"
>
Yes
</button>
<button
onClick={e => { e.stopPropagation(); setConfirming(false) }}
className="text-[0.6875rem] text-muted-foreground hover:text-foreground px-1.5 py-0.5"
>
No
</button>
</div>
) : (
<>
<div className="flex items-center gap-1.5 min-w-0">
<div className="text-[0.8125rem] font-medium truncate">{chat.title}</div>
{chat.psa_ticket_id && (
<span className="font-mono shrink-0 rounded-md bg-accent-dim px-1.5 py-0.5 text-[0.5625rem] text-accent-text">
#{chat.psa_ticket_id}
</span>
)}
{(chat.status === 'escalated' || chat.status === 'requesting_escalation') && (
<span className="font-sans shrink-0 rounded-md bg-warning-dim px-1.5 py-0.5 text-[0.5625rem] uppercase tracking-wider text-warning border border-warning/20">
Escalated
</span>
)}
</div>
{/* Secondary line: problem snippet when the title doesn't already
carry it, otherwise the message count. Keeps untitled
sessions from collapsing into identical-looking rows. */}
{chat.problem_summary && chat.problem_summary !== chat.title ? (
<div className="text-[0.6875rem] text-muted-foreground truncate">
{chat.problem_summary}
</div>
) : (
<div className="text-[0.6875rem] text-muted-foreground">
{chat.message_count} messages
</div>
)}
</>
)}
</div>
{!confirming && (
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={e => { e.stopPropagation(); onTogglePin() }}
className="p-1 rounded hover:bg-elevated"
title={chat.pinned ? 'Unpin' : 'Pin'}
>
<Pin size={12} className={chat.pinned ? 'text-primary' : ''} />
</button>
<button
onClick={e => { e.stopPropagation(); setConfirming(true) }}
className="p-1 rounded hover:bg-elevated text-muted-foreground hover:text-danger"
title="Delete"
>
<Trash2 size={12} />
</button>
</div>
)}
</div>
)
}