Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
7c82669
feat(DATAGO-118394): Implement automatic chat title generation using …
amir-ghasemi Nov 25, 2025
271a178
fix: UI restyle
amir-ghasemi Nov 25, 2025
285af4b
Merge branch 'main' of github.com:SolaceLabs/solace-agent-mesh into a…
amir-ghasemi Nov 28, 2025
ac4f17b
fix: title animation
amir-ghasemi Nov 28, 2025
19017d3
Merge branch 'main' of github.com:SolaceLabs/solace-agent-mesh into a…
amir-ghasemi Dec 4, 2025
8ebb9eb
Merge branch 'main' of github.com:SolaceLabs/solace-agent-mesh into a…
amir-ghasemi Dec 7, 2025
517d65d
fix: auto title generation on background task reload
amir-ghasemi Dec 7, 2025
e938b40
fix: feature flag
amir-ghasemi Dec 7, 2025
4c9206e
fix: feature flag disabled by default
amir-ghasemi Dec 7, 2025
954d0cf
Merge branch 'main' of github.com:SolaceLabs/solace-agent-mesh into a…
amir-ghasemi Dec 11, 2025
eef2378
Merge branch 'main' of github.com:SolaceLabs/solace-agent-mesh into a…
amir-ghasemi Jan 6, 2026
144238c
chore: unit tests for auto title generation
amir-ghasemi Jan 6, 2026
fb7b9a0
Merge branch 'main' of github.com:SolaceLabs/solace-agent-mesh into a…
amir-ghasemi Jan 9, 2026
d0bb5d1
Merge branch 'main' of github.com:SolaceLabs/solace-agent-mesh into a…
amir-ghasemi Jan 12, 2026
a1351c9
fix: use constants for magic numbers
amir-ghasemi Jan 12, 2026
6eba3d5
Merge branch 'main' of github.com:SolaceLabs/solace-agent-mesh into a…
amir-ghasemi Jan 14, 2026
4336938
Merge branch 'main' of github.com:SolaceLabs/solace-agent-mesh into a…
amir-ghasemi Jan 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 153 additions & 20 deletions client/webui/frontend/src/lib/components/chat/SessionList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,62 @@ import React, { useEffect, useState, useRef, useCallback, useMemo } from "react"
import { useInView } from "react-intersection-observer";
import { useNavigate } from "react-router-dom";

import { Trash2, Check, X, Pencil, MessageCircle, FolderInput, MoreHorizontal, PanelsTopLeft, Loader2 } from "lucide-react";
import { Trash2, Check, X, Pencil, MessageCircle, FolderInput, MoreHorizontal, PanelsTopLeft, Sparkles, Loader2 } from "lucide-react";

import { api } from "@/lib/api";
import { useChatContext, useConfigContext } from "@/lib/hooks";
import { useChatContext, useConfigContext, useTitleGeneration, useTitleAnimation } from "@/lib/hooks";
import type { Project, Session } from "@/lib/types";

interface SessionNameProps {
session: Session;
isCurrentSession: boolean;
isResponding: boolean;
}

