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:
chihlasm
2026-04-07 06:31:24 +00:00
parent bf45322c46
commit e8e12cc7e5
2 changed files with 176 additions and 15 deletions

View File

@@ -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

View File

@@ -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"