fix: cockpit/flowpilot bugs and redesign view toggle placement
- Fix return type annotation on unified_chat_service.send_chat_message (6→7 tuple) - Fix stale closure in CockpitPage handleStepComplete auto-advance logic - Fix IncidentHeader copy link hardcoding /assistant/ path (now uses current URL) - Wire psaTicketId from session data through to CockpitPage incident header - Fix FlowPilotAsks showing only first question — add chevron navigation for all - Redesign ViewToggle: add icons (MessageSquare/LayoutDashboard) and subtitles - Move view toggle from chatbar toolbar to standalone row above input on dashboard - Standardize toggle placement across dashboard, FlowPilot, and Cockpit pages Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -223,7 +223,7 @@ async def send_chat_message(
|
||||
message: str,
|
||||
db: AsyncSession,
|
||||
images: list[dict[str, Any]] | None = None,
|
||||
) -> tuple[str, list[dict[str, Any]], AISession, dict[str, Any] | None, list[dict[str, Any]] | None, list[dict[str, Any]] | None]:
|
||||
) -> tuple[str, list[dict[str, Any]], AISession, dict[str, Any] | None, list[dict[str, Any]] | None, list[dict[str, Any]] | None, dict[str, Any] | None]:
|
||||
"""Send a message in a chat session and get AI response.
|
||||
|
||||
Args:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { Send, HelpCircle } from 'lucide-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Send, HelpCircle, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import type { QuestionItem } from '@/types/ai-session'
|
||||
|
||||
interface FlowPilotAsksProps {
|
||||
@@ -10,11 +10,17 @@ interface FlowPilotAsksProps {
|
||||
|
||||
export function FlowPilotAsks({ questions, onAnswer, loading }: FlowPilotAsksProps) {
|
||||
const [freeText, setFreeText] = useState('')
|
||||
const [currentIdx, setCurrentIdx] = useState(0)
|
||||
|
||||
// Show first unanswered question
|
||||
const question = questions.length > 0 ? questions[0] : null
|
||||
// Reset index when questions change
|
||||
useEffect(() => {
|
||||
setCurrentIdx(0)
|
||||
setFreeText('')
|
||||
}, [questions])
|
||||
|
||||
if (!question) return null
|
||||
if (questions.length === 0) return null
|
||||
|
||||
const question = questions[Math.min(currentIdx, questions.length - 1)]
|
||||
|
||||
const handleFreeTextSubmit = () => {
|
||||
if (!freeText.trim()) return
|
||||
@@ -24,9 +30,32 @@ export function FlowPilotAsks({ questions, onAnswer, loading }: FlowPilotAsksPro
|
||||
|
||||
return (
|
||||
<div className="bg-card border border-default rounded-lg p-3">
|
||||
<div className="text-[10px] uppercase tracking-wider text-warning font-semibold mb-2 flex items-center gap-1.5">
|
||||
<HelpCircle size={11} />
|
||||
FlowPilot Asks
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-[10px] uppercase tracking-wider text-warning font-semibold flex items-center gap-1.5">
|
||||
<HelpCircle size={11} />
|
||||
FlowPilot Asks
|
||||
</div>
|
||||
{questions.length > 1 && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => setCurrentIdx(i => Math.max(0, i - 1))}
|
||||
disabled={currentIdx === 0}
|
||||
className="p-0.5 text-muted-foreground hover:text-foreground disabled:opacity-30 transition-colors"
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
</button>
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums">
|
||||
{currentIdx + 1}/{questions.length}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCurrentIdx(i => Math.min(questions.length - 1, i + 1))}
|
||||
disabled={currentIdx >= questions.length - 1}
|
||||
className="p-0.5 text-muted-foreground hover:text-foreground disabled:opacity-30 transition-colors"
|
||||
>
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-primary leading-relaxed mb-2.5">
|
||||
{question.text}
|
||||
|
||||
@@ -7,7 +7,6 @@ import type { TriageMeta } from '@/types/ai-session'
|
||||
interface IncidentHeaderProps {
|
||||
triageMeta: TriageMeta
|
||||
psaTicketId: string | null
|
||||
sessionId: string
|
||||
onFieldSave: (field: keyof TriageMeta, value: string) => void
|
||||
onResolve: () => void
|
||||
onPause?: () => void
|
||||
@@ -97,7 +96,7 @@ function HeaderField({ label, value, placeholder, onSave, isHypothesis }: Header
|
||||
)
|
||||
}
|
||||
|
||||
function OverflowMenu({ onPause, onClose, sessionId }: { onPause?: () => void; onClose?: () => void; sessionId: string }) {
|
||||
function OverflowMenu({ onPause, onClose }: { onPause?: () => void; onClose?: () => void }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -118,7 +117,7 @@ function OverflowMenu({ onPause, onClose, sessionId }: { onPause?: () => void; o
|
||||
}, [open])
|
||||
|
||||
const handleCopyLink = () => {
|
||||
navigator.clipboard.writeText(`${window.location.origin}/assistant/${sessionId}`)
|
||||
navigator.clipboard.writeText(`${window.location.origin}${window.location.pathname}`)
|
||||
toast.success('Session link copied')
|
||||
setOpen(false)
|
||||
}
|
||||
@@ -167,7 +166,6 @@ function OverflowMenu({ onPause, onClose, sessionId }: { onPause?: () => void; o
|
||||
export function IncidentHeader({
|
||||
triageMeta,
|
||||
psaTicketId,
|
||||
sessionId,
|
||||
onFieldSave,
|
||||
onResolve,
|
||||
onPause,
|
||||
@@ -217,7 +215,7 @@ export function IncidentHeader({
|
||||
>
|
||||
Resolve
|
||||
</button>
|
||||
<OverflowMenu onPause={onPause} onClose={onClose} sessionId={sessionId} />
|
||||
<OverflowMenu onPause={onPause} onClose={onClose} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,50 +1,69 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { MessageSquare, LayoutDashboard } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useFeatureFlag } from '@/hooks/useFeatureFlag'
|
||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||
|
||||
type FlowPilotView = 'flowpilot' | 'cockpit'
|
||||
|
||||
const VIEW_OPTIONS: { key: FlowPilotView; label: string; icon: typeof MessageSquare; subtitle: string }[] = [
|
||||
{ key: 'flowpilot', label: 'FlowPilot', icon: MessageSquare, subtitle: 'Chat-first AI troubleshooting' },
|
||||
{ key: 'cockpit', label: 'Cockpit', icon: LayoutDashboard, subtitle: 'Steps, evidence & triage board' },
|
||||
]
|
||||
|
||||
interface ViewToggleProps {
|
||||
currentView: 'flowpilot' | 'cockpit'
|
||||
sessionId: string
|
||||
/** Which view is currently active — drives highlight state */
|
||||
currentView: FlowPilotView
|
||||
/** Session ID for navigation (session pages only). Omit for preference-only mode (dashboard). */
|
||||
sessionId?: string
|
||||
/** Show the subtitle below the toggle. Default true for standalone, false for inline. */
|
||||
showSubtitle?: boolean
|
||||
}
|
||||
|
||||
export function ViewToggle({ currentView, sessionId }: ViewToggleProps) {
|
||||
export function ViewToggle({ currentView, sessionId, showSubtitle = true }: ViewToggleProps) {
|
||||
const navigate = useNavigate()
|
||||
const hasCockpit = useFeatureFlag('flowpilot_cockpit')
|
||||
const setPreferredView = useUserPreferencesStore(s => s.setPreferredFlowPilotView)
|
||||
|
||||
if (!hasCockpit) return null
|
||||
|
||||
const handleSwitch = (view: 'flowpilot' | 'cockpit') => {
|
||||
const activeOption = VIEW_OPTIONS.find(o => o.key === currentView) ?? VIEW_OPTIONS[0]
|
||||
|
||||
const handleSwitch = (view: FlowPilotView) => {
|
||||
if (view === currentView) return
|
||||
const path = view === 'cockpit'
|
||||
? `/cockpit/${sessionId}`
|
||||
: `/assistant/${sessionId}`
|
||||
navigate(path)
|
||||
setPreferredView(view)
|
||||
if (sessionId) {
|
||||
const path = view === 'cockpit'
|
||||
? `/cockpit/${sessionId}`
|
||||
: `/assistant/${sessionId}`
|
||||
navigate(path)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center rounded-lg border border-border bg-card p-0.5 text-xs">
|
||||
<button
|
||||
onClick={() => handleSwitch('flowpilot')}
|
||||
className={cn(
|
||||
'rounded-md px-2.5 py-1 font-medium transition-colors',
|
||||
currentView === 'flowpilot'
|
||||
? 'bg-elevated text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
FlowPilot
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSwitch('cockpit')}
|
||||
className={cn(
|
||||
'rounded-md px-2.5 py-1 font-medium transition-colors',
|
||||
currentView === 'cockpit'
|
||||
? 'bg-elevated text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
Cockpit
|
||||
</button>
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<div className="flex items-center rounded-lg border border-border bg-card p-0.5 text-xs">
|
||||
{VIEW_OPTIONS.map(({ key, label, icon: Icon }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => handleSwitch(key)}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-md px-2.5 py-1 font-medium transition-colors',
|
||||
currentView === key
|
||||
? 'bg-elevated text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<Icon size={12} />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{showSubtitle && (
|
||||
<span className="text-[10px] text-muted-foreground leading-none pl-0.5">
|
||||
{activeOption.subtitle}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ export function StartSessionInput() {
|
||||
const navigate = useNavigate()
|
||||
const hasCockpit = useFeatureFlag('flowpilot_cockpit')
|
||||
const preferredView = useUserPreferencesStore(s => s.preferredFlowPilotView)
|
||||
const setPreferredView = useUserPreferencesStore(s => s.setPreferredFlowPilotView)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const dragCounterRef = useRef(0)
|
||||
@@ -326,36 +325,6 @@ export function StartSessionInput() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* View preference toggle */}
|
||||
{hasCockpit && (
|
||||
<div className="flex items-center rounded-lg border border-border bg-card p-0.5 text-xs mr-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreferredView('flowpilot')}
|
||||
className={cn(
|
||||
'rounded-md px-2 py-1 font-medium transition-colors',
|
||||
preferredView === 'flowpilot'
|
||||
? 'bg-elevated text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
FlowPilot
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreferredView('cockpit')}
|
||||
className={cn(
|
||||
'rounded-md px-2 py-1 font-medium transition-colors',
|
||||
preferredView === 'cockpit'
|
||||
? 'bg-elevated text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
Cockpit
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Send button */}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -163,6 +163,7 @@ export function useAssistantSession() {
|
||||
issue_category: string | null
|
||||
triage_hypothesis: string | null
|
||||
evidence_items: Array<{ text: string; status: string }> | null
|
||||
psa_ticket_id: string | null
|
||||
}) => void) | null>(null)
|
||||
|
||||
const selectChat = useCallback(async (chatId: string) => {
|
||||
@@ -185,6 +186,7 @@ export function useAssistantSession() {
|
||||
issue_category: detail.issue_category ?? null,
|
||||
triage_hypothesis: detail.triage_hypothesis ?? null,
|
||||
evidence_items: detail.evidence_items ?? null,
|
||||
psa_ticket_id: detail.psa_ticket_id ?? null,
|
||||
})
|
||||
// Restore task lane from server state
|
||||
if (detail.pending_task_lane) {
|
||||
|
||||
@@ -26,6 +26,7 @@ export default function CockpitPage() {
|
||||
client_name: null, asset_name: null, issue_category: null,
|
||||
triage_hypothesis: null, evidence_items: [],
|
||||
})
|
||||
const [psaTicketId, setPsaTicketId] = useState<string | null>(null)
|
||||
const [workZonePct, setWorkZonePct] = useState(() => {
|
||||
const saved = localStorage.getItem('rf-assistant-work-zone-height')
|
||||
return saved ? parseFloat(saved) : 55
|
||||
@@ -62,6 +63,7 @@ export default function CockpitPage() {
|
||||
triage_hypothesis: detail.triage_hypothesis ?? null,
|
||||
evidence_items: (detail.evidence_items as EvidenceItem[]) ?? [],
|
||||
})
|
||||
setPsaTicketId(detail.psa_ticket_id ?? null)
|
||||
}
|
||||
return () => { session.onSessionLoadedRef.current = null }
|
||||
}, [session.onSessionLoadedRef])
|
||||
@@ -130,16 +132,16 @@ export default function CockpitPage() {
|
||||
setCompletedSteps(prev => {
|
||||
const next = new Set(prev)
|
||||
next.add(index)
|
||||
// Auto-advance using the updated set (avoids stale closure)
|
||||
const nextIncomplete = session.activeActions.findIndex((_, i) => i > index && !next.has(i))
|
||||
if (nextIncomplete !== -1) {
|
||||
setActiveStepIndex(nextIncomplete)
|
||||
} else if (index + 1 < session.activeActions.length) {
|
||||
setActiveStepIndex(index + 1)
|
||||
}
|
||||
return next
|
||||
})
|
||||
// Auto-advance to the next incomplete step
|
||||
const nextIncomplete = session.activeActions.findIndex((_, i) => i > index && !completedSteps.has(i))
|
||||
if (nextIncomplete !== -1) {
|
||||
setActiveStepIndex(nextIncomplete)
|
||||
} else if (index + 1 < session.activeActions.length) {
|
||||
setActiveStepIndex(index + 1)
|
||||
}
|
||||
}, [session.activeActions, completedSteps])
|
||||
}, [session.activeActions])
|
||||
|
||||
const handleStepSelect = useCallback((index: number) => {
|
||||
setActiveStepIndex(index)
|
||||
@@ -240,6 +242,9 @@ export default function CockpitPage() {
|
||||
Cases
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
{session.activeChatId && (
|
||||
<ViewToggle currentView="cockpit" sessionId={session.activeChatId} showSubtitle={false} />
|
||||
)}
|
||||
<button
|
||||
onClick={session.handleNewChat}
|
||||
className="rounded-lg px-3 py-2 text-sm font-medium text-primary hover:bg-primary/10 transition-colors"
|
||||
@@ -253,13 +258,15 @@ export default function CockpitPage() {
|
||||
{/* Incident Header */}
|
||||
<IncidentHeader
|
||||
triageMeta={triageMeta}
|
||||
psaTicketId={session.messages.length > 0 ? null : null}
|
||||
sessionId={session.activeChatId}
|
||||
psaTicketId={psaTicketId}
|
||||
onFieldSave={handleTriageFieldSave}
|
||||
onResolve={() => session.setShowConclude(true)}
|
||||
onClose={() => session.setShowConclude(true)}
|
||||
/>
|
||||
<ViewToggle currentView="cockpit" sessionId={session.activeChatId} />
|
||||
{/* View toggle bar — desktop only (mobile has it in the header) */}
|
||||
<div className="hidden sm:flex items-center justify-end px-4 py-1.5 border-b border-border/50">
|
||||
<ViewToggle currentView="cockpit" sessionId={session.activeChatId} />
|
||||
</div>
|
||||
|
||||
{/* Resizable work zone + conversation log split */}
|
||||
<div ref={splitContainerRef} className="flex-1 flex flex-col min-h-0 relative">
|
||||
|
||||
@@ -116,7 +116,7 @@ export default function FlowPilotPage() {
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
{session.activeChatId && (
|
||||
<ViewToggle currentView="flowpilot" sessionId={session.activeChatId} />
|
||||
<ViewToggle currentView="flowpilot" sessionId={session.activeChatId} showSubtitle={false} />
|
||||
)}
|
||||
<button
|
||||
onClick={session.handleNewChat}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { ActiveFlowPilotSessions } from '@/components/dashboard/ActiveFlowPilotS
|
||||
import { PerformanceCards } from '@/components/dashboard/PerformanceCards'
|
||||
import { KnowledgeBaseCards } from '@/components/dashboard/KnowledgeBaseCards'
|
||||
import { TeamSummary } from '@/components/dashboard/TeamSummary'
|
||||
import { ViewToggle } from '@/components/assistant/ViewToggle'
|
||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||
|
||||
function SectionLabel({ children, action }: { children: React.ReactNode; action?: React.ReactNode }) {
|
||||
return (
|
||||
@@ -21,6 +23,7 @@ function SectionLabel({ children, action }: { children: React.ReactNode; action?
|
||||
|
||||
export function QuickStartPage() {
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const preferredView = useUserPreferencesStore(s => s.preferredFlowPilotView)
|
||||
|
||||
const now = new Date()
|
||||
const greeting = now.getHours() < 12
|
||||
@@ -46,6 +49,12 @@ export function QuickStartPage() {
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* View preference — standalone row above input */}
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
<span className="text-xs text-muted-foreground">Launch in</span>
|
||||
<ViewToggle currentView={preferredView} showSubtitle={false} />
|
||||
</div>
|
||||
|
||||
{/* Chat-style input */}
|
||||
<StartSessionInput />
|
||||
|
||||
|
||||
Reference in New Issue
Block a user