11import { useCallback , useEffect , useRef , useState } from "react" ;
22import { useAgentStore } from "../../store/useAgentStore" ;
33import { useAuthStore } from "../../store/useAuthStore" ;
4- import { listAgentModels , listAgentSkills } from "../../api/agent-client" ;
4+ import { getAgentSessionState , listAgentModels , listAgentSkills } from "../../api/agent-client" ;
55import { getWs } from "../../store/useWebSocket" ;
66import AgentMessageComponent from "./AgentMessage" ;
7- import type { AgentSkill } from "../../types/agent" ;
7+ import QuestionCard from "./QuestionCard" ;
8+ import type { AgentPlanItem , AgentSkill } from "../../types/agent" ;
89
910export default function AgentChatSidebar ( ) {
1011 const ws = useRef ( getWs ( ) ) . current ;
@@ -20,6 +21,7 @@ export default function AgentChatSidebar() {
2021 sessionId,
2122 status,
2223 messages,
24+ plan,
2325 models,
2426 selectedModel,
2527 modelsLoading,
@@ -34,6 +36,7 @@ export default function AgentChatSidebar() {
3436 toggleSkill,
3537 setSkillsLoading,
3638 addUserMessage,
39+ hydrateSession,
3740 clearSession,
3841 } = useAgentStore ( ) ;
3942
@@ -67,6 +70,24 @@ export default function AgentChatSidebar() {
6770 . finally ( ( ) => setSkillsLoading ( false ) ) ;
6871 } , [ skills . length , setSkills , setSelectedSkillIds , setSkillsLoading ] ) ;
6972
73+ // Hydrate session from sessionStorage on mount
74+ useEffect ( ( ) => {
75+ if ( sessionId ) return ; // Already have a session
76+ const storedId = sessionStorage . getItem ( "agent_session_id" ) ;
77+ if ( ! storedId ) return ;
78+ getAgentSessionState ( storedId )
79+ . then ( ( state ) => {
80+ if ( state ) {
81+ hydrateSession ( state ) ;
82+ } else {
83+ sessionStorage . removeItem ( "agent_session_id" ) ;
84+ }
85+ } )
86+ . catch ( ( ) => {
87+ sessionStorage . removeItem ( "agent_session_id" ) ;
88+ } ) ;
89+ } , [ ] ) ; // eslint-disable-line react-hooks/exhaustive-deps
90+
7091 const [ showScrollTop , setShowScrollTop ] = useState ( false ) ;
7192
7293 const handleScroll = ( ) => {
@@ -84,7 +105,7 @@ export default function AgentChatSidebar() {
84105 }
85106 } ) ;
86107
87- const isBusy = status === "thinking" || status === "executing" || status === "planning" ;
108+ const isBusy = status === "thinking" || status === "executing" || status === "planning" || status === "awaiting_input" ;
88109 const lastMsg = messages [ messages . length - 1 ] ;
89110 const isStreaming = isBusy && lastMsg ?. role === "assistant" && ! lastMsg . done ;
90111 const showBusyIndicator = isBusy && ! isStreaming ;
@@ -173,6 +194,9 @@ export default function AgentChatSidebar() {
173194 isBusy = { isBusy }
174195 />
175196
197+ { /* Sticky Plan */ }
198+ < StickyPlan plan = { plan } />
199+
176200 { /* Messages */ }
177201 < div className = "relative flex-1 overflow-hidden" >
178202 < div
@@ -191,15 +215,15 @@ export default function AgentChatSidebar() {
191215 </ div >
192216 </ div >
193217 ) }
194- { messages . map ( ( msg ) => (
218+ { messages . filter ( ( msg ) => msg . role !== "plan" ) . map ( ( msg ) => (
195219 < AgentMessageComponent key = { msg . id } message = { msg } />
196220 ) ) }
197221 { showBusyIndicator && (
198222 < div className = "py-1.5" >
199223 < div className = "flex items-center gap-1.5" >
200224 < div className = "w-2 h-2 rounded-full animate-pulse" style = { { background : "var(--success)" } } />
201225 < span className = "text-[11px] font-semibold" style = { { color : "var(--success)" } } >
202- { status === "thinking" ? "Thinking..." : status === "executing" ? "Executing..." : "Planning..." }
226+ { status === "thinking" ? "Thinking..." : status === "executing" ? "Executing..." : status === "awaiting_input" ? "Waiting for answer..." : "Planning..." }
203227 </ span >
204228 </ div >
205229 </ div >
@@ -219,6 +243,9 @@ export default function AgentChatSidebar() {
219243 ) }
220244 </ div >
221245
246+ { /* Question card */ }
247+ < QuestionCard />
248+
222249 { /* Input */ }
223250 < div
224251 className = "flex items-end gap-2 px-3 py-2 border-t"
@@ -414,3 +441,88 @@ function Header({
414441 </ div >
415442 ) ;
416443}
444+
445+ const MAX_VISIBLE_PLAN_ITEMS = 10 ;
446+
447+ function StickyPlan ( { plan } : { plan : AgentPlanItem [ ] } ) {
448+ const completed = plan . filter ( ( t ) => t . status === "completed" ) . length ;
449+ const uncompleted = plan . filter ( ( t ) => t . status !== "completed" ) ;
450+ const allDone = plan . length > 0 && completed === plan . length ;
451+ const [ collapsed , setCollapsed ] = useState ( false ) ;
452+ const prevUncompletedCount = useRef ( uncompleted . length ) ;
453+
454+ // Auto-collapse when all tasks complete
455+ useEffect ( ( ) => {
456+ if ( allDone ) setCollapsed ( true ) ;
457+ } , [ allDone ] ) ;
458+
459+ // Auto-expand when new uncompleted items appear
460+ useEffect ( ( ) => {
461+ if ( uncompleted . length > prevUncompletedCount . current ) {
462+ setCollapsed ( false ) ;
463+ }
464+ prevUncompletedCount . current = uncompleted . length ;
465+ } , [ uncompleted . length ] ) ;
466+
467+ if ( plan . length === 0 ) return null ;
468+
469+ // Show all uncompleted + fill remaining slots with most recent completed
470+ const completedItems = plan . filter ( ( t ) => t . status === "completed" ) ;
471+ const remainingSlots = Math . max ( 0 , MAX_VISIBLE_PLAN_ITEMS - uncompleted . length ) ;
472+ const recentCompleted = completedItems . slice ( - remainingSlots ) ;
473+ // Preserve original order: show recent completed first, then uncompleted
474+ const visibleItems = [ ...recentCompleted , ...uncompleted ] ;
475+ const hiddenCount = plan . length - visibleItems . length ;
476+
477+ return (
478+ < div
479+ className = "shrink-0 border-b"
480+ style = { { borderColor : "var(--border)" , background : "var(--bg-secondary)" } }
481+ >
482+ < button
483+ onClick = { ( ) => setCollapsed ( ! collapsed ) }
484+ className = "w-full flex items-center gap-2 px-3 py-1.5 cursor-pointer"
485+ style = { { background : "none" , border : "none" } }
486+ >
487+ < div className = "w-2 h-2 rounded-full" style = { { background : "var(--accent)" } } />
488+ < span className = "text-[11px] font-semibold" style = { { color : "var(--accent)" } } >
489+ Plan ({ completed } /{ plan . length } completed)
490+ </ span >
491+ < svg
492+ width = "10" height = "10" viewBox = "0 0 24 24" fill = "none" stroke = "var(--text-muted)" strokeWidth = "2"
493+ className = "ml-auto"
494+ style = { { transform : collapsed ? "rotate(0deg)" : "rotate(180deg)" , transition : "transform 0.15s" } }
495+ >
496+ < path d = "M6 9l6 6 6-6" />
497+ </ svg >
498+ </ button >
499+ { ! collapsed && (
500+ < div className = "px-3 pb-2 space-y-1" >
501+ { hiddenCount > 0 && (
502+ < div className = "text-[11px]" style = { { color : "var(--text-muted)" } } >
503+ { hiddenCount } earlier completed task{ hiddenCount !== 1 ? "s" : "" } hidden
504+ </ div >
505+ ) }
506+ { visibleItems . map ( ( item , i ) => (
507+ < div key = { i } className = "flex items-center gap-2 text-sm" >
508+ { item . status === "completed" ? (
509+ < svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "var(--success)" strokeWidth = "2.5" strokeLinecap = "round" > < path d = "M20 6L9 17l-5-5" /> </ svg >
510+ ) : item . status === "in_progress" ? (
511+ < span className = "w-3.5 h-3.5 flex items-center justify-center" >
512+ < span className = "w-2 h-2 rounded-full animate-pulse" style = { { background : "var(--accent)" } } />
513+ </ span >
514+ ) : (
515+ < span className = "w-3.5 h-3.5 flex items-center justify-center" >
516+ < span className = "w-2 h-2 rounded-full" style = { { background : "var(--text-muted)" , opacity : 0.4 } } />
517+ </ span >
518+ ) }
519+ < span style = { { color : item . status === "completed" ? "var(--text-muted)" : "var(--text-primary)" , textDecoration : item . status === "completed" ? "line-through" : "none" } } >
520+ { item . title }
521+ </ span >
522+ </ div >
523+ ) ) }
524+ </ div >
525+ ) }
526+ </ div >
527+ ) ;
528+ }
0 commit comments