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:
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -6,8 +6,10 @@ import {
|
||||
Clock,
|
||||
FileText,
|
||||
Hash,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
Target,
|
||||
User,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import type { HandoffResponse } from '@/types/branching'
|
||||
@@ -35,12 +37,21 @@ type ConfidenceTier = 'low' | 'medium' | 'high' | string
|
||||
|
||||
interface HandoffContextScreenProps {
|
||||
handoff: HandoffResponse
|
||||
onStartHere: () => Promise<void> | void
|
||||
// Pre-claim entry point: one of three choices is made before claiming.
|
||||
// Post-claim re-open (dismissible=true) keeps the legacy onStartHere path.
|
||||
onContinue?: () => Promise<void> | void
|
||||
onAIAnalysis?: () => Promise<void> | void
|
||||
onOwnThing?: () => Promise<void> | void
|
||||
// Legacy single-CTA — used when dismissible=true (post-claim toolbar re-open)
|
||||
onStartHere?: () => Promise<void> | void
|
||||
onDismiss?: () => void
|
||||
// When true, renders an "X" close affordance in the corner. Used when the
|
||||
// screen is re-opened from the FlowPilot toolbar (post-claim re-read).
|
||||
dismissible?: boolean
|
||||
isProcessing?: boolean
|
||||
// Whether the task lane has items — drives the 3-option vs 2-option layout
|
||||
hasTaskLane?: boolean
|
||||
activeOptionKey?: 'continue' | 'ai' | 'own' | null
|
||||
}
|
||||
|
||||
function ConfidenceBadge({ value }: { value: number | string | null | undefined }) {
|
||||
@@ -76,10 +87,15 @@ function ConfidenceBadge({ value }: { value: number | string | null | undefined
|
||||
|
||||
export function HandoffContextScreen({
|
||||
handoff,
|
||||
onContinue,
|
||||
onAIAnalysis,
|
||||
onOwnThing,
|
||||
onStartHere,
|
||||
onDismiss,
|
||||
dismissible = false,
|
||||
isProcessing = false,
|
||||
hasTaskLane = false,
|
||||
activeOptionKey = null,
|
||||
}: HandoffContextScreenProps) {
|
||||
const startBtnRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
@@ -114,6 +130,7 @@ export function HandoffContextScreen({
|
||||
|
||||
const assessment = handoff.ai_assessment_data
|
||||
const likelyCause = assessment?.likely_cause
|
||||
const whatWeKnow = assessment?.what_we_know ?? []
|
||||
const suggestedSteps = assessment?.suggested_steps ?? []
|
||||
const assessmentConfidence = assessment?.confidence
|
||||
const assessmentText = handoff.ai_assessment
|
||||
@@ -256,6 +273,21 @@ export function HandoffContextScreen({
|
||||
<p className="text-sm text-foreground">{likelyCause}</p>
|
||||
</div>
|
||||
)}
|
||||
{whatWeKnow.length > 0 && (
|
||||
<div>
|
||||
<p className="font-sans text-[0.625rem] uppercase tracking-wider text-muted-foreground mb-1.5">
|
||||
What we know
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{whatWeKnow.map((fact, i) => (
|
||||
<li key={i} className="text-sm text-foreground flex items-start gap-2">
|
||||
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/50" />
|
||||
<span>{fact}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{assessmentText && !likelyCause && (
|
||||
<p className="text-sm text-foreground whitespace-pre-wrap">
|
||||
{assessmentText}
|
||||
@@ -287,22 +319,92 @@ export function HandoffContextScreen({
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Start here CTA */}
|
||||
{!dismissible && (
|
||||
<div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Picking up assigns this session to you and reactivates it.
|
||||
</p>
|
||||
{/* CTA footer */}
|
||||
{dismissible ? (
|
||||
// Post-claim re-open from toolbar — single close action
|
||||
<div className="mt-6 flex justify-end">
|
||||
<button
|
||||
ref={startBtnRef}
|
||||
onClick={() => void onStartHere()}
|
||||
disabled={isProcessing}
|
||||
className="flex items-center justify-center gap-2 rounded-lg bg-accent px-5 py-3 min-h-[44px] text-sm font-semibold text-white hover:brightness-110 active:scale-[0.98] disabled:opacity-50 disabled:pointer-events-none transition-all"
|
||||
onClick={() => onDismiss?.()}
|
||||
className="px-4 py-2 rounded-lg text-sm text-muted-foreground hover:text-foreground bg-input border border-border hover:border-border-hover transition-all"
|
||||
>
|
||||
<ArrowRight size={14} />
|
||||
{isProcessing ? 'Picking up…' : 'Start here'}
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
// Pre-claim: 3 options (task lane exists) or 2 options (empty lane)
|
||||
<div className="mt-6 space-y-2">
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
How would you like to approach this session?
|
||||
</p>
|
||||
|
||||
{/* Continue — only when task lane has items */}
|
||||
{hasTaskLane && onContinue && (
|
||||
<button
|
||||
ref={startBtnRef}
|
||||
onClick={() => void onContinue()}
|
||||
disabled={isProcessing}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 rounded-lg px-4 py-3 min-h-[52px] text-sm font-semibold transition-all',
|
||||
'bg-accent text-white hover:brightness-110 active:scale-[0.98] disabled:opacity-50 disabled:pointer-events-none',
|
||||
)}
|
||||
>
|
||||
{activeOptionKey === 'continue' ? (
|
||||
<Loader2 size={16} className="shrink-0 animate-spin" />
|
||||
) : (
|
||||
<ArrowRight size={16} className="shrink-0" />
|
||||
)}
|
||||
<span className="flex-1 text-left">
|
||||
Continue where{' '}
|
||||
<span className="font-bold">
|
||||
{handoff.handed_off_by_name ?? 'the original engineer'}
|
||||
</span>{' '}
|
||||
left off
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* AI analysis */}
|
||||
{onAIAnalysis && (
|
||||
<button
|
||||
ref={!hasTaskLane ? startBtnRef : undefined}
|
||||
onClick={() => void onAIAnalysis()}
|
||||
disabled={isProcessing}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 rounded-lg border px-4 py-3 min-h-[52px] text-sm font-semibold transition-all disabled:opacity-50 disabled:pointer-events-none',
|
||||
hasTaskLane
|
||||
? 'border-border bg-card text-foreground hover:bg-elevated hover:border-border-hover active:scale-[0.98]'
|
||||
: 'bg-accent text-white border-transparent hover:brightness-110 active:scale-[0.98]',
|
||||
)}
|
||||
>
|
||||
{activeOptionKey === 'ai' ? (
|
||||
<Loader2 size={16} className="shrink-0 animate-spin" />
|
||||
) : (
|
||||
<Sparkles size={16} className="shrink-0" />
|
||||
)}
|
||||
<span className="flex-1 text-left">Get AI analysis</span>
|
||||
<span className="text-xs font-normal opacity-70">
|
||||
{hasTaskLane ? 'Fresh take on what\'s been tried' : 'Generate diagnostic steps'}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Own approach */}
|
||||
{onOwnThing && (
|
||||
<button
|
||||
onClick={() => void onOwnThing()}
|
||||
disabled={isProcessing}
|
||||
className="w-full flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3 min-h-[52px] text-sm text-foreground hover:bg-elevated hover:border-border-hover active:scale-[0.98] disabled:opacity-50 disabled:pointer-events-none transition-all"
|
||||
>
|
||||
{activeOptionKey === 'own' ? (
|
||||
<Loader2 size={16} className="shrink-0 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<User size={16} className="shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="flex-1 text-left">I'll take it from here</span>
|
||||
<span className="text-xs text-muted-foreground">I know what to try</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
|
||||
@@ -86,14 +86,17 @@ export interface HandoffResponse {
|
||||
id: string
|
||||
session_id: string
|
||||
handed_off_by: string
|
||||
handed_off_by_name: string | null
|
||||
intent: 'park' | 'escalate'
|
||||
source_branch_id: string | null
|
||||
snapshot: Record<string, unknown>
|
||||
ai_assessment: string | null
|
||||
ai_assessment_data: {
|
||||
summary_prose?: string
|
||||
what_we_know?: string[]
|
||||
likely_cause: string
|
||||
suggested_steps: string[]
|
||||
confidence: number
|
||||
confidence: string
|
||||
} | null
|
||||
artifacts: Array<{
|
||||
name: string
|
||||
|
||||
Reference in New Issue
Block a user