55 ConversationEmptyState ,
66 ConversationScrollButton
77} from "@flanksource-ui/components/ai-elements/conversation" ;
8- import { SquarePen , X } from "lucide-react" ;
8+ import { Check , History , SquarePen , Trash2 , X } from "lucide-react" ;
99import { Loader } from "@flanksource-ui/components/ai-elements/loader" ;
1010import {
1111 Confirmation ,
@@ -44,6 +44,14 @@ import {
4444} from "@flanksource-ui/components/ai-elements/suggestion" ;
4545import { Button } from "@flanksource-ui/components/ui/button" ;
4646import { Card } from "@flanksource-ui/components/ui/card" ;
47+ import {
48+ DropdownMenu ,
49+ DropdownMenuContent ,
50+ DropdownMenuItem ,
51+ DropdownMenuLabel ,
52+ DropdownMenuSeparator ,
53+ DropdownMenuTrigger
54+ } from "@flanksource-ui/components/ui/dropdown-menu" ;
4755import {
4856 ChartConfig ,
4957 ChartContainer ,
@@ -52,8 +60,8 @@ import {
5260} from "@flanksource-ui/components/ui/chart" ;
5361import { formatTick , parseTimestamp } from "@flanksource-ui/lib/timeseries" ;
5462import {
55- clearActiveAIConversation ,
56- saveActiveAIConversation
63+ saveAIConversation ,
64+ type AIConversationRecord
5765} from "@flanksource-ui/lib/ai-chat-history" ;
5866import { cn } from "@flanksource-ui/lib/utils" ;
5967import type { FileUIPart , ReasoningUIPart , UIMessage } from "ai" ;
@@ -70,6 +78,45 @@ type PlotTimeseriesOutput = {
7078 title ?: string ;
7179} ;
7280
81+ const HISTORY_PREVIEW_MAX_LENGTH = 72 ;
82+
83+ function getConversationPreview ( messages : UIMessage [ ] ) : string {
84+ for ( const message of messages ) {
85+ if ( message . role !== "user" ) {
86+ continue ;
87+ }
88+
89+ for ( const part of message . parts ) {
90+ if ( part . type !== "text" ) {
91+ continue ;
92+ }
93+
94+ const normalizedText = part . text . replace ( / \s + / g, " " ) . trim ( ) ;
95+
96+ if ( ! normalizedText ) {
97+ continue ;
98+ }
99+
100+ if ( normalizedText . length <= HISTORY_PREVIEW_MAX_LENGTH ) {
101+ return normalizedText ;
102+ }
103+
104+ return `${ normalizedText . slice ( 0 , HISTORY_PREVIEW_MAX_LENGTH - 1 ) } …` ;
105+ }
106+ }
107+
108+ return "Untitled conversation" ;
109+ }
110+
111+ function formatConversationTime ( updatedAt : number ) : string {
112+ return new Date ( updatedAt ) . toLocaleString ( "en-US" , {
113+ month : "short" ,
114+ day : "numeric" ,
115+ hour : "2-digit" ,
116+ minute : "2-digit"
117+ } ) ;
118+ }
119+
73120const isPlotTimeseriesOutput = (
74121 output : unknown
75122) : output is PlotTimeseriesOutput => {
@@ -194,6 +241,14 @@ export type AIChatProps = {
194241 onClose ?: ( ) => void ;
195242 onNewChat ?: ( ) => void ;
196243 quickPrompts ?: string [ ] ;
244+ activeConversationId ?: string ;
245+ conversationHistory ?: AIConversationRecord [ ] ;
246+ onSelectConversation ?: ( conversationId : string ) => void ;
247+ onDeleteConversation ?: ( conversationId : string ) => void ;
248+ onConversationPersisted ?: (
249+ conversationId : string ,
250+ messages : UIMessage [ ]
251+ ) => void ;
197252} ;
198253
199254export function AIChat ( {
@@ -202,7 +257,12 @@ export function AIChat({
202257 id,
203258 onClose,
204259 onNewChat,
205- quickPrompts
260+ quickPrompts,
261+ activeConversationId,
262+ conversationHistory,
263+ onSelectConversation,
264+ onDeleteConversation,
265+ onConversationPersisted
206266} : AIChatProps ) {
207267 const {
208268 messages,
@@ -223,8 +283,9 @@ export function AIChat({
223283 return ;
224284 }
225285
226- void saveActiveAIConversation ( chat . id , messages ) ;
227- } , [ chat . id , messages ] ) ;
286+ void saveAIConversation ( chat . id , messages ) ;
287+ onConversationPersisted ?.( chat . id , messages ) ;
288+ } , [ chat . id , messages , onConversationPersisted ] ) ;
228289
229290 // Auto-send when chat mounts with a pre-seeded user message (e.g. from setChatMessages).
230291 const hasSentOnMount = useRef ( false ) ;
@@ -277,8 +338,6 @@ export function AIChat({
277338 ) ;
278339
279340 const handleNewChat = useCallback ( ( ) => {
280- void clearActiveAIConversation ( ) ;
281-
282341 if ( onNewChat ) {
283342 onNewChat ( ) ;
284343 } else {
@@ -500,6 +559,10 @@ export function AIChat({
500559 ) ;
501560 } ;
502561
562+ const visibleConversationHistory = useMemo ( ( ) => {
563+ return ( conversationHistory ?? [ ] ) . slice ( 0 , 20 ) ;
564+ } , [ conversationHistory ] ) ;
565+
503566 const errorMessage = error
504567 ? error instanceof Error
505568 ? error . message
@@ -510,6 +573,75 @@ export function AIChat({
510573 < div className = { cn ( "flex h-full flex-1 flex-col gap-4" , className ) } >
511574 < Card className = "relative flex h-full flex-1 flex-col bg-card" >
512575 < div className = "absolute left-3 top-3 z-10 flex items-center gap-2" >
576+ { onSelectConversation ? (
577+ < DropdownMenu >
578+ < DropdownMenuTrigger asChild >
579+ < Button
580+ aria-label = "View conversation history"
581+ size = "sm"
582+ variant = "outline"
583+ >
584+ < History className = "mr-2 h-3.5 w-3.5" />
585+ History
586+ </ Button >
587+ </ DropdownMenuTrigger >
588+ < DropdownMenuContent align = "start" className = "w-80" >
589+ < DropdownMenuLabel > Conversation History</ DropdownMenuLabel >
590+ < DropdownMenuSeparator />
591+ { visibleConversationHistory . length > 0 ? (
592+ visibleConversationHistory . map ( ( conversation ) => {
593+ const isActive = activeConversationId === conversation . id ;
594+
595+ return (
596+ < DropdownMenuItem
597+ className = "group"
598+ key = { conversation . id }
599+ onSelect = { ( ) => onSelectConversation ( conversation . id ) }
600+ >
601+ < div className = "flex min-w-0 flex-1 flex-col" >
602+ < span className = "truncate font-medium" >
603+ { getConversationPreview ( conversation . messages ) }
604+ </ span >
605+ < span className = "truncate text-xs text-muted-foreground" >
606+ { formatConversationTime ( conversation . updatedAt ) }
607+ </ span >
608+ </ div >
609+ < div className = "ml-2 flex items-center gap-1" >
610+ { isActive ? (
611+ < Check className = "h-4 w-4 text-primary" />
612+ ) : null }
613+ { onDeleteConversation ? (
614+ < button
615+ type = "button"
616+ aria-label = "Delete conversation"
617+ className = "rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover:opacity-100 group-focus:opacity-100"
618+ title = "Delete conversation"
619+ onPointerDown = { ( event ) => {
620+ event . preventDefault ( ) ;
621+ event . stopPropagation ( ) ;
622+ } }
623+ onClick = { ( event ) => {
624+ event . preventDefault ( ) ;
625+ event . stopPropagation ( ) ;
626+ onDeleteConversation ( conversation . id ) ;
627+ } }
628+ >
629+ < Trash2 className = "h-3.5 w-3.5" />
630+ </ button >
631+ ) : null }
632+ </ div >
633+ </ DropdownMenuItem >
634+ ) ;
635+ } )
636+ ) : (
637+ < DropdownMenuItem disabled >
638+ No saved conversations yet
639+ </ DropdownMenuItem >
640+ ) }
641+ </ DropdownMenuContent >
642+ </ DropdownMenu >
643+ ) : null }
644+
513645 { onNewChat ? (
514646 < Button
515647 aria-label = "Start a new conversation"
0 commit comments