feat(escalations): magic-moment 3-option CTA + claim 500 fix

- HandoffContextScreen: 3-option layout (Continue/AI analysis/Own thing)
  with hasTaskLane, activeOptionKey, spinner/disabled states
- AssistantChatPage: wire up handleContinue, handleAIAnalysis, handleOwnThing
  handlers; chip detail expansion inline with copy-button fix; post-escalation
  redirect to dashboard on ConcludeSessionModal close
- TaskLane: fix async copy button (await + execCommand fallback + copiedKey
  visual feedback); whitespace-pre-wrap on command blocks
- Fix 500 on claim: Pydantic v2 model_validate() + model_copy(update={})
  (was passing update= kwarg directly which v2 rejects)
- HandoffResponse schema: handed_off_by_name field

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-30 00:05:02 -04:00
parent fb2dc222fd
commit db717b0b3f
11 changed files with 673 additions and 207 deletions

View File

@@ -5,7 +5,7 @@ import { handoffsApi } from '@/api/handoffs'
import { timeAgo } from '@/lib/timeAgo'
import type { HandoffResponse } from '@/types/branching'
import { HandoffContextScreen } from '@/components/flowpilot/HandoffContextScreen'
import { Sparkles, Send, Loader2, MessageSquare, Paperclip, Terminal, X, RotateCcw, ImagePlus, ListChecks, FileText, CheckCircle2, ArrowUpRight, MoreHorizontal, Pause, Plus } from 'lucide-react'
import { Sparkles, Send, Loader2, MessageSquare, Paperclip, Terminal, X, RotateCcw, ImagePlus, ListChecks, FileText, CheckCircle2, ArrowUpRight, ArrowRight, MoreHorizontal, Pause, Plus, Copy, Check } from 'lucide-react'
import { cn } from '@/lib/utils'
import { uploadsApi } from '@/api/uploads'
import type { PendingUpload } from '@/types/upload'
@@ -83,12 +83,15 @@ export default function AssistantChatPage() {
const [overlayHandoff, setOverlayHandoff] = useState<HandoffResponse | null>(null)
const [overlayLoading, setOverlayLoading] = useState(false)
const [claiming, setClaiming] = useState(false)
const [activeOptionKey, setActiveOptionKey] = useState<'continue' | 'ai' | 'own' | null>(null)
// Codex correction (locked design): once the magic-moment dissolves, the
// AI's `suggested_steps[]` should still be reachable as chips below the
// composer. Click prefills the input; first send hides the strip; explicit
// X also hides. Per-session lifetime — a refresh wipes the state, which is
// fine because the senior can re-open the Context overlay.
const [chipsHidden, setChipsHidden] = useState(false)
const [selectedChipCardIdx, setSelectedChipCardIdx] = useState<number | null>(null)
const [copiedChipCmd, setCopiedChipCmd] = useState(false)
const [chats, setChats] = useState<ChatListItem[]>([])
const [activeChatId, setActiveChatId] = useState<string | null>(() => {
if (urlSessionId) return urlSessionId
@@ -374,6 +377,65 @@ export default function AssistantChatPage() {
}
}, [urlSessionId, magicHandoff, setSearchParams])
const handleContinue = useCallback(async () => {
if (!urlSessionId || !magicHandoff) return
setActiveOptionKey('continue')
try {
await handoffsApi.claimHandoff(urlSessionId, magicHandoff.id)
setSearchParams({})
setMagicState('dismissed')
void loadChats()
} catch (e: unknown) {
if (axios.isAxiosError(e) && e.response?.status === 409) {
const detail = e.response.data?.detail as
| { error?: string; claimed_by_name?: string; claimed_at?: string }
| undefined
if (detail?.error === 'already_claimed') {
const name = detail.claimed_by_name || 'another engineer'
const when = detail.claimed_at ? timeAgo(detail.claimed_at) : 'just now'
toast.info(`Already claimed by ${name} ${when}.`)
setSearchParams({})
setMagicState('dismissed')
return
}
}
const message = e instanceof Error ? e.message : 'Failed to pick up session'
toast.error(message)
} finally {
setActiveOptionKey(null)
}
}, [urlSessionId, magicHandoff, setSearchParams])
const handleOwnThing = useCallback(async () => {
if (!urlSessionId || !magicHandoff) return
setActiveOptionKey('own')
try {
await handoffsApi.claimHandoff(urlSessionId, magicHandoff.id)
setSearchParams({})
setMagicState('dismissed')
void loadChats()
setTimeout(() => inputRef.current?.focus(), 300)
} catch (e: unknown) {
if (axios.isAxiosError(e) && e.response?.status === 409) {
const detail = e.response.data?.detail as
| { error?: string; claimed_by_name?: string; claimed_at?: string }
| undefined
if (detail?.error === 'already_claimed') {
const name = detail.claimed_by_name || 'another engineer'
const when = detail.claimed_at ? timeAgo(detail.claimed_at) : 'just now'
toast.info(`Already claimed by ${name} ${when}.`)
setSearchParams({})
setMagicState('dismissed')
return
}
}
const message = e instanceof Error ? e.message : 'Failed to pick up session'
toast.error(message)
} finally {
setActiveOptionKey(null)
}
}, [urlSessionId, magicHandoff, setSearchParams])
const openHandoffContextOverlay = useCallback(async () => {
if (!activeChatId) return
if (magicHandoff) {
@@ -1129,6 +1191,90 @@ export default function AssistantChatPage() {
}
}, [refreshSessionDerived])
const handleAIAnalysis = useCallback(async () => {
if (!urlSessionId || !magicHandoff) return
setActiveOptionKey('ai')
const sentForChatId = urlSessionId
try {
await handoffsApi.claimHandoff(urlSessionId, magicHandoff.id)
loadedChatIdsRef.current.add(urlSessionId)
setSearchParams({})
setMagicState('dismissed')
void loadChats()
await selectChat(urlSessionId)
if (currentChatRef.current !== sentForChatId) return
const assessment = magicHandoff.ai_assessment_data
const snapshot = magicHandoff.snapshot as Record<string, unknown>
const problemSummary = (snapshot.problem_summary as string) || 'Untitled session'
const stepCount = (snapshot.step_count as number) ?? 0
const lines: string[] = [
`I just picked up this escalated session. Here's what's known so far:`,
``,
`**Problem:** ${problemSummary}`,
]
if (assessment?.likely_cause) {
lines.push(`**Likely cause:** ${assessment.likely_cause}`)
}
if (assessment?.what_we_know && assessment.what_we_know.length > 0) {
lines.push(`**What we know:**`)
assessment.what_we_know.forEach(fact => lines.push(`- ${fact}`))
}
if (stepCount > 0) {
lines.push(`**Steps on record:** ${stepCount} diagnostic steps.`)
}
if (magicHandoff.engineer_notes) {
lines.push(`**Engineer notes:** ${magicHandoff.engineer_notes}`)
}
lines.push(``, `Please analyze this and give me fresh diagnostic steps to try.`)
const briefing = lines.join('\n')
setMessages(prev => [...prev, { role: 'user', content: briefing }])
setLoading(true)
const response = await aiSessionsApi.sendChatMessage(urlSessionId, { message: briefing })
if (currentChatRef.current !== sentForChatId) return
setMessages(prev => [
...prev,
{
role: 'assistant',
content: response.content,
suggestedFlows: response.suggested_flows,
fork: response.fork,
actions: response.actions,
questions: response.questions,
},
])
const hasQuestions = response.questions && response.questions.length > 0
const hasActions = response.actions && response.actions.length > 0
if (hasQuestions || hasActions) {
clearTaskState(urlSessionId)
setActiveQuestions(response.questions || [])
setActiveActions(response.actions || [])
setShowTaskLane(true)
setTaskLaneOwnerChatId(urlSessionId)
}
} catch (e: unknown) {
if (axios.isAxiosError(e) && e.response?.status === 409) {
const detail = e.response.data?.detail as
| { error?: string; claimed_by_name?: string; claimed_at?: string }
| undefined
if (detail?.error === 'already_claimed') {
const name = detail.claimed_by_name || 'another engineer'
const when = detail.claimed_at ? timeAgo(detail.claimed_at) : 'just now'
toast.info(`Already claimed by ${name} ${when}.`)
setSearchParams({})
setMagicState('dismissed')
return
}
}
const message = e instanceof Error ? e.message : 'Failed to start AI analysis'
toast.error(message)
} finally {
setActiveOptionKey(null)
setLoading(false)
}
}, [urlSessionId, magicHandoff, setSearchParams, selectChat])
const handleNewChat = async () => {
// Invalidate currentChatRef BEFORE the await so any in-flight handleSend/handleTaskSubmit
// for the previous session sees a mismatch and bails — prevents stale task lane appearing
@@ -1546,8 +1692,12 @@ export default function AssistantChatPage() {
<div className="h-[calc(100vh-3.5rem)] overflow-y-auto p-4 sm:p-8">
<HandoffContextScreen
handoff={magicHandoff}
onStartHere={handleStartHere}
isProcessing={claiming}
onContinue={handleContinue}
onAIAnalysis={handleAIAnalysis}
onOwnThing={handleOwnThing}
isProcessing={activeOptionKey !== null}
hasTaskLane={activeActions.length > 0 || activeQuestions.length > 0}
activeOptionKey={activeOptionKey}
/>
</div>
</>
@@ -1888,46 +2038,142 @@ export default function AssistantChatPage() {
/>
)}
{/* Suggested-step chips (Codex correction, locked design):
visible after the magic-moment dissolves (post-claim) so the
senior can pull the AI's suggested next steps into the
composer with one click. Hides on first send or explicit X. */}
{/* Task-lane shortcut chips: visible after the magic-moment
dissolves when the task lane has loaded items. Each card
links directly to the corresponding diagnostic card in the
task lane clicking opens the lane (if closed) and scrolls
to that card. Sourced from actual task lane items, not the
AI's free-text suggested_steps, so the card the user lands
on has full detail (description, command, etc.). */}
{!chipsHidden &&
magicHandoff?.ai_assessment_data?.suggested_steps &&
magicHandoff.ai_assessment_data.suggested_steps.length > 0 &&
magicState === 'dismissed' && (
<div className="px-3 sm:px-6 pt-2 shrink-0">
<div className="max-w-3xl mx-auto flex items-start gap-2">
<p className="font-sans text-[0.625rem] uppercase tracking-wider text-muted-foreground pt-1.5 shrink-0">
Suggested
</p>
<div className="flex flex-wrap gap-1.5 flex-1 min-w-0">
{magicHandoff.ai_assessment_data.suggested_steps.map((step, i) => (
(activeActions.length > 0 || activeQuestions.length > 0) &&
magicState === 'dismissed' && (() => {
const chipItems = [
...activeActions.slice(0, 4).map((a, ai) => ({
label: a.label,
cardIdx: activeQuestions.length + ai,
description: a.description,
command: a.command ?? null,
type: 'action' as const,
})),
...activeQuestions.slice(0, Math.max(0, 4 - Math.min(activeActions.length, 4))).map((q, qi) => ({
label: q.text,
cardIdx: qi,
description: q.context ?? null,
command: null,
type: 'question' as const,
})),
]
const selectedChip = chipItems.find(c => c.cardIdx === selectedChipCardIdx) ?? null
return (
<div className="px-3 sm:px-6 pt-2 pb-0.5 shrink-0">
<div className="max-w-3xl mx-auto">
<div className="flex items-center gap-2 mb-1.5">
<p className="font-sans text-[0.625rem] uppercase tracking-wider text-muted-foreground">
Suggested checks
</p>
<button
key={i}
type="button"
onClick={() => {
setInput(step)
inputRef.current?.focus()
}}
className="rounded-full border border-default bg-elevated px-3 py-1 text-xs text-foreground hover:bg-accent-dim hover:text-accent-text hover:border-accent/30 transition-colors text-left max-w-full truncate"
title={step}
onClick={() => { setChipsHidden(true); setSelectedChipCardIdx(null) }}
aria-label="Hide suggestions"
className="p-0.5 rounded text-muted-foreground hover:text-foreground hover:bg-elevated transition-colors"
>
{step}
<X size={11} />
</button>
))}
</div>
{/* Inline detail card — shown when a chip is selected */}
{selectedChip && (
<div className="mb-2 rounded-lg border border-default bg-card p-3 animate-fade-in">
<div className="flex items-start justify-between gap-2 mb-1.5">
<span className="text-[0.8125rem] font-medium text-heading leading-snug">{selectedChip.label}</span>
<button
onClick={() => setSelectedChipCardIdx(null)}
className="shrink-0 p-0.5 rounded text-muted-foreground hover:text-foreground transition-colors"
aria-label="Close detail"
>
<X size={12} />
</button>
</div>
{selectedChip.description && (
<p className="text-[0.6875rem] text-muted-foreground mb-2 leading-relaxed">{selectedChip.description}</p>
)}
{selectedChip.command && (
<div className="rounded-md bg-code border border-default/50 px-3 py-2 flex items-start gap-2 mb-2.5">
<code className="flex-1 text-[0.75rem] font-mono text-heading whitespace-pre-wrap break-all leading-relaxed">{selectedChip.command}</code>
<button
onClick={async () => {
try {
await navigator.clipboard.writeText(selectedChip.command!)
} catch {
try {
const el = document.createElement('textarea')
el.value = selectedChip.command!
el.style.cssText = 'position:fixed;opacity:0;pointer-events:none'
document.body.appendChild(el)
el.select()
document.execCommand('copy')
document.body.removeChild(el)
} catch { return }
}
setCopiedChipCmd(true)
setTimeout(() => setCopiedChipCmd(false), 1500)
}}
className="shrink-0 p-1 rounded text-muted-foreground hover:text-heading hover:bg-elevated transition-colors mt-0.5"
title={copiedChipCmd ? 'Copied!' : 'Copy command'}
>
{copiedChipCmd
? <Check size={13} className="text-success" />
: <Copy size={13} />
}
</button>
</div>
)}
<button
onClick={() => {
setSelectedChipCardIdx(null)
if (!showTaskLane) setShowTaskLane(true)
const el = document.getElementById(`task-lane-card-${selectedChip.cardIdx}`)
if (el) {
setTimeout(() => el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }), showTaskLane ? 0 : 200)
}
}}
className="flex items-center gap-1 text-[0.75rem] font-medium text-accent-text hover:text-accent transition-colors"
>
<ArrowRight size={11} />
Open in Tasks panel
</button>
</div>
)}
<div className="flex gap-2 overflow-x-auto pb-1" style={{ scrollbarWidth: 'none' }}>
{chipItems.map((item) => {
const isSelected = item.cardIdx === selectedChipCardIdx
return (
<button
key={item.cardIdx}
type="button"
onClick={() => {
setCopiedChipCmd(false)
setSelectedChipCardIdx(isSelected ? null : item.cardIdx)
}}
className={cn(
'flex items-start gap-2 rounded-lg border px-3 py-2.5 text-left transition-colors shrink-0 w-[172px]',
isSelected
? 'border-accent/50 bg-accent-dim'
: 'border-default bg-card hover:bg-accent-dim hover:border-accent/30',
)}
>
<ArrowRight size={12} className="text-accent-text shrink-0 mt-0.5" />
<span className="text-xs text-foreground line-clamp-2 leading-snug">{item.label}</span>
</button>
)
})}
</div>
</div>
<button
type="button"
onClick={() => setChipsHidden(true)}
aria-label="Hide suggestions"
className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-elevated transition-colors shrink-0"
>
<X size={12} />
</button>
</div>
</div>
)}
)
})()}
{/* Rich Input */}
<div className="px-3 sm:px-6 py-3 shrink-0">
@@ -2284,7 +2530,13 @@ export default function AssistantChatPage() {
{/* Conclude Session Modal */}
<ConcludeSessionModal
isOpen={showConclude}
onClose={() => setShowConclude(false)}
onClose={() => {
setShowConclude(false)
if (activeSessionStatus === 'escalated') {
toast.info('Session escalated. Heading back to your dashboard.')
navigate('/')
}
}}
onConclude={handleConclude}
onResumeNew={handleResumeNew}
chatTitle={chats.find(c => c.id === activeChatId)?.title ?? 'Chat'}
@@ -2347,7 +2599,6 @@ export default function AssistantChatPage() {
>
<HandoffContextScreen
handoff={overlayHandoff}
onStartHere={() => {}}
onDismiss={() => setOverlayHandoff(null)}
dismissible
/>