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,15 @@ 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 ;
252+ storageScopeKey ?: string ;
197253} ;
198254
199255export function AIChat ( {
@@ -202,7 +258,13 @@ export function AIChat({
202258 id,
203259 onClose,
204260 onNewChat,
205- quickPrompts
261+ quickPrompts,
262+ activeConversationId,
263+ conversationHistory,
264+ onSelectConversation,
265+ onDeleteConversation,
266+ onConversationPersisted,
267+ storageScopeKey
206268} : AIChatProps ) {
207269 const {
208270 messages,
@@ -223,8 +285,9 @@ export function AIChat({
223285 return ;
224286 }
225287
226- void saveActiveAIConversation ( chat . id , messages ) ;
227- } , [ chat . id , messages ] ) ;
288+ void saveAIConversation ( chat . id , messages , storageScopeKey ) ;
289+ onConversationPersisted ?.( chat . id , messages ) ;
290+ } , [ chat . id , messages , onConversationPersisted , storageScopeKey ] ) ;
228291
229292 // Auto-send when chat mounts with a pre-seeded user message (e.g. from setChatMessages).
230293 const hasSentOnMount = useRef ( false ) ;
@@ -277,8 +340,6 @@ export function AIChat({
277340 ) ;
278341
279342 const handleNewChat = useCallback ( ( ) => {
280- void clearActiveAIConversation ( ) ;
281-
282343 if ( onNewChat ) {
283344 onNewChat ( ) ;
284345 } else {
@@ -500,6 +561,10 @@ export function AIChat({
500561 ) ;
501562 } ;
502563
564+ const visibleConversationHistory = useMemo ( ( ) => {
565+ return ( conversationHistory ?? [ ] ) . slice ( 0 , 20 ) ;
566+ } , [ conversationHistory ] ) ;
567+
503568 const errorMessage = error
504569 ? error instanceof Error
505570 ? error . message
@@ -510,6 +575,75 @@ export function AIChat({
510575 < div className = { cn ( "flex h-full flex-1 flex-col gap-4" , className ) } >
511576 < Card className = "relative flex h-full flex-1 flex-col bg-card" >
512577 < div className = "absolute left-3 top-3 z-10 flex items-center gap-2" >
578+ { onSelectConversation ? (
579+ < DropdownMenu >
580+ < DropdownMenuTrigger asChild >
581+ < Button
582+ aria-label = "View conversation history"
583+ size = "sm"
584+ variant = "outline"
585+ >
586+ < History className = "mr-2 h-3.5 w-3.5" />
587+ History
588+ </ Button >
589+ </ DropdownMenuTrigger >
590+ < DropdownMenuContent align = "start" className = "w-80" >
591+ < DropdownMenuLabel > Conversation History</ DropdownMenuLabel >
592+ < DropdownMenuSeparator />
593+ { visibleConversationHistory . length > 0 ? (
594+ visibleConversationHistory . map ( ( conversation ) => {
595+ const isActive = activeConversationId === conversation . id ;
596+
597+ return (
598+ < DropdownMenuItem
599+ className = "group"
600+ key = { conversation . id }
601+ onSelect = { ( ) => onSelectConversation ( conversation . id ) }
602+ >
603+ < div className = "flex min-w-0 flex-1 flex-col" >
604+ < span className = "truncate font-medium" >
605+ { getConversationPreview ( conversation . messages ) }
606+ </ span >
607+ < span className = "truncate text-xs text-muted-foreground" >
608+ { formatConversationTime ( conversation . updatedAt ) }
609+ </ span >
610+ </ div >
611+ < div className = "ml-2 flex items-center gap-1" >
612+ { isActive ? (
613+ < Check className = "h-4 w-4 text-primary" />
614+ ) : null }
615+ { onDeleteConversation ? (
616+ < button
617+ type = "button"
618+ aria-label = "Delete conversation"
619+ 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"
620+ title = "Delete conversation"
621+ onPointerDown = { ( event ) => {
622+ event . preventDefault ( ) ;
623+ event . stopPropagation ( ) ;
624+ } }
625+ onClick = { ( event ) => {
626+ event . preventDefault ( ) ;
627+ event . stopPropagation ( ) ;
628+ onDeleteConversation ( conversation . id ) ;
629+ } }
630+ >
631+ < Trash2 className = "h-3.5 w-3.5" />
632+ </ button >
633+ ) : null }
634+ </ div >
635+ </ DropdownMenuItem >
636+ ) ;
637+ } )
638+ ) : (
639+ < DropdownMenuItem disabled >
640+ No saved conversations yet
641+ </ DropdownMenuItem >
642+ ) }
643+ </ DropdownMenuContent >
644+ </ DropdownMenu >
645+ ) : null }
646+
513647 { onNewChat ? (
514648 < Button
515649 aria-label = "Start a new conversation"
0 commit comments