Merge pull request #346 from rishimohan/fix-share-page-ui

fix: z-index issue on share pages preventing clicks on header, also add canonical url
This commit is contained in:
Marko Kraemer 2025-05-17 14:57:46 +02:00 committed by GitHub
commit 269da4f041
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 756 additions and 589 deletions

View File

@ -6,6 +6,9 @@ export async function generateMetadata({ params }): Promise<Metadata> {
const fallbackMetaData = { const fallbackMetaData = {
title: 'Shared Conversation | Kortix Suna', title: 'Shared Conversation | Kortix Suna',
description: 'Replay this Agent conversation on Kortix Suna', description: 'Replay this Agent conversation on Kortix Suna',
alternates: {
canonical: `${process.env.NEXT_PUBLIC_URL}/share/${threadId}`,
},
openGraph: { openGraph: {
title: 'Shared Conversation | Kortix Suna', title: 'Shared Conversation | Kortix Suna',
description: 'Replay this Agent conversation on Kortix Suna', description: 'Replay this Agent conversation on Kortix Suna',
@ -13,7 +16,6 @@ export async function generateMetadata({ params }): Promise<Metadata> {
}, },
}; };
try { try {
const threadData = await getThread(threadId); const threadData = await getThread(threadId);
const projectData = await getProject(threadData.project_id); const projectData = await getProject(threadData.project_id);
@ -28,7 +30,9 @@ export async function generateMetadata({ params }): Promise<Metadata> {
process.env.NEXT_PUBLIC_ENV_MODE === 'local'; process.env.NEXT_PUBLIC_ENV_MODE === 'local';
const title = projectData.name || 'Shared Conversation | Kortix Suna'; const title = projectData.name || 'Shared Conversation | Kortix Suna';
const description = projectData.description || 'Replay this Agent conversation on Kortix Suna'; const description =
projectData.description ||
'Replay this Agent conversation on Kortix Suna';
const ogImage = isDevelopment const ogImage = isDevelopment
? `${process.env.NEXT_PUBLIC_URL}/share-page/og-fallback.png` ? `${process.env.NEXT_PUBLIC_URL}/share-page/og-fallback.png`
: `${process.env.NEXT_PUBLIC_URL}/api/share-page/og-image?title=${projectData.name}`; : `${process.env.NEXT_PUBLIC_URL}/api/share-page/og-image?title=${projectData.name}`;
@ -36,6 +40,9 @@ export async function generateMetadata({ params }): Promise<Metadata> {
return { return {
title, title,
description, description,
alternates: {
canonical: `${process.env.NEXT_PUBLIC_URL}/share/${threadId}`,
},
openGraph: { openGraph: {
title, title,
description, description,
@ -45,7 +52,6 @@ export async function generateMetadata({ params }): Promise<Metadata> {
title, title,
description, description,
images: ogImage, images: ogImage,
creator: '@kortixai',
card: 'summary_large_image', card: 'summary_large_image',
}, },
}; };

View File

@ -12,16 +12,25 @@ import {
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { FileViewerModal } from '@/components/thread/file-viewer-modal'; import { FileViewerModal } from '@/components/thread/file-viewer-modal';
import { ToolCallSidePanel, ToolCallInput } from "@/components/thread/tool-call-side-panel"; import {
ToolCallSidePanel,
ToolCallInput,
} from '@/components/thread/tool-call-side-panel';
import { ThreadContent } from '@/components/thread/content/ThreadContent'; import { ThreadContent } from '@/components/thread/content/ThreadContent';
import { PlaybackControls, PlaybackController } from '@/components/thread/content/PlaybackControls'; import {
import { UnifiedMessage, ParsedMetadata, ThreadParams } from '@/components/thread/types'; PlaybackControls,
PlaybackController,
} from '@/components/thread/content/PlaybackControls';
import {
UnifiedMessage,
ParsedMetadata,
ThreadParams,
} from '@/components/thread/types';
import { safeJsonParse } from '@/components/thread/utils'; import { safeJsonParse } from '@/components/thread/utils';
import { useAgentStream } from '@/hooks/useAgentStream'; import { useAgentStream } from '@/hooks/useAgentStream';
import { threadErrorCodeMessages } from '@/lib/constants/errorCodeMessages'; import { threadErrorCodeMessages } from '@/lib/constants/errorCodeMessages';
import { ThreadSkeleton } from '@/components/thread/content/ThreadSkeleton'; import { ThreadSkeleton } from '@/components/thread/content/ThreadSkeleton';
// Extend the base Message type with the expected database fields // Extend the base Message type with the expected database fields
interface ApiMessageType extends BaseApiMessageType { interface ApiMessageType extends BaseApiMessageType {
message_id?: string; message_id?: string;
@ -43,7 +52,8 @@ interface StreamingToolCall {
// Add a helper function to extract tool calls from message content // Add a helper function to extract tool calls from message content
const extractToolCallsFromMessage = (content: string) => { const extractToolCallsFromMessage = (content: string) => {
const toolCallRegex = /<([a-zA-Z\-_]+)(?:\s+[^>]*)?>(?:[\s\S]*?)<\/\1>|<([a-zA-Z\-_]+)(?:\s+[^>]*)?\/>/g; const toolCallRegex =
/<([a-zA-Z\-_]+)(?:\s+[^>]*)?>(?:[\s\S]*?)<\/\1>|<([a-zA-Z\-_]+)(?:\s+[^>]*)?\/>/g;
const results = []; const results = [];
let match; let match;
@ -51,7 +61,7 @@ const extractToolCallsFromMessage = (content: string) => {
const toolName = match[1] || match[2]; const toolName = match[1] || match[2];
results.push({ results.push({
name: toolName, name: toolName,
fullMatch: match[0] fullMatch: match[0],
}); });
} }
@ -71,7 +81,9 @@ export default function ThreadPage({
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [agentRunId, setAgentRunId] = useState<string | null>(null); const [agentRunId, setAgentRunId] = useState<string | null>(null);
const [agentStatus, setAgentStatus] = useState<'idle' | 'running' | 'connecting' | 'error'>('idle'); const [agentStatus, setAgentStatus] = useState<
'idle' | 'running' | 'connecting' | 'error'
>('idle');
const [isSidePanelOpen, setIsSidePanelOpen] = useState(false); const [isSidePanelOpen, setIsSidePanelOpen] = useState(false);
const [toolCalls, setToolCalls] = useState<ToolCallInput[]>([]); const [toolCalls, setToolCalls] = useState<ToolCallInput[]>([]);
const [currentToolIndex, setCurrentToolIndex] = useState<number>(0); const [currentToolIndex, setCurrentToolIndex] = useState<number>(0);
@ -85,7 +97,9 @@ export default function ThreadPage({
useState<StreamingToolCall | null>(null); useState<StreamingToolCall | null>(null);
// Create a message-to-tool-index map for faster lookups // Create a message-to-tool-index map for faster lookups
const [messageToToolIndex, setMessageToToolIndex] = useState<Record<string, number>>({}); const [messageToToolIndex, setMessageToToolIndex] = useState<
Record<string, number>
>({});
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null); const messagesContainerRef = useRef<HTMLDivElement>(null);
@ -130,10 +144,12 @@ export default function ThreadPage({
`[STREAM HANDLER] Received message: ID=${message.message_id}, Type=${message.type}`, `[STREAM HANDLER] Received message: ID=${message.message_id}, Type=${message.type}`,
); );
if (!message.message_id) { if (!message.message_id) {
console.warn(`[STREAM HANDLER] Received message is missing ID: Type=${message.type}, Content=${message.content?.substring(0, 50)}...`); console.warn(
`[STREAM HANDLER] Received message is missing ID: Type=${message.type}, Content=${message.content?.substring(0, 50)}...`,
);
} }
setMessages(prev => { setMessages((prev) => {
// First check if the message already exists // First check if the message already exists
const messageExists = prev.some( const messageExists = prev.some(
(m) => m.message_id === message.message_id, (m) => m.message_id === message.message_id,
@ -155,7 +171,8 @@ export default function ThreadPage({
} }
}, []); }, []);
const handleStreamStatusChange = useCallback((hookStatus: string) => { const handleStreamStatusChange = useCallback(
(hookStatus: string) => {
console.log(`[PAGE] Hook status changed: ${hookStatus}`); console.log(`[PAGE] Hook status changed: ${hookStatus}`);
switch (hookStatus) { switch (hookStatus) {
case 'idle': case 'idle':
@ -182,7 +199,9 @@ export default function ThreadPage({
}, 3000); }, 3000);
break; break;
} }
}, [threadId]); },
[threadId],
);
const handleStreamError = useCallback((errorMessage: string) => { const handleStreamError = useCallback((errorMessage: string) => {
console.error(`[PAGE] Stream hook error: ${errorMessage}`); console.error(`[PAGE] Stream hook error: ${errorMessage}`);
@ -207,7 +226,11 @@ export default function ThreadPage({
} }
const currentMessage = messages[currentMessageIndex]; const currentMessage = messages[currentMessageIndex];
console.log(`Playing message ${currentMessageIndex}:`, currentMessage.type, currentMessage.message_id); console.log(
`Playing message ${currentMessageIndex}:`,
currentMessage.type,
currentMessage.message_id,
);
// Move to the next message // Move to the next message
setCurrentMessageIndex((prevIndex) => prevIndex + 1); setCurrentMessageIndex((prevIndex) => prevIndex + 1);
@ -260,7 +283,7 @@ export default function ThreadPage({
// Start loading all data in parallel // Start loading all data in parallel
const [threadData, messagesData] = await Promise.all([ const [threadData, messagesData] = await Promise.all([
getThread(threadId).catch(err => { getThread(threadId).catch((err) => {
if (threadErrorCodeMessages[err.code]) { if (threadErrorCodeMessages[err.code]) {
setError(threadErrorCodeMessages[err.code]); setError(threadErrorCodeMessages[err.code]);
} else { } else {
@ -268,7 +291,7 @@ export default function ThreadPage({
} }
return null; return null;
}), }),
getMessages(threadId).catch(err => { getMessages(threadId).catch((err) => {
console.warn('Failed to load messages:', err); console.warn('Failed to load messages:', err);
return []; return [];
}), }),
@ -277,11 +300,12 @@ export default function ThreadPage({
if (!isMounted) return; if (!isMounted) return;
// Load project data if we have a project ID // Load project data if we have a project ID
const projectData = threadData?.project_id ? const projectData = threadData?.project_id
await getProject(threadData.project_id).catch(err => { ? await getProject(threadData.project_id).catch((err) => {
console.warn('[SHARE] Could not load project data:', err); console.warn('[SHARE] Could not load project data:', err);
return null; return null;
}) : null; })
: null;
if (isMounted) { if (isMounted) {
if (projectData) { if (projectData) {
@ -319,7 +343,9 @@ export default function ThreadPage({
// Calculate historical tool pairs // Calculate historical tool pairs
const historicalToolPairs: ToolCallInput[] = []; const historicalToolPairs: ToolCallInput[] = [];
const assistantMessages = unifiedMessages.filter(m => m.type === 'assistant' && m.message_id); const assistantMessages = unifiedMessages.filter(
(m) => m.type === 'assistant' && m.message_id,
);
// Map to track which assistant messages have tool results // Map to track which assistant messages have tool results
const assistantToolMap = new Map<string, UnifiedMessage>(); const assistantToolMap = new Map<string, UnifiedMessage>();
@ -355,7 +381,8 @@ export default function ThreadPage({
assistantContent = { content: assistantMsg.content }; assistantContent = { content: assistantMsg.content };
} }
const assistantMessageText = assistantContent.content || assistantMsg.content; const assistantMessageText =
assistantContent.content || assistantMsg.content;
// Use a regex to find tool calls in the message content // Use a regex to find tool calls in the message content
const toolCalls = extractToolCallsFromMessage(assistantMessageText); const toolCalls = extractToolCallsFromMessage(assistantMessageText);
@ -448,46 +475,63 @@ export default function ThreadPage({
return ( return (
<div className="space-y-1"> <div className="space-y-1">
<div className="text-xs font-medium text-muted-foreground">Assistant Message</div> <div className="text-xs font-medium text-muted-foreground">
Assistant Message
</div>
<div className="rounded-md border bg-muted/50 p-3"> <div className="rounded-md border bg-muted/50 p-3">
<div className="text-xs prose prose-xs dark:prose-invert chat-markdown max-w-none">{assistantContent}</div> <div className="text-xs prose prose-xs dark:prose-invert chat-markdown max-w-none">
{assistantContent}
</div>
</div> </div>
</div> </div>
); );
}, []); }, []);
// Process the tool result data // Process the tool result data
const toolViewResult = useCallback((toolContent?: string, isSuccess?: boolean) => { const toolViewResult = useCallback(
(toolContent?: string, isSuccess?: boolean) => {
if (!toolContent) return null; if (!toolContent) return null;
return ( return (
<div className="space-y-1"> <div className="space-y-1">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div className="text-xs font-medium text-muted-foreground">Tool Result</div> <div className="text-xs font-medium text-muted-foreground">
<div className={`px-2 py-0.5 rounded-full text-xs ${isSuccess Tool Result
</div>
<div
className={`px-2 py-0.5 rounded-full text-xs ${
isSuccess
? 'bg-green-50 text-green-700 dark:bg-green-900 dark:text-green-300' ? 'bg-green-50 text-green-700 dark:bg-green-900 dark:text-green-300'
: 'bg-red-50 text-red-700 dark:bg-red-900 dark:text-red-300' : 'bg-red-50 text-red-700 dark:bg-red-900 dark:text-red-300'
}`}> }`}
>
{isSuccess ? 'Success' : 'Failed'} {isSuccess ? 'Success' : 'Failed'}
</div> </div>
</div> </div>
<div className="rounded-md border bg-muted/50 p-3"> <div className="rounded-md border bg-muted/50 p-3">
<div className="text-xs prose prose-xs dark:prose-invert chat-markdown max-w-none">{toolContent}</div> <div className="text-xs prose prose-xs dark:prose-invert chat-markdown max-w-none">
{toolContent}
</div>
</div> </div>
</div> </div>
); );
}, []); },
[],
);
// Handle tool clicks // Handle tool clicks
const handleToolClick = useCallback((clickedAssistantMessageId: string | null, clickedToolName: string) => { const handleToolClick = useCallback(
(clickedAssistantMessageId: string | null, clickedToolName: string) => {
// Explicitly ignore ask tags from opening the side panel // Explicitly ignore ask tags from opening the side panel
if (clickedToolName === 'ask') { if (clickedToolName === 'ask') {
return; return;
} }
if (!clickedAssistantMessageId) { if (!clickedAssistantMessageId) {
console.warn("Clicked assistant message ID is null. Cannot open side panel."); console.warn(
toast.warning("Cannot view details: Assistant message ID is missing."); 'Clicked assistant message ID is null. Cannot open side panel.',
);
toast.warning('Cannot view details: Assistant message ID is missing.');
return; return;
} }
@ -501,10 +545,14 @@ export default function ThreadPage({
setCurrentToolIndex(toolIndex); setCurrentToolIndex(toolIndex);
setIsSidePanelOpen(true); setIsSidePanelOpen(true);
} else { } else {
console.warn(`Could not find matching tool call for message ID: ${clickedAssistantMessageId}`); console.warn(
toast.info("Could not find details for this tool call."); `Could not find matching tool call for message ID: ${clickedAssistantMessageId}`,
);
toast.info('Could not find details for this tool call.');
} }
}, [messageToToolIndex]); },
[messageToToolIndex],
);
const handleOpenFileViewer = useCallback((filePath?: string) => { const handleOpenFileViewer = useCallback((filePath?: string) => {
if (filePath) { if (filePath) {
@ -523,7 +571,7 @@ export default function ThreadPage({
toolCalls, toolCalls,
setCurrentToolIndex, setCurrentToolIndex,
onFileViewerOpen: handleOpenFileViewer, onFileViewerOpen: handleOpenFileViewer,
projectName: projectName || 'Shared Conversation' projectName: projectName || 'Shared Conversation',
}); });
// Extract the playback state and functions // Extract the playback state and functions
@ -534,7 +582,7 @@ export default function ThreadPage({
renderWelcomeOverlay, renderWelcomeOverlay,
togglePlayback, togglePlayback,
resetPlayback, resetPlayback,
skipToEnd skipToEnd,
} = playbackController; } = playbackController;
// Connect playbackState to component state // Connect playbackState to component state
@ -553,7 +601,8 @@ export default function ThreadPage({
// Scroll button visibility // Scroll button visibility
useEffect(() => { useEffect(() => {
if (!latestMessageRef.current || playbackState.visibleMessages.length === 0) return; if (!latestMessageRef.current || playbackState.visibleMessages.length === 0)
return;
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
([entry]) => setShowScrollButton(!entry?.isIntersecting), ([entry]) => setShowScrollButton(!entry?.isIntersecting),
{ root: messagesContainerRef.current, threshold: 0.1 }, { root: messagesContainerRef.current, threshold: 0.1 },
@ -563,13 +612,21 @@ export default function ThreadPage({
}, [playbackState.visibleMessages, streamingText, currentToolCall]); }, [playbackState.visibleMessages, streamingText, currentToolCall]);
useEffect(() => { useEffect(() => {
console.log(`[PAGE] 🔄 Page AgentStatus: ${agentStatus}, Hook Status: ${streamHookStatus}, Target RunID: ${agentRunId || 'none'}, Hook RunID: ${currentHookRunId || 'none'}`); console.log(
`[PAGE] 🔄 Page AgentStatus: ${agentStatus}, Hook Status: ${streamHookStatus}, Target RunID: ${agentRunId || 'none'}, Hook RunID: ${currentHookRunId || 'none'}`,
);
// If the stream hook reports completion/stopping but our UI hasn't updated // If the stream hook reports completion/stopping but our UI hasn't updated
if ((streamHookStatus === 'completed' || streamHookStatus === 'stopped' || if (
streamHookStatus === 'agent_not_running' || streamHookStatus === 'error') && (streamHookStatus === 'completed' ||
(agentStatus === 'running' || agentStatus === 'connecting')) { streamHookStatus === 'stopped' ||
console.log('[PAGE] Detected hook completed but UI still shows running, updating status'); streamHookStatus === 'agent_not_running' ||
streamHookStatus === 'error') &&
(agentStatus === 'running' || agentStatus === 'connecting')
) {
console.log(
'[PAGE] Detected hook completed but UI still shows running, updating status',
);
setAgentStatus('idle'); setAgentStatus('idle');
setAgentRunId(null); setAgentRunId(null);
setAutoOpenedPanel(false); setAutoOpenedPanel(false);
@ -577,11 +634,14 @@ export default function ThreadPage({
}, [agentStatus, streamHookStatus, agentRunId, currentHookRunId]); }, [agentStatus, streamHookStatus, agentRunId, currentHookRunId]);
// Auto-scroll function for use throughout the component // Auto-scroll function for use throughout the component
const autoScrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => { const autoScrollToBottom = useCallback(
(behavior: ScrollBehavior = 'smooth') => {
if (!userHasScrolled && messagesEndRef.current) { if (!userHasScrolled && messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior }); messagesEndRef.current.scrollIntoView({ behavior });
} }
}, [userHasScrolled]); },
[userHasScrolled],
);
// Very direct approach to update the tool index during message playback // Very direct approach to update the tool index during message playback
useEffect(() => { useEffect(() => {
@ -625,7 +685,9 @@ export default function ThreadPage({
const assistantId = metadata.assistant_message_id; const assistantId = metadata.assistant_message_id;
if (assistantId) { if (assistantId) {
console.log(`Looking for tool panel for assistant message ${assistantId}`); console.log(
`Looking for tool panel for assistant message ${assistantId}`,
);
// Scan for matching tool call // Scan for matching tool call
for (let j = 0; j < toolCalls.length; j++) { for (let j = 0; j < toolCalls.length; j++) {
@ -648,7 +710,9 @@ export default function ThreadPage({
// Loading skeleton UI // Loading skeleton UI
if (isLoading && !initialLoadCompleted.current) { if (isLoading && !initialLoadCompleted.current) {
return <ThreadSkeleton isSidePanelOpen={isSidePanelOpen} showHeader={true} />; return (
<ThreadSkeleton isSidePanelOpen={isSidePanelOpen} showHeader={true} />
);
} }
// Error state UI // Error state UI
@ -658,7 +722,7 @@ export default function ThreadPage({
<div <div
className={`flex flex-col flex-1 overflow-hidden transition-all duration-200 ease-in-out ${isSidePanelOpen ? 'mr-[90%] sm:mr-[450px] md:mr-[500px] lg:mr-[550px] xl:mr-[650px]' : ''}`} className={`flex flex-col flex-1 overflow-hidden transition-all duration-200 ease-in-out ${isSidePanelOpen ? 'mr-[90%] sm:mr-[450px] md:mr-[500px] lg:mr-[550px] xl:mr-[650px]' : ''}`}
> >
<div className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"> <div className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 relative z-[100]">
<div className="flex h-14 items-center gap-4 px-4"> <div className="flex h-14 items-center gap-4 px-4">
<div className="flex-1"> <div className="flex-1">
<span className="text-foreground font-medium"> <span className="text-foreground font-medium">
@ -671,7 +735,10 @@ export default function ThreadPage({
<div className="flex w-full max-w-md flex-col items-center gap-4 rounded-lg border bg-card p-6 text-center"> <div className="flex w-full max-w-md flex-col items-center gap-4 rounded-lg border bg-card p-6 text-center">
<h2 className="text-lg font-semibold text-destructive">Error</h2> <h2 className="text-lg font-semibold text-destructive">Error</h2>
<p className="text-sm text-muted-foreground">{error}</p> <p className="text-sm text-muted-foreground">{error}</p>
<button className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground" onClick={() => router.push('/')}> <button
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground"
onClick={() => router.push('/')}
>
Back to Home Back to Home
</button> </button>
</div> </div>
@ -701,7 +768,7 @@ export default function ThreadPage({
streamingText={playbackState.streamingText} streamingText={playbackState.streamingText}
isStreamingText={playbackState.isStreamingText} isStreamingText={playbackState.isStreamingText}
currentToolCall={playbackState.currentToolCall} currentToolCall={playbackState.currentToolCall}
sandboxId={sandboxId || ""} sandboxId={sandboxId || ''}
project={project} project={project}
/> />

View File

@ -3,6 +3,7 @@ import { Button } from '@/components/ui/button';
import { Play, Pause, ArrowDown, FileText, Info } from 'lucide-react'; import { Play, Pause, ArrowDown, FileText, Info } from 'lucide-react';
import { UnifiedMessage } from '@/components/thread/types'; import { UnifiedMessage } from '@/components/thread/types';
import { safeJsonParse } from '@/components/thread/utils'; import { safeJsonParse } from '@/components/thread/utils';
import Link from 'next/link';
// Define the set of tags whose raw XML should be hidden during streaming // Define the set of tags whose raw XML should be hidden during streaming
const HIDE_STREAMING_XML_TAGS = new Set([ const HIDE_STREAMING_XML_TAGS = new Set([
@ -29,7 +30,7 @@ const HIDE_STREAMING_XML_TAGS = new Set([
'ask', 'ask',
'complete', 'complete',
'crawl-webpage', 'crawl-webpage',
'web-search' 'web-search',
]); ]);
export interface PlaybackControlsProps { export interface PlaybackControlsProps {
@ -70,16 +71,16 @@ export const PlaybackControls = ({
toolCalls, toolCalls,
setCurrentToolIndex, setCurrentToolIndex,
onFileViewerOpen, onFileViewerOpen,
projectName = 'Shared Conversation' projectName = 'Shared Conversation',
}: PlaybackControlsProps): PlaybackController => { }: PlaybackControlsProps): PlaybackController => {
const [playbackState, setPlaybackState] = useState<PlaybackState>({ const [playbackState, setPlaybackState] = useState<PlaybackState>({
isPlaying: false, isPlaying: false,
currentMessageIndex: 0, currentMessageIndex: 0,
visibleMessages: [], visibleMessages: [],
streamingText: "", streamingText: '',
isStreamingText: false, isStreamingText: false,
currentToolCall: null, currentToolCall: null,
toolPlaybackIndex: -1 toolPlaybackIndex: -1,
}); });
// Extract state variables for easier access // Extract state variables for easier access
@ -90,18 +91,18 @@ export const PlaybackControls = ({
streamingText, streamingText,
isStreamingText, isStreamingText,
currentToolCall, currentToolCall,
toolPlaybackIndex toolPlaybackIndex,
} = playbackState; } = playbackState;
// Helper function to update playback state // Helper function to update playback state
const updatePlaybackState = useCallback((updates: Partial<PlaybackState>) => { const updatePlaybackState = useCallback((updates: Partial<PlaybackState>) => {
setPlaybackState(prev => ({ ...prev, ...updates })); setPlaybackState((prev) => ({ ...prev, ...updates }));
}, []); }, []);
// Define togglePlayback and resetPlayback functions // Define togglePlayback and resetPlayback functions
const togglePlayback = useCallback(() => { const togglePlayback = useCallback(() => {
updatePlaybackState({ updatePlaybackState({
isPlaying: !isPlaying isPlaying: !isPlaying,
}); });
// When starting playback, show the side panel // When starting playback, show the side panel
@ -115,10 +116,10 @@ export const PlaybackControls = ({
isPlaying: false, isPlaying: false,
currentMessageIndex: 0, currentMessageIndex: 0,
visibleMessages: [], visibleMessages: [],
streamingText: "", streamingText: '',
isStreamingText: false, isStreamingText: false,
currentToolCall: null, currentToolCall: null,
toolPlaybackIndex: -1 toolPlaybackIndex: -1,
}); });
}, [updatePlaybackState]); }, [updatePlaybackState]);
@ -127,10 +128,10 @@ export const PlaybackControls = ({
isPlaying: false, isPlaying: false,
currentMessageIndex: messages.length, currentMessageIndex: messages.length,
visibleMessages: messages, visibleMessages: messages,
streamingText: "", streamingText: '',
isStreamingText: false, isStreamingText: false,
currentToolCall: null, currentToolCall: null,
toolPlaybackIndex: toolCalls.length - 1 toolPlaybackIndex: toolCalls.length - 1,
}); });
if (toolCalls.length > 0) { if (toolCalls.length > 0) {
@ -139,22 +140,31 @@ export const PlaybackControls = ({
onToggleSidePanel(); onToggleSidePanel();
} }
} }
}, [messages, toolCalls, isSidePanelOpen, onToggleSidePanel, setCurrentToolIndex, updatePlaybackState]); }, [
messages,
toolCalls,
isSidePanelOpen,
onToggleSidePanel,
setCurrentToolIndex,
updatePlaybackState,
]);
// Streaming text function // Streaming text function
const streamText = useCallback((text: string, onComplete: () => void) => { const streamText = useCallback(
(text: string, onComplete: () => void) => {
if (!text || !isPlaying) { if (!text || !isPlaying) {
onComplete(); onComplete();
return () => { }; return () => {};
} }
updatePlaybackState({ updatePlaybackState({
isStreamingText: true, isStreamingText: true,
streamingText: "" streamingText: '',
}); });
// Define regex to find tool calls in text // Define regex to find tool calls in text
const toolCallRegex = /<([a-zA-Z\-_]+)(?:\s+[^>]*)?>(?:[\s\S]*?)<\/\1>|<([a-zA-Z\-_]+)(?:\s+[^>]*)?\/>/g; const toolCallRegex =
/<([a-zA-Z\-_]+)(?:\s+[^>]*)?>(?:[\s\S]*?)<\/\1>|<([a-zA-Z\-_]+)(?:\s+[^>]*)?\/>/g;
// Split text into chunks (handling tool calls as special chunks) // Split text into chunks (handling tool calls as special chunks)
const chunks: { text: string; isTool: boolean; toolName?: string }[] = []; const chunks: { text: string; isTool: boolean; toolName?: string }[] = [];
@ -166,7 +176,7 @@ export const PlaybackControls = ({
if (match.index > lastIndex) { if (match.index > lastIndex) {
chunks.push({ chunks.push({
text: text.substring(lastIndex, match.index), text: text.substring(lastIndex, match.index),
isTool: false isTool: false,
}); });
} }
@ -175,7 +185,7 @@ export const PlaybackControls = ({
chunks.push({ chunks.push({
text: match[0], text: match[0],
isTool: true, isTool: true,
toolName toolName,
}); });
lastIndex = toolCallRegex.lastIndex; lastIndex = toolCallRegex.lastIndex;
@ -185,7 +195,7 @@ export const PlaybackControls = ({
if (lastIndex < text.length) { if (lastIndex < text.length) {
chunks.push({ chunks.push({
text: text.substring(lastIndex), text: text.substring(lastIndex),
isTool: false isTool: false,
}); });
} }
@ -204,7 +214,7 @@ export const PlaybackControls = ({
if (chunkIndex >= chunks.length) { if (chunkIndex >= chunks.length) {
// All chunks processed, we're done // All chunks processed, we're done
updatePlaybackState({ updatePlaybackState({
isStreamingText: false isStreamingText: false,
}); });
// Update visible messages with the complete message // Update visible messages with the complete message
@ -214,12 +224,15 @@ export const PlaybackControls = ({
if (lastMessage?.message_id === currentMessage.message_id) { if (lastMessage?.message_id === currentMessage.message_id) {
// Replace the streaming message with the complete one // Replace the streaming message with the complete one
updatePlaybackState({ updatePlaybackState({
visibleMessages: [...visibleMessages.slice(0, -1), currentMessage] visibleMessages: [
...visibleMessages.slice(0, -1),
currentMessage,
],
}); });
} else { } else {
// Add the complete message // Add the complete message
updatePlaybackState({ updatePlaybackState({
visibleMessages: [...visibleMessages, currentMessage] visibleMessages: [...visibleMessages, currentMessage],
}); });
} }
@ -232,17 +245,20 @@ export const PlaybackControls = ({
// If this is a tool call chunk and we're at the start of it // If this is a tool call chunk and we're at the start of it
if (currentChunk.isTool && currentIndex === 0) { if (currentChunk.isTool && currentIndex === 0) {
// For tool calls, check if they should be hidden during streaming // For tool calls, check if they should be hidden during streaming
if (currentChunk.toolName && HIDE_STREAMING_XML_TAGS.has(currentChunk.toolName)) { if (
currentChunk.toolName &&
HIDE_STREAMING_XML_TAGS.has(currentChunk.toolName)
) {
// Instead of showing the XML, create a tool call object // Instead of showing the XML, create a tool call object
const toolCall = { const toolCall = {
name: currentChunk.toolName, name: currentChunk.toolName,
arguments: currentChunk.text, arguments: currentChunk.text,
xml_tag_name: currentChunk.toolName xml_tag_name: currentChunk.toolName,
}; };
updatePlaybackState({ updatePlaybackState({
currentToolCall: toolCall, currentToolCall: toolCall,
toolPlaybackIndex: toolPlaybackIndex + 1 toolPlaybackIndex: toolPlaybackIndex + 1,
}); });
if (!isSidePanelOpen) { if (!isSidePanelOpen) {
@ -273,7 +289,7 @@ export const PlaybackControls = ({
// Add more delay for punctuation to make it feel more natural // Add more delay for punctuation to make it feel more natural
const char = currentChunk.text[currentIndex]; const char = currentChunk.text[currentIndex];
if (".!?,;:".includes(char)) { if ('.!?,;:'.includes(char)) {
typingDelay = baseDelay + Math.random() * 100 + 50; // Reduced from 300+100 to 100+50ms pause after punctuation typingDelay = baseDelay + Math.random() * 100 + 50; // Reduced from 300+100 to 100+50ms pause after punctuation
} else { } else {
const variableDelay = Math.random() * 5; // Reduced from 15 to 5ms const variableDelay = Math.random() * 5; // Reduced from 15 to 5ms
@ -301,11 +317,23 @@ export const PlaybackControls = ({
return () => { return () => {
updatePlaybackState({ updatePlaybackState({
isStreamingText: false, isStreamingText: false,
streamingText: "" streamingText: '',
}); });
isPaused = true; // Stop processing isPaused = true; // Stop processing
}; };
}, [isPlaying, messages, currentMessageIndex, toolPlaybackIndex, setCurrentToolIndex, isSidePanelOpen, onToggleSidePanel, updatePlaybackState, visibleMessages]); },
[
isPlaying,
messages,
currentMessageIndex,
toolPlaybackIndex,
setCurrentToolIndex,
isSidePanelOpen,
onToggleSidePanel,
updatePlaybackState,
visibleMessages,
],
);
// Main playback function // Main playback function
useEffect(() => { useEffect(() => {
@ -322,7 +350,11 @@ export const PlaybackControls = ({
} }
const currentMessage = messages[currentMessageIndex]; const currentMessage = messages[currentMessageIndex];
console.log(`Playing message ${currentMessageIndex}:`, currentMessage.type, currentMessage.message_id); console.log(
`Playing message ${currentMessageIndex}:`,
currentMessage.type,
currentMessage.message_id,
);
// If it's an assistant message, stream it // If it's an assistant message, stream it
if (currentMessage.type === 'assistant') { if (currentMessage.type === 'assistant') {
@ -348,16 +380,16 @@ export const PlaybackControls = ({
} else { } else {
// For non-assistant messages, just add them to visible messages // For non-assistant messages, just add them to visible messages
updatePlaybackState({ updatePlaybackState({
visibleMessages: [...visibleMessages, currentMessage] visibleMessages: [...visibleMessages, currentMessage],
}); });
// Wait a moment before showing the next message // Wait a moment before showing the next message
await new Promise(resolve => setTimeout(resolve, 500)); await new Promise((resolve) => setTimeout(resolve, 500));
} }
// Move to the next message // Move to the next message
updatePlaybackState({ updatePlaybackState({
currentMessageIndex: currentMessageIndex + 1 currentMessageIndex: currentMessageIndex + 1,
}); });
}; };
@ -368,7 +400,14 @@ export const PlaybackControls = ({
clearTimeout(playbackTimeout); clearTimeout(playbackTimeout);
if (cleanupStreaming) cleanupStreaming(); if (cleanupStreaming) cleanupStreaming();
}; };
}, [isPlaying, currentMessageIndex, messages, streamText, updatePlaybackState, visibleMessages]); }, [
isPlaying,
currentMessageIndex,
messages,
streamText,
updatePlaybackState,
visibleMessages,
]);
// Floating playback controls position based on side panel state // Floating playback controls position based on side panel state
const controlsPositionClass = isSidePanelOpen const controlsPositionClass = isSidePanelOpen
@ -376,15 +415,28 @@ export const PlaybackControls = ({
: 'left-1/2 -translate-x-1/2'; : 'left-1/2 -translate-x-1/2';
// Header with playback controls // Header with playback controls
const renderHeader = useCallback(() => ( const renderHeader = useCallback(
<div className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"> () => (
<div className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 relative z-[50]">
<div className="flex h-14 items-center gap-4 px-4"> <div className="flex h-14 items-center gap-4 px-4">
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="flex items-center justify-center w-6 h-6 rounded-md overflow-hidden bg-primary/10"> <div className="flex items-center justify-center w-6 h-6 rounded-md overflow-hidden bg-primary/10">
<img src="/kortix-symbol.svg" alt="Kortix" width={16} height={16} className="object-contain" /> <Link href="/">
<img
src="/kortix-symbol.svg"
alt="Kortix"
width={16}
height={16}
className="object-contain"
/>
</Link>
</div> </div>
<span className="font-medium text-foreground">{projectName}</span> <h1>
<span className="font-medium text-foreground">
{projectName}
</span>
</h1>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -402,9 +454,13 @@ export const PlaybackControls = ({
size="icon" size="icon"
onClick={togglePlayback} onClick={togglePlayback}
className="h-8 w-8" className="h-8 w-8"
aria-label={isPlaying ? "Pause Replay" : "Play Replay"} aria-label={isPlaying ? 'Pause Replay' : 'Play Replay'}
> >
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />} {isPlaying ? (
<Pause className="h-4 w-4" />
) : (
<Play className="h-4 w-4" />
)}
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
@ -419,7 +475,7 @@ export const PlaybackControls = ({
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={onToggleSidePanel} onClick={onToggleSidePanel}
className={`h-8 w-8 ${isSidePanelOpen ? "text-primary" : ""}`} className={`h-8 w-8 ${isSidePanelOpen ? 'text-primary' : ''}`}
aria-label="Toggle Tool Panel" aria-label="Toggle Tool Panel"
> >
<Info className="h-4 w-4" /> <Info className="h-4 w-4" />
@ -427,9 +483,20 @@ export const PlaybackControls = ({
</div> </div>
</div> </div>
</div> </div>
), [isPlaying, isSidePanelOpen, onFileViewerOpen, onToggleSidePanel, projectName, resetPlayback, togglePlayback]); ),
[
isPlaying,
isSidePanelOpen,
onFileViewerOpen,
onToggleSidePanel,
projectName,
resetPlayback,
togglePlayback,
],
);
const renderFloatingControls = useCallback(() => ( const renderFloatingControls = useCallback(
() => (
<> <>
{messages.length > 0 && ( {messages.length > 0 && (
<div <div
@ -442,11 +509,21 @@ export const PlaybackControls = ({
onClick={togglePlayback} onClick={togglePlayback}
className="h-8 w-8" className="h-8 w-8"
> >
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />} {isPlaying ? (
<Pause className="h-4 w-4" />
) : (
<Play className="h-4 w-4" />
)}
</Button> </Button>
<div className="flex items-center text-xs text-muted-foreground"> <div className="flex items-center text-xs text-muted-foreground">
<span>{Math.min(currentMessageIndex + (isStreamingText ? 0 : 1), messages.length)}/{messages.length}</span> <span>
{Math.min(
currentMessageIndex + (isStreamingText ? 0 : 1),
messages.length,
)}
/{messages.length}
</span>
</div> </div>
<Button <Button
@ -470,10 +547,22 @@ export const PlaybackControls = ({
</div> </div>
)} )}
</> </>
), [controlsPositionClass, currentMessageIndex, isPlaying, isStreamingText, messages.length, resetPlayback, skipToEnd, togglePlayback]); ),
[
controlsPositionClass,
currentMessageIndex,
isPlaying,
isStreamingText,
messages.length,
resetPlayback,
skipToEnd,
togglePlayback,
],
);
// When no messages are displayed yet, show the welcome overlay // When s are displayed yet, show the welcome overlay
const renderWelcomeOverlay = useCallback(() => ( const renderWelcomeOverlay = useCallback(
() => (
<> <>
{visibleMessages.length === 0 && !streamingText && !currentToolCall && ( {visibleMessages.length === 0 && !streamingText && !currentToolCall && (
<div className="fixed inset-0 flex flex-col items-center justify-center"> <div className="fixed inset-0 flex flex-col items-center justify-center">
@ -484,9 +573,12 @@ export const PlaybackControls = ({
<div className="rounded-full bg-primary/10 backdrop-blur-sm w-12 h-12 mx-auto flex items-center justify-center mb-4"> <div className="rounded-full bg-primary/10 backdrop-blur-sm w-12 h-12 mx-auto flex items-center justify-center mb-4">
<Play className="h-5 w-5 text-primary" /> <Play className="h-5 w-5 text-primary" />
</div> </div>
<h3 className="text-lg font-medium mb-2 text-white">Watch this agent in action</h3> <h3 className="text-lg font-medium mb-2 text-white">
Watch this agent in action
</h3>
<p className="text-sm text-white/80 mb-4"> <p className="text-sm text-white/80 mb-4">
This is a shared view-only agent run. Click play to replay the entire conversation with realistic timing. This is a shared view-only agent run. Click play to replay the
entire conversation with realistic timing.
</p> </p>
<Button <Button
onClick={togglePlayback} onClick={togglePlayback}
@ -501,7 +593,9 @@ export const PlaybackControls = ({
</div> </div>
)} )}
</> </>
), [currentToolCall, streamingText, togglePlayback, visibleMessages.length]); ),
[currentToolCall, streamingText, togglePlayback, visibleMessages.length],
);
return { return {
playbackState, playbackState,
@ -511,7 +605,7 @@ export const PlaybackControls = ({
renderWelcomeOverlay, renderWelcomeOverlay,
togglePlayback, togglePlayback,
resetPlayback, resetPlayback,
skipToEnd skipToEnd,
}; };
}; };