fix: move session lifecycle actions to header bar in AssistantChatPage
- Add persistent session header with title, status badge, Resolve, Escalate, and Update Ticket/Share Update buttons — mirrors FlowPilotSessionPage pattern exactly - Update Ticket label when psa_ticket_id present, Share Update otherwise - Full mobile support via ⋯ overflow menu (Resolve, Escalate, Update, Pause) - Strip _(not yet completed)_ markers from stored conversation_messages in unified_chat_service to prevent stale task lane items from prior turns leaking into new sessions via the AI's re-include instruction - Add currentChatRef guard to handleResumeNew (was missing unlike handleSend) - Remove Update/Conclude from chatbar — toolbar is now input utilities only Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -237,8 +237,10 @@ async def send_chat_message(
|
||||
ai_content, input_tokens, output_tokens = await _call_ai(**prompt_args)
|
||||
|
||||
# Update branch conversation
|
||||
# Strip _(not yet completed)_ markers before storage (same reason as main path)
|
||||
stored_message = message.replace("_(not yet completed)_", "(pending)").replace("_(skipped)_", "(skipped)")
|
||||
msgs = list(branch.conversation_messages or [])
|
||||
msgs.append({"role": "user", "content": message})
|
||||
msgs.append({"role": "user", "content": stored_message})
|
||||
msgs.append({"role": "assistant", "content": ai_content})
|
||||
branch.conversation_messages = msgs
|
||||
|
||||
@@ -350,8 +352,14 @@ async def send_chat_message(
|
||||
# Store DISPLAY content (markers stripped) in conversation_messages.
|
||||
# The format reminder in the user message + system prompt final reminder
|
||||
# are sufficient to keep the AI emitting markers on subsequent turns.
|
||||
#
|
||||
# Strip _(not yet completed)_ task markers from the stored user message.
|
||||
# The AI processes them correctly on the current turn, but persisting them
|
||||
# into history causes the AI to re-inject stale task lane items from prior
|
||||
# turns — even across unrelated topics in a long session.
|
||||
stored_message = message.replace("_(not yet completed)_", "(pending)").replace("_(skipped)_", "(skipped)")
|
||||
msgs = list(session.conversation_messages or [])
|
||||
msgs.append({"role": "user", "content": message})
|
||||
msgs.append({"role": "user", "content": stored_message})
|
||||
msgs.append({"role": "assistant", "content": display_content})
|
||||
session.conversation_messages = msgs
|
||||
session.step_count += 2 # message count for display
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||
import { Sparkles, Send, Loader2, Flag, MessageSquare, Paperclip, Terminal, X, RotateCcw, ImagePlus, ListChecks, FileText } from 'lucide-react'
|
||||
import { Sparkles, Send, Loader2, MessageSquare, Paperclip, Terminal, X, RotateCcw, ImagePlus, ListChecks, FileText, CheckCircle2, ArrowUpRight, MoreHorizontal, Pause } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { uploadsApi } from '@/api/uploads'
|
||||
import type { PendingUpload } from '@/types/upload'
|
||||
@@ -71,6 +71,9 @@ export default function AssistantChatPage() {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(() =>
|
||||
localStorage.getItem('rf-chat-sidebar-collapsed') === 'true'
|
||||
)
|
||||
const [activeSessionStatus, setActiveSessionStatus] = useState<string | null>(null)
|
||||
const [activePsaTicketId, setActivePsaTicketId] = useState<string | null>(null)
|
||||
const [showOverflow, setShowOverflow] = useState(false)
|
||||
const toggleSidebarCollapse = () => {
|
||||
const next = !sidebarCollapsed
|
||||
setSidebarCollapsed(next)
|
||||
@@ -127,6 +130,8 @@ export default function AssistantChatPage() {
|
||||
setShowTaskLane(false)
|
||||
setActiveQuestions([])
|
||||
setActiveActions([])
|
||||
setActiveSessionStatus('active')
|
||||
setActivePsaTicketId(null)
|
||||
|
||||
try {
|
||||
const session = await aiSessionsApi.createChatSession({
|
||||
@@ -223,12 +228,16 @@ export default function AssistantChatPage() {
|
||||
setShowTaskLane(false)
|
||||
setActiveQuestions([])
|
||||
setActiveActions([])
|
||||
setActiveSessionStatus(null)
|
||||
setActivePsaTicketId(null)
|
||||
try {
|
||||
const detail = await aiSessionsApi.getSession(chatId)
|
||||
// Guard: if the user switched to a different chat while this API call was
|
||||
// in flight (e.g. clicked "New Chat"), discard stale results so we don't
|
||||
// clobber the new session's task lane state.
|
||||
if (currentChatRef.current !== chatId) return
|
||||
setActiveSessionStatus(detail.status)
|
||||
setActivePsaTicketId(detail.psa_ticket_id)
|
||||
setMessages(
|
||||
(detail.conversation_messages || []).map(m => ({
|
||||
role: m.role as 'user' | 'assistant',
|
||||
@@ -268,6 +277,8 @@ export default function AssistantChatPage() {
|
||||
setActiveQuestions([])
|
||||
setActiveActions([])
|
||||
setMessages([])
|
||||
setActiveSessionStatus('active')
|
||||
setActivePsaTicketId(null)
|
||||
try {
|
||||
const session = await aiSessionsApi.createChatSession({
|
||||
intake_type: 'free_text',
|
||||
@@ -427,14 +438,17 @@ export default function AssistantChatPage() {
|
||||
await aiSessionsApi.resolveSession(activeChatId, {
|
||||
resolution_summary: _notes || 'Resolved via assistant chat',
|
||||
})
|
||||
setActiveSessionStatus('resolved')
|
||||
return activeChatId
|
||||
} else if (outcome === 'escalated') {
|
||||
await aiSessionsApi.escalateSession(activeChatId, {
|
||||
escalation_reason: _notes || 'Escalated from assistant chat',
|
||||
})
|
||||
setActiveSessionStatus('escalated')
|
||||
return activeChatId
|
||||
} else {
|
||||
await aiSessionsApi.pauseSession(activeChatId)
|
||||
setActiveSessionStatus('paused')
|
||||
return activeChatId
|
||||
}
|
||||
}
|
||||
@@ -446,6 +460,8 @@ export default function AssistantChatPage() {
|
||||
setShowTaskLane(false)
|
||||
setActiveQuestions([])
|
||||
setActiveActions([])
|
||||
setActiveSessionStatus('active')
|
||||
setActivePsaTicketId(null)
|
||||
try {
|
||||
const resumePrompt = `I'm continuing a previous troubleshooting session. Here's where we left off:\n\n${summary}\n\nPlease review this context and help me continue from where we stopped.`
|
||||
const session = await aiSessionsApi.createChatSession({
|
||||
@@ -467,6 +483,8 @@ export default function AssistantChatPage() {
|
||||
setLoading(true)
|
||||
|
||||
const response = await aiSessionsApi.sendChatMessage(session.session_id, { message: resumePrompt })
|
||||
// Guard: discard if user switched to a different chat while this was in flight
|
||||
if (currentChatRef.current !== session.session_id) return
|
||||
setMessages(prev => [
|
||||
...prev,
|
||||
{ role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows, fork: response.fork, actions: response.actions, questions: response.questions },
|
||||
@@ -645,6 +663,153 @@ export default function AssistantChatPage() {
|
||||
|
||||
{activeChatId ? (
|
||||
<>
|
||||
{/* Session header — title + lifecycle actions */}
|
||||
{(() => {
|
||||
const chatTitle = chats.find(c => c.id === activeChatId)?.title
|
||||
const isActive = activeSessionStatus === 'active' || activeSessionStatus === null
|
||||
const canAct = messages.length >= 2 && isActive && !loading
|
||||
const updateLabel = activePsaTicketId ? 'Update Ticket' : 'Share Update'
|
||||
return (
|
||||
<div className="flex items-center gap-3 border-b border-border px-3 sm:px-5 py-2.5 shrink-0">
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-accent-dim shrink-0">
|
||||
<Sparkles size={14} className="text-primary" />
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="font-heading text-sm font-semibold text-foreground truncate">
|
||||
{chatTitle || 'AI Assistant'}
|
||||
</h1>
|
||||
{activeSessionStatus && activeSessionStatus !== 'active' && (
|
||||
<span className={cn(
|
||||
'text-[0.625rem] font-medium uppercase tracking-wide',
|
||||
activeSessionStatus === 'resolved' ? 'text-success' :
|
||||
activeSessionStatus === 'escalated' || activeSessionStatus === 'requesting_escalation' ? 'text-warning' :
|
||||
activeSessionStatus === 'paused' ? 'text-muted-foreground' : 'text-muted-foreground'
|
||||
)}>
|
||||
{activeSessionStatus === 'requesting_escalation' ? 'Escalated' : activeSessionStatus.charAt(0).toUpperCase() + activeSessionStatus.slice(1)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop actions — shown when session is active and has messages */}
|
||||
<div className="hidden sm:flex items-center gap-1.5">
|
||||
{isActive && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setShowConclude(true)}
|
||||
disabled={!canAct}
|
||||
data-conclude-outcome="resolved"
|
||||
className="flex items-center gap-1.5 rounded-lg bg-success-dim border border-success/20 px-3 py-1.5 text-xs font-medium text-success hover:bg-success/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
|
||||
>
|
||||
<CheckCircle2 size={13} />
|
||||
Resolve
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowConclude(true)}
|
||||
disabled={!canAct}
|
||||
data-conclude-outcome="escalated"
|
||||
className="flex items-center gap-1.5 rounded-lg bg-warning-dim border border-warning/20 px-3 py-1.5 text-xs font-medium text-warning hover:bg-warning/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
|
||||
>
|
||||
<ArrowUpRight size={13} />
|
||||
Escalate
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{messages.length >= 2 && (
|
||||
<button
|
||||
onClick={() => setShowStatusUpdate(true)}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-accent-dim border border-accent/20 px-3 py-1.5 text-xs font-medium text-accent hover:bg-accent/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
|
||||
>
|
||||
<FileText size={13} />
|
||||
{updateLabel}
|
||||
</button>
|
||||
)}
|
||||
{/* Overflow: Pause / — */}
|
||||
{isActive && messages.length >= 2 && (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowOverflow(!showOverflow)}
|
||||
className="flex items-center justify-center rounded-lg px-2 py-1.5 text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
||||
>
|
||||
<MoreHorizontal size={16} />
|
||||
</button>
|
||||
{showOverflow && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setShowOverflow(false)} />
|
||||
<div className="absolute right-0 top-full mt-1 z-50 w-36 rounded-lg border border-border bg-card py-1 shadow-lg">
|
||||
<button
|
||||
onClick={() => { setShowOverflow(false); aiSessionsApi.pauseSession(activeChatId).then(() => setActiveSessionStatus('paused')).catch(() => toast.error('Failed to pause')) }}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
||||
>
|
||||
<Pause size={13} />
|
||||
Pause
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile: single overflow menu */}
|
||||
{messages.length >= 2 && (
|
||||
<div className="sm:hidden relative">
|
||||
<button
|
||||
onClick={() => setShowOverflow(!showOverflow)}
|
||||
className="flex items-center justify-center rounded-lg px-2 py-1.5 text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
||||
>
|
||||
<MoreHorizontal size={18} />
|
||||
</button>
|
||||
{showOverflow && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setShowOverflow(false)} />
|
||||
<div className="absolute right-0 top-full mt-1 z-50 w-44 rounded-lg border border-border bg-card py-1 shadow-lg">
|
||||
{isActive && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => { setShowOverflow(false); setShowConclude(true) }}
|
||||
disabled={!canAct}
|
||||
className="flex w-full items-center gap-2 px-3 py-2.5 text-xs text-success hover:bg-success-dim transition-colors disabled:opacity-40"
|
||||
>
|
||||
<CheckCircle2 size={14} />
|
||||
Resolve
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowOverflow(false); setShowConclude(true) }}
|
||||
disabled={!canAct}
|
||||
className="flex w-full items-center gap-2 px-3 py-2.5 text-xs text-warning hover:bg-warning-dim transition-colors disabled:opacity-40"
|
||||
>
|
||||
<ArrowUpRight size={14} />
|
||||
Escalate
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { setShowOverflow(false); setShowStatusUpdate(true) }}
|
||||
disabled={loading}
|
||||
className="flex w-full items-center gap-2 px-3 py-2.5 text-xs text-accent hover:bg-accent-dim transition-colors disabled:opacity-40"
|
||||
>
|
||||
<FileText size={14} />
|
||||
{updateLabel}
|
||||
</button>
|
||||
{isActive && (
|
||||
<button
|
||||
onClick={() => { setShowOverflow(false); aiSessionsApi.pauseSession(activeChatId).then(() => setActiveSessionStatus('paused')).catch(() => toast.error('Failed to pause')) }}
|
||||
className="flex w-full items-center gap-2 px-3 py-2.5 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
||||
>
|
||||
<Pause size={14} />
|
||||
Pause
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto px-4 sm:px-6 py-4 space-y-4">
|
||||
{messages.length === 0 && !loading && (
|
||||
@@ -784,18 +949,6 @@ export default function AssistantChatPage() {
|
||||
<span className="hidden sm:inline">Paste Logs</span>
|
||||
</button>
|
||||
)}
|
||||
{messages.length >= 2 && (
|
||||
<>
|
||||
<button type="button" onClick={() => setShowStatusUpdate(true)} disabled={loading} className="flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-xs text-muted-foreground hover:text-accent hover:bg-accent-dim transition-colors disabled:opacity-40" title="Share status update">
|
||||
<FileText size={14} />
|
||||
<span className="hidden sm:inline">Update</span>
|
||||
</button>
|
||||
<button type="button" onClick={() => setShowConclude(true)} disabled={loading} className="flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-xs text-muted-foreground hover:text-warning hover:bg-warning-dim transition-colors disabled:opacity-40" title="Conclude session">
|
||||
<Flag size={14} />
|
||||
<span className="hidden sm:inline">Conclude</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{!showTaskLane && (activeQuestions.length > 0 || activeActions.length > 0) && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user