refactor: resolve merge conflicts — combine main improvements with token normalization
- .gitignore: keep both graphify-out/ entries and main's .gitnexus entry - ScriptCodeBlock/ScriptPreviewModal: take main's border-border and text-accent-text for filename labels; use neutral ghost style for Save button in ScriptCodeBlock; use bg-accent (normalized from bg-primary) for Save button in ScriptPreviewModal Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,12 +3,21 @@ import { useNavigate } from 'react-router-dom'
|
||||
import { AlertTriangle, Clock, Hash, Ticket, Loader2, RefreshCw } from 'lucide-react'
|
||||
import { aiSessionsApi } from '@/api'
|
||||
import type { AISessionSummary } from '@/types/ai-session'
|
||||
import { timeAgo } from '@/lib/timeAgo'
|
||||
|
||||
interface EscalationQueueProps {
|
||||
onPickup?: (sessionId: string) => void
|
||||
onCountChange?: (count: number) => void
|
||||
}
|
||||
|
||||
export function EscalationQueue({ onPickup }: EscalationQueueProps) {
|
||||
function waitTimeColor(createdAt: string): string {
|
||||
const hours = (Date.now() - new Date(createdAt).getTime()) / 3_600_000
|
||||
if (hours >= 4) return '#f87171' // danger
|
||||
if (hours >= 1) return '#fbbf24' // warning/amber
|
||||
return '#848b9b' // muted
|
||||
}
|
||||
|
||||
export function EscalationQueue({ onPickup, onCountChange }: EscalationQueueProps) {
|
||||
const navigate = useNavigate()
|
||||
const [sessions, setSessions] = useState<AISessionSummary[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
@@ -19,7 +28,12 @@ export function EscalationQueue({ onPickup }: EscalationQueueProps) {
|
||||
setError(null)
|
||||
try {
|
||||
const data = await aiSessionsApi.getEscalationQueue()
|
||||
setSessions(data)
|
||||
// Sort oldest-first — longest waiting = most urgent
|
||||
const sorted = [...data].sort(
|
||||
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
|
||||
)
|
||||
setSessions(sorted)
|
||||
onCountChange?.(sorted.length)
|
||||
} catch {
|
||||
setError('Failed to load escalation queue')
|
||||
} finally {
|
||||
@@ -29,6 +43,7 @@ export function EscalationQueue({ onPickup }: EscalationQueueProps) {
|
||||
|
||||
useEffect(() => {
|
||||
loadQueue()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- load once on mount
|
||||
}, [])
|
||||
|
||||
const handlePickup = (sessionId: string) => {
|
||||
@@ -80,7 +95,7 @@ export function EscalationQueue({ onPickup }: EscalationQueueProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<h3 className="font-sans text-xs text-[0.625rem] uppercase tracking-wider text-text-muted">
|
||||
<h3 className="font-sans text-[0.625rem] uppercase tracking-wider text-muted-foreground">
|
||||
Awaiting pickup ({sessions.length})
|
||||
</h3>
|
||||
<button
|
||||
@@ -93,7 +108,7 @@ export function EscalationQueue({ onPickup }: EscalationQueueProps) {
|
||||
</div>
|
||||
|
||||
{sessions.map((session) => (
|
||||
<div key={session.id} className="card-interactive p-3 sm:p-4 space-y-3">
|
||||
<div key={session.id} className="card-flat p-3 sm:p-4 space-y-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
{session.problem_summary || 'Untitled session'}
|
||||
@@ -107,7 +122,7 @@ export function EscalationQueue({ onPickup }: EscalationQueueProps) {
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
{session.problem_domain && (
|
||||
<span className="font-sans text-xs rounded-md bg-accent-dim px-1.5 py-0.5 text-[0.5625rem] uppercase tracking-wider text-primary">
|
||||
<span className="font-sans rounded-md bg-accent-dim px-1.5 py-0.5 text-[0.5625rem] uppercase tracking-wider text-accent-text">
|
||||
{session.problem_domain}
|
||||
</span>
|
||||
)}
|
||||
@@ -115,24 +130,29 @@ export function EscalationQueue({ onPickup }: EscalationQueueProps) {
|
||||
<Hash size={10} />
|
||||
{session.step_count} steps
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span
|
||||
className="flex items-center gap-1 font-medium"
|
||||
style={{ color: waitTimeColor(session.created_at) }}
|
||||
>
|
||||
<Clock size={10} />
|
||||
{new Date(session.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
{timeAgo(session.created_at)}
|
||||
</span>
|
||||
{session.psa_ticket_id && (
|
||||
<span className="flex items-center gap-1 text-primary">
|
||||
<span className="flex items-center gap-1 text-accent-text">
|
||||
<Ticket size={10} />
|
||||
#{session.psa_ticket_id}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handlePickup(session.id)}
|
||||
className="w-full min-h-[44px] rounded-lg bg-primary text-white px-4 py-2 text-sm font-semibold hover:brightness-110 active:scale-[0.98] transition-all"
|
||||
>
|
||||
Pick Up Session
|
||||
</button>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={() => handlePickup(session.id)}
|
||||
className="rounded-lg bg-primary text-white px-4 py-2 text-sm font-semibold hover:brightness-110 active:scale-[0.98] transition-all"
|
||||
>
|
||||
Pick Up
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -187,6 +187,23 @@ export function SessionDocView({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Follow-up recommendations */}
|
||||
{documentation.follow_up_recommendations.length > 0 && (
|
||||
<div className="card-flat p-3 sm:p-4">
|
||||
<h4 className="font-sans text-xs text-[0.625rem] uppercase tracking-wider text-muted-foreground mb-2">
|
||||
Follow-up
|
||||
</h4>
|
||||
<ul className="space-y-1">
|
||||
{documentation.follow_up_recommendations.map((rec, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm text-foreground">
|
||||
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-primary" />
|
||||
{rec}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rating */}
|
||||
{onRate && (
|
||||
<div className="card-flat p-3 sm:p-4 text-center">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { FileText, User, Mail, Zap, AlignLeft, Copy, Check, RotateCcw, ArrowLeftRight, Loader2 } from 'lucide-react'
|
||||
import { FileText, User, Mail, HelpCircle, Zap, AlignLeft, Copy, Check, RotateCcw, ArrowLeftRight, Loader2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
import type { StatusUpdateAudience, StatusUpdateLength, StatusUpdateContext, StatusUpdateResponse } from '@/types/ai-session'
|
||||
@@ -12,10 +12,11 @@ interface StatusUpdateModalProps {
|
||||
hasPsaTicket?: boolean
|
||||
}
|
||||
|
||||
const AUDIENCES: { value: StatusUpdateAudience; icon: typeof FileText; label: string; description: string }[] = [
|
||||
const AUDIENCES: { value: StatusUpdateAudience; icon: typeof FileText; label: string; description: string; skipLength?: boolean }[] = [
|
||||
{ value: 'ticket_notes', icon: FileText, label: 'Ticket Notes', description: 'Technical, for your PSA' },
|
||||
{ value: 'client_update', icon: User, label: 'Client Update', description: 'Professional, non-technical' },
|
||||
{ value: 'email_draft', icon: Mail, label: 'Email Draft', description: 'Full email with subject line' },
|
||||
{ value: 'request_info', icon: HelpCircle, label: 'Request Information', description: 'Ask the client specific questions', skipLength: true },
|
||||
]
|
||||
|
||||
const LENGTHS: { value: StatusUpdateLength; icon: typeof Zap; label: string; description: string }[] = [
|
||||
@@ -38,9 +39,24 @@ export function StatusUpdateModal({ open, onClose, onGenerate, context = 'status
|
||||
escalation: 'Share Escalation',
|
||||
}
|
||||
|
||||
const handleAudienceSelect = (value: StatusUpdateAudience) => {
|
||||
const handleAudienceSelect = async (value: StatusUpdateAudience) => {
|
||||
setAudience(value)
|
||||
setStep('length')
|
||||
const opt = AUDIENCES.find(a => a.value === value)
|
||||
if (opt?.skipLength) {
|
||||
// Skip length selection — always concise for request_info
|
||||
setLength('quick')
|
||||
setStep('generating')
|
||||
try {
|
||||
const res = await onGenerate(value, 'quick', context)
|
||||
setResult(res)
|
||||
setStep('result')
|
||||
} catch {
|
||||
setStep('audience')
|
||||
setAudience(null)
|
||||
}
|
||||
} else {
|
||||
setStep('length')
|
||||
}
|
||||
}
|
||||
|
||||
const handleLengthSelect = async (value: StatusUpdateLength) => {
|
||||
@@ -170,7 +186,7 @@ export function StatusUpdateModal({ open, onClose, onGenerate, context = 'status
|
||||
<div className="flex flex-col items-center justify-center py-8 gap-3">
|
||||
<Loader2 size={24} className="animate-spin text-accent" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Generating {audience === 'email_draft' ? 'email draft' : audience === 'client_update' ? 'client update' : 'ticket notes'}...
|
||||
{audience === 'request_info' ? 'Drafting information request...' : audience === 'email_draft' ? 'Generating email draft...' : audience === 'client_update' ? 'Generating client update...' : 'Generating ticket notes...'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user