+ {/* Header */}
+
+
+
Agent Session
+
+ {!agentsLoading && agents.length > 0 && (
+
+ Agent Session:
+ {
+ console.log("🤖 User selected agent:", agentId);
+ setSelectedAgentId(agentId);
+ SessionUtils.saveCurrentAgentId(agentId);
+ loadAgentConfig(agentId);
+ loadAgentSessions(agentId);
+ }}
+ disabled={agentsLoading}
+ >
+
+
+
+
+ {agents.map(agent => (
+
+ {(() => {
+ if (
+ agent.agent_config &&
+ "name" in agent.agent_config &&
+ typeof agent.agent_config.name === "string"
+ ) {
+ return agent.agent_config.name;
+ }
+ if (
+ agent.agent_config &&
+ "agent_name" in agent.agent_config &&
+ typeof agent.agent_config.agent_name === "string"
+ ) {
+ return agent.agent_config.agent_name;
+ }
+ return `Agent ${agent.agent_id.slice(0, 8)}...`;
+ })()}
+
+ ))}
+
+
+ {selectedAgentId && agents.length > 1 && (
+ deleteAgent(selectedAgentId)}
+ variant="outline"
+ size="sm"
+ className="text-destructive hover:text-destructive hover:bg-destructive/10"
+ title="Delete current agent"
+ >
+
+
+ )}
+
+ )}
+
setShowCreateAgent(true)}
+ variant="outline"
+ size="sm"
+ >
+ + New Agent
+
+ {!agentsLoading && agents.length > 0 && (
+
+ Clear Chat
+
+ )}
+
+
+
+ {/* Main Two-Column Layout */}
+
+ {/* Left Column - Configuration Panel */}
+
+
+ Settings
+
+
+ {/* Model Configuration */}
+
+
+ Model Configuration
+
+
+
+
Model
+
+
+
+
+
+ {models.map(model => (
+
+ {model.identifier}
+
+ ))}
+
+
+ {modelsError && (
+
{modelsError}
+ )}
+
+
+
+
+ Agent Instructions
+
+
+ {(selectedAgentId &&
+ agents.find(a => a.agent_id === selectedAgentId)
+ ?.agent_config?.instructions) ||
+ "No agent selected"}
+
+
+ Instructions are set when creating an agent and cannot be
+ changed.
+
+
+
+
+
+ {/* Agent Tools */}
+
+
+ Agent Tools
+
+
+
+
+ Configured Tools (Coming Soon)
+
+
+ {selectedAgentConfig?.toolgroups &&
+ selectedAgentConfig.toolgroups.length > 0 ? (
+ selectedAgentConfig.toolgroups.map(
+ (
+ toolgroup:
+ | string
+ | { name: string; args: Record
},
+ index: number
+ ) => {
+ const toolName =
+ typeof toolgroup === "string"
+ ? toolgroup
+ : toolgroup.name;
+ const toolArgs =
+ typeof toolgroup === "object" ? toolgroup.args : null;
+
+ return (
+
+
+
+ {toolName}
+
+
+ {toolName.includes("rag")
+ ? "🔍 RAG"
+ : toolName.includes("search")
+ ? "🌐 Search"
+ : "🔧 Tool"}
+
+
+ {toolArgs && Object.keys(toolArgs).length > 0 && (
+
+ Args: {" "}
+ {Object.entries(toolArgs)
+ .map(
+ ([key, value]) =>
+ `${key}: ${JSON.stringify(value)}`
+ )
+ .join(", ")}
+
+ )}
+
+ );
+ }
+ )
+ ) : (
+
+
+ No tools configured
+
+
+ This agent only has text generation capabilities
+
+
+ )}
+
+
+ Tools are configured when creating an agent and provide
+ additional capabilities like web search, math calculations, or
+ RAG document retrieval.
+
+
+
+
+
+
+ {/* Right Column - Chat Interface */}
+
+ {error && (
+
+ )}
+
+
+ setCurrentSession(prev =>
+ prev ? { ...prev, messages, updatedAt: Date.now() } : prev
+ )
+ }
+ />
- {modelsError && (
-
-
{modelsError}
+ {/* Create Agent Modal */}
+ {showCreateAgent && (
+
+
+ Create New Agent
+
+
+
+
+ Agent Name (optional)
+
+ setNewAgentName(e.target.value)}
+ placeholder="My Custom Agent"
+ />
+
+
+
+ Model
+
+
+
+
+
+ {models.map(model => (
+
+ {model.identifier}
+
+ ))}
+
+
+
+
+
+
+ System Instructions
+
+
+
+
+
+ Tools (optional)
+
+
+ NOTE: Tools are not yet implemented
+
+
+ Available toolgroups: {availableToolgroups.length} found
+
+
+ {availableToolgroups.length === 0 ? (
+
+ Loading toolgroups...
+
+ ) : (
+ availableToolgroups.map(toolgroup => (
+
+ {
+ console.log(
+ "Tool selection changed:",
+ toolgroup.identifier,
+ e.target.checked
+ );
+ if (e.target.checked) {
+ setSelectedToolgroups(prev => {
+ const newSelection = [
+ ...prev,
+ toolgroup.identifier,
+ ];
+ console.log(
+ "New selected toolgroups:",
+ newSelection
+ );
+ return newSelection;
+ });
+ } else {
+ setSelectedToolgroups(prev => {
+ const newSelection = prev.filter(
+ id => id !== toolgroup.identifier
+ );
+ console.log(
+ "New selected toolgroups:",
+ newSelection
+ );
+ return newSelection;
+ });
+ }
+ }}
+ className="rounded border-input"
+ />
+
+
+ {toolgroup.identifier}
+
+
+ ({toolgroup.provider_id})
+
+
+
+ ))
+ )}
+
+ {selectedToolgroups.length === 0 && (
+
+ No tools selected - agent will only have text generation
+ capabilities.
+
+ )}
+
+ Note: Selected tools will be configured for
+ the agent. Some tools like RAG may require additional vector
+ DB configuration, and web search tools need API keys. Basic
+ text generation agents work without tools.
+
+
+
+
+
+ {
+ try {
+ await createNewAgent(
+ newAgentName,
+ newAgentInstructions,
+ selectedModel,
+ selectedToolgroups
+ );
+ setShowCreateAgent(false);
+ setNewAgentName("");
+ setNewAgentInstructions("You are a helpful assistant.");
+ setSelectedToolgroups([]);
+ } catch (error) {
+ console.error("Failed to create agent:", error);
+ }
+ }}
+ className="flex-1"
+ disabled={!selectedModel || !newAgentInstructions.trim()}
+ >
+ Create Agent
+
+ {
+ setShowCreateAgent(false);
+ setNewAgentName("");
+ setNewAgentInstructions("You are a helpful assistant.");
+ setSelectedToolgroups([]);
+ }}
+ className="flex-1"
+ >
+ Cancel
+
+
+
)}
-
- {error && (
-
- )}
-
-
);
}
diff --git a/llama_stack/ui/app/favicon.ico b/llama_stack/ui/app/favicon.ico
deleted file mode 100644
index 718d6fea4..000000000
Binary files a/llama_stack/ui/app/favicon.ico and /dev/null differ
diff --git a/llama_stack/ui/app/globals.css b/llama_stack/ui/app/globals.css
index dc98be74c..000dad718 100644
--- a/llama_stack/ui/app/globals.css
+++ b/llama_stack/ui/app/globals.css
@@ -120,3 +120,44 @@
@apply bg-background text-foreground;
}
}
+
+@layer utilities {
+ .animate-typing-dot-1 {
+ animation: typing-dot-bounce-1 0.8s cubic-bezier(0.4, 0, 0.6, 1) infinite;
+ }
+
+ .animate-typing-dot-2 {
+ animation: typing-dot-bounce-2 0.8s cubic-bezier(0.4, 0, 0.6, 1) infinite;
+ }
+
+ .animate-typing-dot-3 {
+ animation: typing-dot-bounce-3 0.8s cubic-bezier(0.4, 0, 0.6, 1) infinite;
+ }
+
+ @keyframes typing-dot-bounce-1 {
+ 0%, 15%, 85%, 100% {
+ transform: translateY(0);
+ }
+ 7.5% {
+ transform: translateY(-6px);
+ }
+ }
+
+ @keyframes typing-dot-bounce-2 {
+ 0%, 15%, 35%, 85%, 100% {
+ transform: translateY(0);
+ }
+ 25% {
+ transform: translateY(-6px);
+ }
+ }
+
+ @keyframes typing-dot-bounce-3 {
+ 0%, 35%, 55%, 85%, 100% {
+ transform: translateY(0);
+ }
+ 45% {
+ transform: translateY(-6px);
+ }
+ }
+}
diff --git a/llama_stack/ui/app/layout.tsx b/llama_stack/ui/app/layout.tsx
index 19fb18c36..8b91341e4 100644
--- a/llama_stack/ui/app/layout.tsx
+++ b/llama_stack/ui/app/layout.tsx
@@ -18,6 +18,9 @@ const geistMono = Geist_Mono({
export const metadata: Metadata = {
title: "Llama Stack",
description: "Llama Stack UI",
+ icons: {
+ icon: "/favicon.ico",
+ },
};
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
diff --git a/llama_stack/ui/components/chat-playground/chat-message.tsx b/llama_stack/ui/components/chat-playground/chat-message.tsx
index 84c798e29..3545e6a29 100644
--- a/llama_stack/ui/components/chat-playground/chat-message.tsx
+++ b/llama_stack/ui/components/chat-playground/chat-message.tsx
@@ -161,10 +161,12 @@ export const ChatMessage: React.FC
= ({
const isUser = role === "user";
- const formattedTime = createdAt?.toLocaleTimeString("en-US", {
- hour: "2-digit",
- minute: "2-digit",
- });
+ const formattedTime = createdAt
+ ? new Date(createdAt).toLocaleTimeString("en-US", {
+ hour: "2-digit",
+ minute: "2-digit",
+ })
+ : undefined;
if (isUser) {
return (
@@ -185,7 +187,7 @@ export const ChatMessage: React.FC = ({
{showTimeStamp && createdAt ? (
= ({
{showTimeStamp && createdAt ? (
= ({
{showTimeStamp && createdAt ? (
({
+ useAuthClient: jest.fn(() => mockClient),
+}));
+
+// Mock additional SessionUtils methods that are now being used
+jest.mock("./conversations", () => {
+ const actual = jest.requireActual("./conversations");
+ return {
+ ...actual,
+ SessionUtils: {
+ ...actual.SessionUtils,
+ saveSessionData: jest.fn(),
+ loadSessionData: jest.fn(),
+ saveAgentConfig: jest.fn(),
+ loadAgentConfig: jest.fn(),
+ clearAgentCache: jest.fn(),
+ },
+ };
+});
+
+const localStorageMock = {
+ getItem: jest.fn(),
+ setItem: jest.fn(),
+ removeItem: jest.fn(),
+ clear: jest.fn(),
+};
+
+Object.defineProperty(window, "localStorage", {
+ value: localStorageMock,
+ writable: true,
+});
+
+// Mock crypto.randomUUID for test environment
+let uuidCounter = 0;
+Object.defineProperty(globalThis, "crypto", {
+ value: {
+ randomUUID: jest.fn(() => `test-uuid-${++uuidCounter}`),
+ },
+ writable: true,
+});
+
+describe("SessionManager", () => {
+ const mockSession: ChatSession = {
+ id: "session_123",
+ name: "Test Session",
+ messages: [
+ {
+ id: "msg_1",
+ role: "user",
+ content: "Hello",
+ createdAt: new Date(),
+ },
+ ],
+ selectedModel: "test-model",
+ systemMessage: "You are a helpful assistant.",
+ agentId: "agent_123",
+ createdAt: 1710000000,
+ updatedAt: 1710001000,
+ };
+
+ const mockAgentSessions = [
+ {
+ session_id: "session_123",
+ session_name: "Test Session",
+ started_at: "2024-01-01T00:00:00Z",
+ turns: [],
+ },
+ {
+ session_id: "session_456",
+ session_name: "Another Session",
+ started_at: "2024-01-01T01:00:00Z",
+ turns: [],
+ },
+ ];
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ localStorageMock.getItem.mockReturnValue(null);
+ localStorageMock.setItem.mockImplementation(() => {});
+ mockClient.agents.session.list.mockResolvedValue({
+ data: mockAgentSessions,
+ });
+ mockClient.agents.session.create.mockResolvedValue({
+ session_id: "new_session_123",
+ });
+ mockClient.agents.session.delete.mockResolvedValue(undefined);
+ mockClient.agents.session.retrieve.mockResolvedValue({
+ session_id: "test-session",
+ session_name: "Test Session",
+ started_at: new Date().toISOString(),
+ turns: [],
+ });
+ uuidCounter = 0; // Reset UUID counter for consistent test behavior
+ });
+
+ describe("Component Rendering", () => {
+ test("does not render when no agent is selected", async () => {
+ const { container } = await act(async () => {
+ return render(
+
+ );
+ });
+
+ expect(container.firstChild).toBeNull();
+ });
+
+ test("renders loading state initially", async () => {
+ mockClient.agents.session.list.mockImplementation(
+ () => new Promise(() => {}) // Never resolves to simulate loading
+ );
+
+ await act(async () => {
+ render(
+
+ );
+ });
+
+ expect(screen.getByText("Select Session")).toBeInTheDocument();
+ // When loading, the "+ New" button should be disabled
+ expect(screen.getByText("+ New")).toBeDisabled();
+ });
+
+ test("renders session selector when agent sessions are loaded", async () => {
+ await act(async () => {
+ render(
+
+ );
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText("Select Session")).toBeInTheDocument();
+ });
+ });
+
+ test("renders current session name when session is selected", async () => {
+ await act(async () => {
+ render(
+
+ );
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText("Test Session")).toBeInTheDocument();
+ });
+ });
+ });
+
+ describe("Agent API Integration", () => {
+ test("loads sessions from agent API on mount", async () => {
+ await act(async () => {
+ render(
+
+ );
+ });
+
+ await waitFor(() => {
+ expect(mockClient.agents.session.list).toHaveBeenCalledWith(
+ "agent_123"
+ );
+ });
+ });
+
+ test("handles API errors gracefully", async () => {
+ mockClient.agents.session.list.mockRejectedValue(new Error("API Error"));
+ const consoleSpy = jest
+ .spyOn(console, "error")
+ .mockImplementation(() => {});
+
+ await act(async () => {
+ render(
+
+ );
+ });
+
+ await waitFor(() => {
+ expect(consoleSpy).toHaveBeenCalledWith(
+ "Error loading agent sessions:",
+ expect.any(Error)
+ );
+ });
+
+ consoleSpy.mockRestore();
+ });
+ });
+
+ describe("Error Handling", () => {
+ test("component renders without crashing when API is unavailable", async () => {
+ mockClient.agents.session.list.mockRejectedValue(
+ new Error("Network Error")
+ );
+ const consoleSpy = jest
+ .spyOn(console, "error")
+ .mockImplementation(() => {});
+
+ await act(async () => {
+ render(
+
+ );
+ });
+
+ // Should still render the session manager with the select trigger
+ expect(screen.getByRole("combobox")).toBeInTheDocument();
+ expect(screen.getByText("+ New")).toBeInTheDocument();
+ consoleSpy.mockRestore();
+ });
+ });
+});
+
+describe("SessionUtils", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ localStorageMock.getItem.mockReturnValue(null);
+ localStorageMock.setItem.mockImplementation(() => {});
+ });
+
+ describe("saveCurrentSessionId", () => {
+ test("saves session ID to localStorage", () => {
+ SessionUtils.saveCurrentSessionId("test-session-id");
+
+ expect(localStorageMock.setItem).toHaveBeenCalledWith(
+ "chat-playground-current-session",
+ "test-session-id"
+ );
+ });
+ });
+
+ describe("createDefaultSession", () => {
+ test("creates default session with agent ID", () => {
+ const result = SessionUtils.createDefaultSession("agent_123");
+
+ expect(result).toEqual(
+ expect.objectContaining({
+ name: "Default Session",
+ messages: [],
+ selectedModel: "",
+ systemMessage: "You are a helpful assistant.",
+ agentId: "agent_123",
+ })
+ );
+ expect(result.id).toBeTruthy();
+ expect(result.createdAt).toBeTruthy();
+ expect(result.updatedAt).toBeTruthy();
+ });
+
+ test("creates default session with inherited model", () => {
+ const result = SessionUtils.createDefaultSession(
+ "agent_123",
+ "inherited-model"
+ );
+
+ expect(result.selectedModel).toBe("inherited-model");
+ expect(result.agentId).toBe("agent_123");
+ });
+
+ test("creates unique session IDs", () => {
+ const originalNow = Date.now;
+ let mockTime = 1710005000;
+ Date.now = jest.fn(() => ++mockTime);
+
+ const session1 = SessionUtils.createDefaultSession("agent_123");
+ const session2 = SessionUtils.createDefaultSession("agent_123");
+
+ expect(session1.id).not.toBe(session2.id);
+
+ Date.now = originalNow;
+ });
+
+ test("sets creation and update timestamps", () => {
+ const result = SessionUtils.createDefaultSession("agent_123");
+
+ expect(result.createdAt).toBeTruthy();
+ expect(result.updatedAt).toBeTruthy();
+ expect(typeof result.createdAt).toBe("number");
+ expect(typeof result.updatedAt).toBe("number");
+ });
+ });
+});
diff --git a/llama_stack/ui/components/chat-playground/conversations.tsx b/llama_stack/ui/components/chat-playground/conversations.tsx
new file mode 100644
index 000000000..1a9c960fe
--- /dev/null
+++ b/llama_stack/ui/components/chat-playground/conversations.tsx
@@ -0,0 +1,568 @@
+"use client";
+
+import { useState, useEffect, useCallback } from "react";
+import { Button } from "@/components/ui/button";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Input } from "@/components/ui/input";
+import { Card } from "@/components/ui/card";
+import { Trash2 } from "lucide-react";
+import type { Message } from "@/components/chat-playground/chat-message";
+import { useAuthClient } from "@/hooks/use-auth-client";
+import type {
+ Session,
+ SessionCreateParams,
+} from "llama-stack-client/resources/agents";
+
+export interface ChatSession {
+ id: string;
+ name: string;
+ messages: Message[];
+ selectedModel: string;
+ systemMessage: string;
+ agentId: string;
+ session?: Session;
+ createdAt: number;
+ updatedAt: number;
+}
+
+interface SessionManagerProps {
+ currentSession: ChatSession | null;
+ onSessionChange: (session: ChatSession) => void;
+ onNewSession: () => void;
+ selectedAgentId: string;
+}
+
+const CURRENT_SESSION_KEY = "chat-playground-current-session";
+
+// ensures this only happens client side
+const safeLocalStorage = {
+ getItem: (key: string): string | null => {
+ if (typeof window === "undefined") return null;
+ try {
+ return localStorage.getItem(key);
+ } catch (err) {
+ console.error("Error accessing localStorage:", err);
+ return null;
+ }
+ },
+ setItem: (key: string, value: string): void => {
+ if (typeof window === "undefined") return;
+ try {
+ localStorage.setItem(key, value);
+ } catch (err) {
+ console.error("Error writing to localStorage:", err);
+ }
+ },
+ removeItem: (key: string): void => {
+ if (typeof window === "undefined") return;
+ try {
+ localStorage.removeItem(key);
+ } catch (err) {
+ console.error("Error removing from localStorage:", err);
+ }
+ },
+};
+
+const generateSessionId = (): string => {
+ return globalThis.crypto.randomUUID();
+};
+
+export function Conversations({
+ currentSession,
+ onSessionChange,
+ selectedAgentId,
+}: SessionManagerProps) {
+ const [sessions, setSessions] = useState([]);
+ const [showCreateForm, setShowCreateForm] = useState(false);
+ const [newSessionName, setNewSessionName] = useState("");
+ const [loading, setLoading] = useState(false);
+ const client = useAuthClient();
+
+ const loadAgentSessions = useCallback(async () => {
+ if (!selectedAgentId) return;
+
+ setLoading(true);
+ try {
+ const response = await client.agents.session.list(selectedAgentId);
+ console.log("Sessions response:", response);
+
+ if (!response.data || !Array.isArray(response.data)) {
+ console.warn("Invalid sessions response, starting fresh");
+ setSessions([]);
+ return;
+ }
+
+ const agentSessions: ChatSession[] = response.data
+ .filter(sessionData => {
+ const isValid =
+ sessionData &&
+ typeof sessionData === "object" &&
+ sessionData.session_id &&
+ sessionData.session_name;
+ if (!isValid) {
+ console.warn("Filtering out invalid session:", sessionData);
+ }
+ return isValid;
+ })
+ .map(sessionData => ({
+ id: sessionData.session_id,
+ name: sessionData.session_name,
+ messages: [],
+ selectedModel: currentSession?.selectedModel || "",
+ systemMessage:
+ currentSession?.systemMessage || "You are a helpful assistant.",
+ agentId: selectedAgentId,
+ session: sessionData,
+ createdAt: sessionData.started_at
+ ? new Date(sessionData.started_at).getTime()
+ : Date.now(),
+ updatedAt: sessionData.started_at
+ ? new Date(sessionData.started_at).getTime()
+ : Date.now(),
+ }));
+ setSessions(agentSessions);
+ } catch (error) {
+ console.error("Error loading agent sessions:", error);
+ setSessions([]);
+ } finally {
+ setLoading(false);
+ }
+ }, [
+ selectedAgentId,
+ client,
+ currentSession?.selectedModel,
+ currentSession?.systemMessage,
+ ]);
+
+ useEffect(() => {
+ if (selectedAgentId) {
+ loadAgentSessions();
+ }
+ }, [selectedAgentId, loadAgentSessions]);
+
+ const createNewSession = async () => {
+ if (!selectedAgentId) return;
+
+ const sessionName =
+ newSessionName.trim() || `Session ${sessions.length + 1}`;
+ setLoading(true);
+
+ try {
+ const response = await client.agents.session.create(selectedAgentId, {
+ session_name: sessionName,
+ } as SessionCreateParams);
+
+ const newSession: ChatSession = {
+ id: response.session_id,
+ name: sessionName,
+ messages: [],
+ selectedModel: currentSession?.selectedModel || "",
+ systemMessage:
+ currentSession?.systemMessage || "You are a helpful assistant.",
+ agentId: selectedAgentId,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ };
+
+ setSessions(prev => [...prev, newSession]);
+ SessionUtils.saveCurrentSessionId(newSession.id, selectedAgentId);
+ onSessionChange(newSession);
+
+ setNewSessionName("");
+ setShowCreateForm(false);
+ } catch (error) {
+ console.error("Error creating session:", error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const loadSessionMessages = useCallback(
+ async (agentId: string, sessionId: string): Promise => {
+ try {
+ const session = await client.agents.session.retrieve(
+ agentId,
+ sessionId
+ );
+
+ if (!session || !session.turns || !Array.isArray(session.turns)) {
+ return [];
+ }
+
+ const messages: Message[] = [];
+ for (const turn of session.turns) {
+ // Add user messages from input_messages
+ if (turn.input_messages && Array.isArray(turn.input_messages)) {
+ for (const input of turn.input_messages) {
+ if (input.role === "user" && input.content) {
+ messages.push({
+ id: `${turn.turn_id}-user-${messages.length}`,
+ role: "user",
+ content:
+ typeof input.content === "string"
+ ? input.content
+ : JSON.stringify(input.content),
+ createdAt: new Date(turn.started_at || Date.now()),
+ });
+ }
+ }
+ }
+
+ // Add assistant message from output_message
+ if (turn.output_message && turn.output_message.content) {
+ messages.push({
+ id: `${turn.turn_id}-assistant-${messages.length}`,
+ role: "assistant",
+ content:
+ typeof turn.output_message.content === "string"
+ ? turn.output_message.content
+ : JSON.stringify(turn.output_message.content),
+ createdAt: new Date(
+ turn.completed_at || turn.started_at || Date.now()
+ ),
+ });
+ }
+ }
+
+ return messages;
+ } catch (error) {
+ console.error("Error loading session messages:", error);
+ return [];
+ }
+ },
+ [client]
+ );
+
+ const switchToSession = useCallback(
+ async (sessionId: string) => {
+ const session = sessions.find(s => s.id === sessionId);
+ if (session) {
+ setLoading(true);
+ try {
+ // Load messages for this session
+ const messages = await loadSessionMessages(
+ selectedAgentId,
+ sessionId
+ );
+ const sessionWithMessages = {
+ ...session,
+ messages,
+ };
+
+ SessionUtils.saveCurrentSessionId(sessionId, selectedAgentId);
+ onSessionChange(sessionWithMessages);
+ } catch (error) {
+ console.error("Error switching to session:", error);
+ // Fallback to session without messages
+ SessionUtils.saveCurrentSessionId(sessionId, selectedAgentId);
+ onSessionChange(session);
+ } finally {
+ setLoading(false);
+ }
+ }
+ },
+ [sessions, selectedAgentId, loadSessionMessages, onSessionChange]
+ );
+
+ const deleteSession = async (sessionId: string) => {
+ if (sessions.length <= 1 || !selectedAgentId) {
+ return;
+ }
+
+ if (
+ confirm(
+ "Are you sure you want to delete this session? This action cannot be undone."
+ )
+ ) {
+ setLoading(true);
+ try {
+ await client.agents.session.delete(selectedAgentId, sessionId);
+
+ const updatedSessions = sessions.filter(s => s.id !== sessionId);
+ setSessions(updatedSessions);
+
+ if (currentSession?.id === sessionId) {
+ const newCurrentSession = updatedSessions[0] || null;
+ if (newCurrentSession) {
+ SessionUtils.saveCurrentSessionId(
+ newCurrentSession.id,
+ selectedAgentId
+ );
+ onSessionChange(newCurrentSession);
+ } else {
+ SessionUtils.clearCurrentSession(selectedAgentId);
+ onNewSession();
+ }
+ }
+ } catch (error) {
+ console.error("Error deleting session:", error);
+ } finally {
+ setLoading(false);
+ }
+ }
+ };
+
+ useEffect(() => {
+ if (currentSession) {
+ setSessions(prevSessions => {
+ const updatedSessions = prevSessions.map(session =>
+ session.id === currentSession.id ? currentSession : session
+ );
+
+ if (!prevSessions.find(s => s.id === currentSession.id)) {
+ updatedSessions.push(currentSession);
+ }
+
+ return updatedSessions;
+ });
+ }
+ }, [currentSession]);
+
+ // Don't render if no agent is selected
+ if (!selectedAgentId) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+
+
+ {sessions.map(session => (
+
+ {session.name}
+
+ ))}
+
+
+
+ setShowCreateForm(true)}
+ variant="outline"
+ size="sm"
+ disabled={loading || !selectedAgentId}
+ >
+ + New
+
+
+ {currentSession && sessions.length > 1 && (
+ deleteSession(currentSession.id)}
+ variant="outline"
+ size="sm"
+ className="text-destructive hover:text-destructive hover:bg-destructive/10"
+ title="Delete current session"
+ >
+
+
+ )}
+
+
+ {showCreateForm && (
+
+ Create New Session
+
+ setNewSessionName(e.target.value)}
+ placeholder="Session name (optional)"
+ onKeyDown={e => {
+ if (e.key === "Enter") {
+ createNewSession();
+ } else if (e.key === "Escape") {
+ setShowCreateForm(false);
+ setNewSessionName("");
+ }
+ }}
+ />
+
+
+
+ {loading ? "Creating..." : "Create"}
+
+ {
+ setShowCreateForm(false);
+ setNewSessionName("");
+ }}
+ className="flex-1"
+ >
+ Cancel
+
+
+
+ )}
+
+ {currentSession && sessions.length > 1 && (
+
+ {sessions.length} sessions • Current: {currentSession.name}
+ {currentSession.messages.length > 0 &&
+ ` • ${currentSession.messages.length} messages`}
+
+ )}
+
+ );
+}
+
+export const SessionUtils = {
+ loadCurrentSessionId: (agentId?: string): string | null => {
+ const key = agentId
+ ? `${CURRENT_SESSION_KEY}-${agentId}`
+ : CURRENT_SESSION_KEY;
+ return safeLocalStorage.getItem(key);
+ },
+
+ saveCurrentSessionId: (sessionId: string, agentId?: string) => {
+ const key = agentId
+ ? `${CURRENT_SESSION_KEY}-${agentId}`
+ : CURRENT_SESSION_KEY;
+ safeLocalStorage.setItem(key, sessionId);
+ },
+
+ createDefaultSession: (
+ agentId: string,
+ inheritModel?: string
+ ): ChatSession => ({
+ id: generateSessionId(),
+ name: "Default Session",
+ messages: [],
+ selectedModel: inheritModel || "",
+ systemMessage: "You are a helpful assistant.",
+ agentId,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ }),
+
+ clearCurrentSession: (agentId?: string) => {
+ const key = agentId
+ ? `${CURRENT_SESSION_KEY}-${agentId}`
+ : CURRENT_SESSION_KEY;
+ safeLocalStorage.removeItem(key);
+ },
+
+ loadCurrentAgentId: (): string | null => {
+ return safeLocalStorage.getItem("chat-playground-current-agent");
+ },
+
+ saveCurrentAgentId: (agentId: string) => {
+ safeLocalStorage.setItem("chat-playground-current-agent", agentId);
+ },
+
+ // Comprehensive session caching
+ saveSessionData: (agentId: string, sessionData: ChatSession) => {
+ const key = `chat-playground-session-data-${agentId}-${sessionData.id}`;
+ safeLocalStorage.setItem(
+ key,
+ JSON.stringify({
+ ...sessionData,
+ cachedAt: Date.now(),
+ })
+ );
+ },
+
+ loadSessionData: (agentId: string, sessionId: string): ChatSession | null => {
+ const key = `chat-playground-session-data-${agentId}-${sessionId}`;
+ const cached = safeLocalStorage.getItem(key);
+ if (!cached) return null;
+
+ try {
+ const data = JSON.parse(cached);
+ // Check if cache is fresh (less than 1 hour old)
+ const cacheAge = Date.now() - (data.cachedAt || 0);
+ if (cacheAge > 60 * 60 * 1000) {
+ safeLocalStorage.removeItem(key);
+ return null;
+ }
+
+ // Convert date strings back to Date objects
+ return {
+ ...data,
+ messages: data.messages.map(
+ (msg: { createdAt: string; [key: string]: unknown }) => ({
+ ...msg,
+ createdAt: new Date(msg.createdAt),
+ })
+ ),
+ };
+ } catch (error) {
+ console.error("Error parsing cached session data:", error);
+ safeLocalStorage.removeItem(key);
+ return null;
+ }
+ },
+
+ // Agent config caching
+ saveAgentConfig: (
+ agentId: string,
+ config: {
+ toolgroups?: Array<
+ string | { name: string; args: Record }
+ >;
+ [key: string]: unknown;
+ }
+ ) => {
+ const key = `chat-playground-agent-config-${agentId}`;
+ safeLocalStorage.setItem(
+ key,
+ JSON.stringify({
+ config,
+ cachedAt: Date.now(),
+ })
+ );
+ },
+
+ loadAgentConfig: (
+ agentId: string
+ ): {
+ toolgroups?: Array<
+ string | { name: string; args: Record }
+ >;
+ [key: string]: unknown;
+ } | null => {
+ const key = `chat-playground-agent-config-${agentId}`;
+ const cached = safeLocalStorage.getItem(key);
+ if (!cached) return null;
+
+ try {
+ const data = JSON.parse(cached);
+ // Check if cache is fresh (less than 30 minutes old)
+ const cacheAge = Date.now() - (data.cachedAt || 0);
+ if (cacheAge > 30 * 60 * 1000) {
+ safeLocalStorage.removeItem(key);
+ return null;
+ }
+ return data.config;
+ } catch (error) {
+ console.error("Error parsing cached agent config:", error);
+ safeLocalStorage.removeItem(key);
+ return null;
+ }
+ },
+
+ // Clear all cached data for an agent
+ clearAgentCache: (agentId: string) => {
+ const keys = Object.keys(localStorage).filter(
+ key =>
+ key.includes(`chat-playground-session-data-${agentId}`) ||
+ key.includes(`chat-playground-agent-config-${agentId}`)
+ );
+ keys.forEach(key => safeLocalStorage.removeItem(key));
+ },
+};
diff --git a/llama_stack/ui/components/chat-playground/typing-indicator.tsx b/llama_stack/ui/components/chat-playground/typing-indicator.tsx
index 8950c066b..3b5a560b7 100644
--- a/llama_stack/ui/components/chat-playground/typing-indicator.tsx
+++ b/llama_stack/ui/components/chat-playground/typing-indicator.tsx
@@ -5,9 +5,9 @@ export function TypingIndicator() {
diff --git a/llama_stack/ui/components/layout/app-sidebar.tsx b/llama_stack/ui/components/layout/app-sidebar.tsx
index bee3d6a70..373f0c5ae 100644
--- a/llama_stack/ui/components/layout/app-sidebar.tsx
+++ b/llama_stack/ui/components/layout/app-sidebar.tsx
@@ -11,6 +11,7 @@ import {
} from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
+import Image from "next/image";
import { cn } from "@/lib/utils";
import {
@@ -110,7 +111,16 @@ export function AppSidebar() {
return (
- Llama Stack
+
+
+ Llama Stack
+
diff --git a/llama_stack/ui/public/favicon.ico b/llama_stack/ui/public/favicon.ico
new file mode 100644
index 000000000..553368b18
Binary files /dev/null and b/llama_stack/ui/public/favicon.ico differ
diff --git a/llama_stack/ui/public/logo.webp b/llama_stack/ui/public/logo.webp
new file mode 100644
index 000000000..28caa6edd
Binary files /dev/null and b/llama_stack/ui/public/logo.webp differ