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

@@ -97,6 +97,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
const [submitting, setSubmitting] = useState(false)
const [showRunAll, setShowRunAll] = useState(false)
const [showPreview, setShowPreview] = useState(false)
const [copiedKey, setCopiedKey] = useState<string | null>(null)
// ── Resize state ──
const DEFAULT_WIDTH = 340
@@ -208,8 +209,26 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
`# ── ${i + 1}. ${a.label} ──\n${a.command}`
)).join('\n\n')
const handleCopy = (text: string) => {
navigator.clipboard.writeText(text)
const handleCopy = async (text: string) => {
try {
await navigator.clipboard.writeText(text)
} catch {
// Fallback for HTTP or focus-restricted contexts
try {
const el = document.createElement('textarea')
el.value = text
el.style.cssText = 'position:fixed;opacity:0;pointer-events:none'
document.body.appendChild(el)
el.select()
document.execCommand('copy')
document.body.removeChild(el)
} catch {
toast.error('Copy failed — select the text and copy manually')
return
}
}
setCopiedKey(text)
setTimeout(() => setCopiedKey(k => k === text ? null : k), 1500)
toast.success('Copied to clipboard')
}
@@ -325,7 +344,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
if (q.state === 'done') {
return (
<div key={idx} className="rounded-lg border-l-[3px] border-l-success border border-success/25 bg-success-dim/30 p-3 mb-2 cursor-pointer hover:border-success/40 transition-colors" onClick={() => updateTask(idx, { state: 'active' })}>
<div key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border-l-[3px] border-l-success border border-success/25 bg-success-dim/30 p-3 mb-2 cursor-pointer hover:border-success/40 transition-colors" onClick={() => updateTask(idx, { state: 'active' })}>
<div className="flex items-center gap-1.5">
<Check size={12} className="text-success shrink-0" />
<span className="text-[0.8125rem] text-foreground">{q.text}</span>
@@ -337,7 +356,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
if (q.state === 'skipped') {
return (
<div key={idx} className="rounded-lg border border-default/50 bg-elevated/20 p-3 mb-2 opacity-60 cursor-pointer hover:opacity-80 hover:border-default transition-all" onClick={() => updateTask(idx, { state: 'pending' })} title="Click to restore">
<div key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border border-default/50 bg-elevated/20 p-3 mb-2 opacity-60 cursor-pointer hover:opacity-80 hover:border-default transition-all" onClick={() => updateTask(idx, { state: 'pending' })} title="Click to restore">
<div className="flex justify-between">
<div className="text-[0.8125rem] text-muted-foreground line-through">{q.text}</div>
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Skipped</span>
@@ -347,7 +366,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
}
return (
<div key={idx} className="rounded-lg border border-default bg-card p-3 mb-2">
<div key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border border-default bg-card p-3 mb-2">
<div className="text-[0.8125rem] text-heading leading-relaxed">{q.text}</div>
{q.context && (
<div className="text-[0.6875rem] text-muted-foreground mt-1">{q.context}</div>
@@ -430,10 +449,11 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
<div className="flex items-center justify-between mb-2">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Combined script</span>
<button
onClick={() => handleCopy(combinedScript)}
className="flex items-center gap-1 text-[0.75rem] text-muted-foreground hover:text-heading"
onClick={() => void handleCopy(combinedScript)}
className="flex items-center gap-1 text-[0.75rem] text-muted-foreground hover:text-heading transition-colors"
>
<Copy size={11} /> Copy
{copiedKey === combinedScript ? <Check size={11} className="text-success" /> : <Copy size={11} />}
{copiedKey === combinedScript ? 'Copied' : 'Copy'}
</button>
</div>
<pre className="text-[0.75rem] font-mono text-heading whitespace-pre-wrap overflow-x-auto">{combinedScript}</pre>
@@ -448,7 +468,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
if (a.state === 'done') {
return (
<div key={idx} className="rounded-lg border-l-[3px] border-l-success border border-success/25 bg-success-dim/30 p-3 mb-2 cursor-pointer hover:border-success/40 transition-colors" onClick={() => updateTask(idx, { state: 'active' })}>
<div key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border-l-[3px] border-l-success border border-success/25 bg-success-dim/30 p-3 mb-2 cursor-pointer hover:border-success/40 transition-colors" onClick={() => updateTask(idx, { state: 'active' })}>
<div className="flex items-center gap-1.5">
<Check size={12} className="text-success shrink-0" />
<span className="text-[0.8125rem] font-medium text-foreground flex-1">{a.label}</span>
@@ -459,7 +479,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
if (a.state === 'skipped') {
return (
<div key={idx} className="rounded-lg border border-default/50 bg-elevated/20 p-3 mb-2 opacity-60 cursor-pointer hover:opacity-80 hover:border-default transition-all" onClick={() => updateTask(idx, { state: 'pending' })} title="Click to restore">
<div key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border border-default/50 bg-elevated/20 p-3 mb-2 opacity-60 cursor-pointer hover:opacity-80 hover:border-default transition-all" onClick={() => updateTask(idx, { state: 'pending' })} title="Click to restore">
<div className="flex justify-between">
<div className="text-[0.8125rem] text-muted-foreground line-through">{a.label}</div>
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Skipped</span>
@@ -469,7 +489,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
}
return (
<div key={idx} className="rounded-lg border border-default bg-card p-3 mb-2 hover:border-hover transition-colors">
<div key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border border-default bg-card p-3 mb-2 hover:border-hover transition-colors">
<div className="text-[0.8125rem] font-medium text-heading">{a.label}</div>
{a.description && (
<div className="text-[0.6875rem] text-muted-foreground mt-0.5 leading-relaxed">{a.description}</div>
@@ -477,9 +497,16 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
{a.command && (
<div className="mt-2 flex items-center gap-2 rounded bg-code px-2.5 py-1.5">
<code className="flex-1 text-[0.6875rem] font-mono text-heading truncate">{a.command}</code>
<button onClick={() => handleCopy(a.command!)} className="shrink-0 text-muted-foreground hover:text-heading" title="Copy">
<Copy size={11} />
<code className="flex-1 text-[0.6875rem] font-mono text-heading whitespace-pre-wrap break-all">{a.command}</code>
<button
onClick={() => void handleCopy(a.command!)}
className="shrink-0 text-muted-foreground hover:text-heading transition-colors p-0.5 rounded"
title={copiedKey === a.command ? 'Copied!' : 'Copy command'}
>
{copiedKey === a.command
? <Check size={11} className="text-success" />
: <Copy size={11} />
}
</button>
</div>
)}