Skip to content

Commit 2cbc202

Browse files
committed
feat(ai-chat): add local conversation history and resume UI
1 parent b3ad3d1 commit 2cbc202

File tree

3 files changed

+512
-71
lines changed

3 files changed

+512
-71
lines changed

src/components/ai/AiChat.tsx

Lines changed: 140 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
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";
99
import { Loader } from "@flanksource-ui/components/ai-elements/loader";
1010
import {
1111
Confirmation,
@@ -44,6 +44,14 @@ import {
4444
} from "@flanksource-ui/components/ai-elements/suggestion";
4545
import { Button } from "@flanksource-ui/components/ui/button";
4646
import { 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";
4755
import {
4856
ChartConfig,
4957
ChartContainer,
@@ -52,8 +60,8 @@ import {
5260
} from "@flanksource-ui/components/ui/chart";
5361
import { formatTick, parseTimestamp } from "@flanksource-ui/lib/timeseries";
5462
import {
55-
clearActiveAIConversation,
56-
saveActiveAIConversation
63+
saveAIConversation,
64+
type AIConversationRecord
5765
} from "@flanksource-ui/lib/ai-chat-history";
5866
import { cn } from "@flanksource-ui/lib/utils";
5967
import 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+
73120
const 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

199254
export 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

Comments
 (0)