const SessionName: React.FC<SessionNameProps> = ({ session, isCurrentSession, isResponding }) => {
const { autoTitleGenerationEnabled } = useConfigContext();

const displayName = useMemo(() => {
if (session.name && session.name.trim()) {
return session.name;
}
// Fallback to "New Chat" if no name
return "New Chat";
}, [session.name]);

// Pass session ID to useTitleAnimation so it can listen for title generation events
const { text: animatedName, isAnimating, isGenerating } = useTitleAnimation(displayName, session.id);

const isWaitingForTitle = useMemo(() => {
// Always show pulse when isGenerating (manual "Rename with AI")
if (isGenerating) {
return true;
}

if (!autoTitleGenerationEnabled) {
return false; // No pulse when auto title generation is disabled
}
const isNewChat = !session.name || session.name === "New Chat";
return isCurrentSession && isNewChat && isResponding;
}, [session.name, isCurrentSession, isResponding, isGenerating, autoTitleGenerationEnabled]);

// Show slow pulse while waiting for title, faster pulse during transition animation
const animationClass = useMemo(() => {
if (isGenerating || isAnimating) {
if (isWaitingForTitle) {
return "animate-pulse-slow";
}
return "animate-pulse opacity-50";
}
// For automatic title generation waiting state
if (isWaitingForTitle) {
return "animate-pulse-slow";
}
return "opacity-100";
}, [isWaitingForTitle, isAnimating, isGenerating]);

return <span className={`truncate font-semibold transition-opacity duration-300 ${animationClass}`}>{animatedName}</span>;
};
import { formatTimestamp, getErrorMessage } from "@/lib/utils";
import { MoveSessionDialog, ProjectBadge, SessionSearch } from "@/lib/components/chat";
import {
Expand Down Expand Up @@ -46,8 +97,9 @@ interface SessionListProps {

export const SessionList: React.FC<SessionListProps> = ({ projects = [] }) => {
const navigate = useNavigate();
const { sessionId, handleSwitchSession, updateSessionName, openSessionDeleteModal, addNotification, displayError } = useChatContext();
const { sessionId, handleSwitchSession, updateSessionName, openSessionDeleteModal, addNotification, displayError, isResponding } = useChatContext();
const { persistenceEnabled } = useConfigContext();
const { generateTitle } = useTitleGeneration();
const inputRef = useRef<HTMLInputElement>(null);

const [sessions, setSessions] = useState<Session[]>([]);
Expand All @@ -59,6 +111,7 @@ export const SessionList: React.FC<SessionListProps> = ({ projects = [] }) => {
const [selectedProject, setSelectedProject] = useState<string>("all");
const [isMoveDialogOpen, setIsMoveDialogOpen] = useState(false);
const [sessionToMove, setSessionToMove] = useState<Session | null>(null);
const [regeneratingTitleForSession, setRegeneratingTitleForSession] = useState<string | null>(null);

const { ref: loadMoreRef, inView } = useInView({
threshold: 0,
Expand Down Expand Up @@ -103,16 +156,38 @@ export const SessionList: React.FC<SessionListProps> = ({ projects = [] }) => {
return prevSessions;
});
};
const handleTitleUpdated = async (event: Event) => {
const customEvent = event as CustomEvent;
const { sessionId: updatedSessionId } = customEvent.detail;

// Fetch the updated session from backend to get the new title
try {
const sessionData = await api.webui.get(`/api/v1/sessions/${updatedSessionId}`);
const updatedSession = sessionData?.data;

if (updatedSession) {
setSessions(prevSessions => {
return prevSessions.map(s => (s.id === updatedSessionId ? { ...s, name: updatedSession.name } : s));
});
}
} catch (error) {
console.error("[SessionList] Error fetching updated session:", error);
// Fallback: just refresh the entire list
fetchSessions(1, false);
}
};
const handleBackgroundTaskCompleted = () => {
// Refresh session list when background task completes to update indicators
fetchSessions(1, false);
};
window.addEventListener("new-chat-session", handleNewSession);
window.addEventListener("session-updated", handleSessionUpdated as EventListener);
window.addEventListener("session-title-updated", handleTitleUpdated);
window.addEventListener("background-task-completed", handleBackgroundTaskCompleted);
return () => {
window.removeEventListener("new-chat-session", handleNewSession);
window.removeEventListener("session-updated", handleSessionUpdated as EventListener);
window.removeEventListener("session-title-updated", handleTitleUpdated);
window.removeEventListener("background-task-completed", handleBackgroundTaskCompleted);
};
}, [fetchSessions]);
Expand Down Expand Up @@ -186,6 +261,70 @@ export const SessionList: React.FC<SessionListProps> = ({ projects = [] }) => {
navigate(`/projects/${session.projectId}`);
};

const handleRenameWithAI = useCallback(
async (session: Session) => {
if (regeneratingTitleForSession) {
addNotification?.("AI rename already in progress", "info");
return;
}

setRegeneratingTitleForSession(session.id);
addNotification?.("Generating AI name...", "info");

try {
// Fetch all tasks/messages for this session
const data = await api.webui.get(`/api/v1/sessions/${session.id}/chat-tasks`);
const tasks = data.tasks || [];

if (tasks.length === 0) {
addNotification?.("No messages found in this session", "warning");
setRegeneratingTitleForSession(null);
return;
}

// Parse and extract all messages from all tasks
const allMessages: string[] = [];

for (const task of tasks) {
const messageBubbles = JSON.parse(task.messageBubbles);
for (const bubble of messageBubbles) {
const text = bubble.text || "";
if (text.trim()) {
allMessages.push(text.trim());
}
}
}

if (allMessages.length === 0) {
addNotification?.("No text content found in session", "warning");
setRegeneratingTitleForSession(null);
return;
}

// Create a summary of the conversation for better context
// Use LAST 3 messages of each type to capture recent conversation
const userMessages = allMessages.filter((_, idx) => idx % 2 === 0); // Assuming alternating user/agent
const agentMessages = allMessages.filter((_, idx) => idx % 2 === 1);

const userSummary = userMessages.slice(-3).join(" | ");
const agentSummary = agentMessages.slice(-3).join(" | ");

// Call the title generation service with the full context
// Pass current title so polling can detect the change
// Pass force=true to bypass the "already has title" check
await generateTitle(session.id, userSummary, agentSummary, session.name || "New Chat", true);

addNotification?.("Title regenerated successfully", "success");
} catch (error) {
console.error("Error regenerating title:", error);
addNotification?.(`Failed to regenerate title: ${error instanceof Error ? error.message : "Unknown error"}`, "warning");
} finally {
setRegeneratingTitleForSession(null);
}
},
[generateTitle, addNotification, regeneratingTitleForSession]
);

const handleMoveConfirm = async (targetProjectId: string | null) => {
if (!sessionToMove) return;

Expand Down Expand Up @@ -229,22 +368,6 @@ export const SessionList: React.FC<SessionListProps> = ({ projects = [] }) => {
return formatTimestamp(dateString);
};

const getSessionDisplayName = (session: Session) => {
if (session.name && session.name.trim()) {
return session.name;
}
// Generate a short, readable identifier from the session ID
const sessionId = session.id;
if (sessionId.startsWith("web-session-")) {
// Extract the UUID part and create a short identifier
const uuid = sessionId.replace("web-session-", "");
const shortId = uuid.substring(0, 8);
return `Chat ${shortId}`;
}
// Fallback for other ID formats
return `Session ${sessionId.substring(0, 8)}`;
};

// Get unique project names from sessions, sorted alphabetically
const projectNames = useMemo(() => {
const uniqueProjectNames = new Set<string>();
Expand Down Expand Up @@ -339,7 +462,7 @@ export const SessionList: React.FC<SessionListProps> = ({ projects = [] }) => {
<div className="flex items-center gap-2">
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex items-center gap-2">
<span className="truncate font-semibold">{getSessionDisplayName(session)}</span>
<SessionName session={session} isCurrentSession={session.id === sessionId} isResponding={isResponding} />
{session.hasRunningBackgroundTask && (
<Tooltip>
<TooltipTrigger asChild>
Expand Down Expand Up @@ -396,6 +519,16 @@ export const SessionList: React.FC<SessionListProps> = ({ projects = [] }) => {
<Pencil size={16} className="mr-2" />
Rename
</DropdownMenuItem>
<DropdownMenuItem
onClick={e => {
e.stopPropagation();
handleRenameWithAI(session);
}}
disabled={regeneratingTitleForSession === session.id}
>
<Sparkles size={16} className={`mr-2 ${regeneratingTitleForSession === session.id ? "animate-pulse" : ""}`} />
Rename with AI
</DropdownMenuItem>
<DropdownMenuItem
onClick={e => {
e.stopPropagation();
Expand Down
36 changes: 32 additions & 4 deletions client/webui/frontend/src/lib/components/pages/ChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { PanelLeftIcon } from "lucide-react";
import type { ImperativePanelHandle } from "react-resizable-panels";

import { Header } from "@/lib/components/header";
import { useChatContext, useTaskContext, useThemeContext } from "@/lib/hooks";
import { useChatContext, useTaskContext, useThemeContext, useTitleAnimation, useConfigContext } from "@/lib/hooks";
import { useProjectContext } from "@/lib/providers";
import type { TextPart } from "@/lib/types";
import { ChatInputArea, ChatMessage, ChatSessionDialog, ChatSessionDeleteDialog, ChatSidePanel, LoadingMessageRow, ProjectBadge, SessionSidePanel } from "@/lib/components/chat";
Expand Down Expand Up @@ -33,8 +33,10 @@ const PANEL_SIZES_OPEN = {
export function ChatPage() {
const { activeProject } = useProjectContext();
const { currentTheme } = useThemeContext();
const { autoTitleGenerationEnabled } = useConfigContext();
const {
agents,
sessionId,
sessionName,
messages,
isSidePanelCollapsed,
Expand Down Expand Up @@ -99,11 +101,35 @@ export function ChatPage() {

const breadcrumbs = undefined;

// Determine the page title
const pageTitle = useMemo(() => {
// Determine the page title with pulse/fade effect
const rawPageTitle = useMemo(() => {
return sessionName || "New Chat";
}, [sessionName]);

const { text: pageTitle, isAnimating: isTitleAnimating, isGenerating: isTitleGenerating } = useTitleAnimation(rawPageTitle, sessionId);

const isWaitingForTitle = useMemo(() => {
if (!autoTitleGenerationEnabled) {
return false;
}
const isNewChat = !sessionName || sessionName === "New Chat";
return (isNewChat && isResponding) || isTitleGenerating;
}, [sessionName, isResponding, isTitleGenerating, autoTitleGenerationEnabled]);

// Determine the appropriate animation class
const titleAnimationClass = useMemo(() => {
if (!autoTitleGenerationEnabled) {
return "opacity-100"; // No animation when disabled
}
if (isWaitingForTitle) {
return "animate-pulse-slow";
}
if (isTitleAnimating) {
return "animate-pulse opacity-50";
}
return "opacity-100";
}, [isWaitingForTitle, isTitleAnimating, autoTitleGenerationEnabled]);

useEffect(() => {
if (chatSidePanelRef.current && isSidePanelCollapsed) {
chatSidePanelRef.current.resize(COLLAPSED_SIZE);
Expand Down Expand Up @@ -188,7 +214,9 @@ export function ChatPage() {
title={
<div className="flex items-center gap-3">
<Tooltip delayDuration={300}>
<TooltipTrigger className="font-inherit max-w-[400px] cursor-default truncate border-0 bg-transparent p-0 text-left text-inherit hover:bg-transparent">{pageTitle}</TooltipTrigger>
<TooltipTrigger className={`font-inherit max-w-[400px] cursor-default truncate border-0 bg-transparent p-0 text-left text-inherit transition-opacity duration-300 hover:bg-transparent ${titleAnimationClass}`}>
{pageTitle}
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{pageTitle}</p>
</TooltipContent>
Expand Down
7 changes: 7 additions & 0 deletions client/webui/frontend/src/lib/contexts/ConfigContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,13 @@ export interface ConfigContextValue {
* When false, platform-dependent features (agent builder, connectors, etc.) are unavailable.
*/
platformConfigured: boolean;

/**
* Whether automatic title generation is enabled for new chat sessions.
* When true, the first message exchange will trigger AI-powered title generation.
* Requires persistence to be enabled.
*/
autoTitleGenerationEnabled?: boolean;
}

export const ConfigContext = createContext<ConfigContextValue | null>(null);
2 changes: 2 additions & 0 deletions client/webui/frontend/src/lib/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,5 @@ export * from "./useSessionStorage";
export * from "./useMap";
export * from "./useLocalStorage";
export * from "./useToggle";
export * from "./useTitleGeneration";
export * from "./useTitleAnimation";
21 changes: 19 additions & 2 deletions client/webui/frontend/src/lib/hooks/useBackgroundTaskMonitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,8 +193,25 @@ export function useBackgroundTaskMonitor({ userId, onTaskCompleted, onTaskFailed
checkForRunningTasks();
}, [userId, fetchActiveBackgroundTasks]);

// Periodic checking removed - tasks are unregistered immediately on completion
// via SSE final_response event in ChatProvider
// Periodic checking to detect background task completion when not connected to SSE
// This handles the case where a task completes while the user is on a different session
useEffect(() => {
if (backgroundTasks.length === 0) {
return;
}

// Check immediately on mount/change
checkAllBackgroundTasks();

// Then check periodically (every 5 seconds)
const intervalId = setInterval(() => {
checkAllBackgroundTasks();
}, 5000);

return () => {
clearInterval(intervalId);
};
}, [backgroundTasks.length, checkAllBackgroundTasks]);

// Dismiss a notification
const dismissNotification = useCallback((taskId: string) => {
Expand Down
7 changes: 2 additions & 5 deletions client/webui/frontend/src/lib/hooks/useStreamingAnimation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,7 @@ import type { StreamingState } from "./useStreamingSpeed";
* Hook that runs an animation loop to smoothly advance through streaming content.
* Uses the speed calculated by useStreamingSpeed to determine render pace.
*/
export function useStreamingAnimation(
state: MutableRefObject<StreamingState>,
contentRef: MutableRefObject<string>
): string {
export function useStreamingAnimation(state: MutableRefObject<StreamingState>, contentRef: MutableRefObject<string>): string {
const [displayedContent, setDisplayedContent] = useState("");

useEffect(() => {
Expand Down Expand Up @@ -41,7 +38,7 @@ export function useStreamingAnimation(
setDisplayedContent(target.slice(0, Math.floor(s.cursor)));
} else if (target.length > 0) {
// Ensure final content is displayed
setDisplayedContent(prev => prev !== target ? target : prev);
setDisplayedContent(prev => (prev !== target ? target : prev));
}

animationFrameId = requestAnimationFrame(animate);
Expand Down
Loading
Loading