diff --git a/src/App.tsx b/src/App.tsx index 0070ec7..927b320 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -345,6 +345,17 @@ const App: React.FC<{ router?: boolean }> = ({ router = true }) => { } }; } + + // Check if it is an ATIF trajectory (steps array) + if (data.steps && Array.isArray(data.steps)) { + console.log('Detected ATIF trajectory format - using trajectory viewer'); + return { + content: { + trajectoryData: data.steps, + fileType: 'trajectory' + } + }; + } // If it's not in a recognized format, return as is console.log('Unknown format, passing as trajectory data'); @@ -829,4 +840,4 @@ const App: React.FC<{ router?: boolean }> = ({ router = true }) => { return router ? {content} : content; }; -export default App; \ No newline at end of file +export default App; diff --git a/src/components/share/__tests__/trajectory-list.test.tsx b/src/components/share/__tests__/trajectory-list.test.tsx new file mode 100644 index 0000000..9ff7a19 --- /dev/null +++ b/src/components/share/__tests__/trajectory-list.test.tsx @@ -0,0 +1,59 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { TrajectoryList } from '../trajectory-list'; + +describe('TrajectoryList', () => { + it('renders ATIF steps with visible headers and content', () => { + render( + + ); + + expect(screen.getByText('System step #1')).toBeInTheDocument(); + expect(screen.getByText('Agent step #2')).toBeInTheDocument(); + expect(document.body.textContent).toContain('System line 1'); + expect(document.body.textContent).not.toContain('System line 6'); + expect(screen.getAllByRole('button', { name: 'Show more' })).toHaveLength(3); + expect(document.body.textContent).toContain('terminal'); + expect(document.body.textContent).toContain('file 1'); + expect(screen.getByText('think')).toBeInTheDocument(); + expect(screen.getByText('I should inspect the next file.')).toBeInTheDocument(); + expect(screen.getByText('Agent step #4')).toBeInTheDocument(); + expect(screen.getByText('Operation:')).toBeInTheDocument(); + expect(screen.getByText('view')).toBeInTheDocument(); + expect(screen.getByText('/app/data/README.md')).toBeInTheDocument(); + expect(screen.getByText('README contents')).toBeInTheDocument(); + }); +}); diff --git a/src/components/share/expandable-content.tsx b/src/components/share/expandable-content.tsx new file mode 100644 index 0000000..c456710 --- /dev/null +++ b/src/components/share/expandable-content.tsx @@ -0,0 +1,46 @@ +import React, { useState } from 'react'; +import { CSyntaxHighlighter } from '../syntax-highlighter'; + +interface ExpandableContentProps { + content: string; + maxLines: number; + language?: string; + renderContent?: (content: string) => React.ReactNode; +} + +export const ExpandableContent: React.FC = ({ + content, + maxLines, + language, + renderContent +}) => { + const [isExpanded, setIsExpanded] = useState(false); + const lines = content.split('\n'); + const isExpandable = lines.length > maxLines; + const displayedContent = isExpanded ? content : lines.slice(0, maxLines).join('\n'); + const renderedContent = renderContent + ? renderContent(displayedContent) + : language + ? {displayedContent} + :
{displayedContent}
; + + return ( +
+ {renderedContent} + {isExpandable && ( +
+ +
+ )} +
+ ); +}; diff --git a/src/components/share/trajectory-card.tsx b/src/components/share/trajectory-card.tsx index 2978c15..f0b3079 100644 --- a/src/components/share/trajectory-card.tsx +++ b/src/components/share/trajectory-card.tsx @@ -7,6 +7,7 @@ interface TrajectoryTempateProps { className?: React.HTMLAttributes["className"]; originalJson?: any; defaultCollapsed?: boolean; + collapsedPreview?: React.ReactNode; timestamp?: string; } @@ -15,7 +16,7 @@ interface TrajectoryCardType extends React.FC { Body: React.FC; } -export const TrajectoryCard: TrajectoryCardType = ({ children, className, originalJson, defaultCollapsed = false, timestamp }) => { +export const TrajectoryCard: TrajectoryCardType = ({ children, className, originalJson, defaultCollapsed = false, collapsedPreview, timestamp }) => { const [isModalOpen, setIsModalOpen] = useState(false); const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed); @@ -67,12 +68,12 @@ export const TrajectoryCard: TrajectoryCardType = ({ children, className, origin )} {/* Render body only if not collapsed, otherwise show hint */} - {!isCollapsed ? ( - body - ) : ( -
- Content collapsed. Click the arrow icon in the top right to expand. -
+ {!isCollapsed ? body : ( + collapsedPreview || ( +
+ Content collapsed. Click the arrow icon in the top right to expand. +
+ ) )} {originalJson && ( @@ -139,4 +140,4 @@ const TrajectoryCardBody: React.FC = ({ children }) => }; TrajectoryCard.Header = TrajectoryCardHeader; -TrajectoryCard.Body = TrajectoryCardBody; \ No newline at end of file +TrajectoryCard.Body = TrajectoryCardBody; diff --git a/src/components/share/trajectory-list-items/atif-step.tsx b/src/components/share/trajectory-list-items/atif-step.tsx new file mode 100644 index 0000000..acfbd68 --- /dev/null +++ b/src/components/share/trajectory-list-items/atif-step.tsx @@ -0,0 +1,145 @@ +import React from 'react'; +import { CMarkdown } from '../../markdown'; +import { CSyntaxHighlighter } from '../../syntax-highlighter'; +import { ExpandableContent } from '../expandable-content'; +import { TrajectoryCard } from '../trajectory-card'; + +interface AtifToolCall { + function_name?: string; + arguments?: unknown; + tool_call_id?: string; +} + +interface AtifObservationResult { + content?: unknown; + source_call_id?: string; +} + +export interface AtifStep { + step_id?: number; + timestamp?: string; + source?: string; + message?: string; + model_name?: string; + tool_calls?: AtifToolCall[]; + observation?: { + results?: AtifObservationResult[]; + }; +} + +interface AtifStepProps { + step: AtifStep; +} + +const sourceStyles: Record = { + system: 'bg-slate-100 dark:bg-slate-800/50 text-slate-800 dark:text-slate-100', + user: 'bg-blue-100 dark:bg-blue-800/50 text-blue-800 dark:text-blue-100', + agent: 'bg-amber-100 dark:bg-amber-800/50 text-amber-800 dark:text-amber-100' +}; + +const getArgument = (toolCall: AtifToolCall, name: string): unknown => { + if (!toolCall.arguments || typeof toolCall.arguments !== 'object') return undefined; + return (toolCall.arguments as Record)[name]; +}; + +const renderFileEditorArguments = (toolCall: AtifToolCall) => { + const command = getArgument(toolCall, 'command'); + const path = getArgument(toolCall, 'path'); + const patch = getArgument(toolCall, 'patch'); + const fileText = getArgument(toolCall, 'file_text'); + + return ( +
+ {typeof command === 'string' && ( +
Operation: {command}
+ )} + {typeof path === 'string' && ( +
Path: {path}
+ )} + {typeof patch === 'string' && } + {typeof fileText === 'string' && } +
+ ); +}; + +export const AtifStepComponent: React.FC = ({ step }) => { + const source = step.source || 'unknown'; + const toolCalls = Array.isArray(step.tool_calls) ? step.tool_calls : []; + const results = Array.isArray(step.observation?.results) ? step.observation.results : []; + const message = typeof step.message === 'string' ? step.message : ''; + const headerSource = `${source.charAt(0).toUpperCase()}${source.slice(1)}`; + + return ( + + + {headerSource} step{step.step_id !== undefined ? ` #${step.step_id}` : ''} + {step.model_name && ({step.model_name})} + + + {message && ( +
+ {source === 'system' ? ( + {content}} /> + ) : {message}} +
+ )} + + {toolCalls.length > 0 && ( +
+
+ Tool calls ({toolCalls.length}) +
+ {toolCalls.map((toolCall, index) => ( +
+
+ {toolCall.function_name || 'Unnamed tool'} +
+ {toolCall.function_name === 'file_editor' ? ( + renderFileEditorArguments(toolCall) + ) : toolCall.function_name === 'think' && typeof getArgument(toolCall, 'thought') === 'string' ? ( +
+ {content}} + /> +
+ ) : toolCall.function_name === 'terminal' && typeof getArgument(toolCall, 'command') === 'string' ? ( + + ) : toolCall.arguments !== undefined && ( + + {JSON.stringify(toolCall.arguments, null, 2)} + + )} +
+ ))} +
+ )} + + {results.length > 0 && ( +
+
+ {toolCalls.some(toolCall => toolCall.function_name === 'file_editor') ? 'File editor output' : 'Tool output'} ({results.length}) +
+ {results.map((result, index) => ( + + ))} +
+ )} + + {!message && toolCalls.length === 0 && results.length === 0 && ( +
No content in this step.
+ )} +
+
+ ); +}; diff --git a/src/components/share/trajectory-list-items/command-observation.tsx b/src/components/share/trajectory-list-items/command-observation.tsx index 2f53d32..a97c6c7 100644 --- a/src/components/share/trajectory-list-items/command-observation.tsx +++ b/src/components/share/trajectory-list-items/command-observation.tsx @@ -1,5 +1,5 @@ -import React, { useState } from 'react'; -import { CSyntaxHighlighter } from "../../syntax-highlighter"; +import React from 'react'; +import { ExpandableContent } from "../expandable-content"; import { TrajectoryCard } from "../trajectory-card"; import { CommandObservation } from '../../../types/share'; import { ToolCallMetadataDisplay } from './tool-call-metadata'; @@ -9,21 +9,6 @@ interface CommandObservationProps { } export const CommandObservationComponent: React.FC = ({ observation }) => { - const [isExpanded, setIsExpanded] = useState(false); - - // Get the first 5 lines of content - const getPreviewContent = (content: string) => { - const lines = content.split('\n'); - if (lines.length <= 5) return content; - return lines.slice(0, 5).join('\n'); - }; - - // Check if content has more than 5 lines - const hasMoreContent = observation.content.split('\n').length > 5; - - // Get preview or full content based on expanded state - const displayContent = isExpanded ? observation.content : getPreviewContent(observation.content); - return ( = ({ > Shell Output - {displayContent} - - {hasMoreContent && !isExpanded && ( -
- -
- )} - - {isExpanded && ( -
- -
- )} +
Exit code: {String(observation.extras?.metadata?.exit_code ?? observation.extras?.exit_code ?? 'N/A')} @@ -75,4 +26,4 @@ export const CommandObservationComponent: React.FC = ({ ); -}; \ No newline at end of file +}; diff --git a/src/components/share/trajectory-list.tsx b/src/components/share/trajectory-list.tsx index 1333576..3a6101a 100644 --- a/src/components/share/trajectory-list.tsx +++ b/src/components/share/trajectory-list.tsx @@ -30,6 +30,7 @@ import { EditObservationComponent, ErrorObservationComponent } from "./trajectory-list-items"; +import { AtifStepComponent } from "./trajectory-list-items/atif-step"; import { TrajectoryCard } from "./trajectory-card"; import { TrajectoryItem } from '../../types/share'; @@ -68,7 +69,10 @@ export const TrajectoryList: React.FC = ({ trajectory }) =>
{filteredTrajectory.map((item, index) => { - if (isAgentStateChange(item)) { + const rawItem = item as unknown as Record; + if (typeof rawItem.step_id === "number" && typeof rawItem.source === "string") { + return ; + } else if (isAgentStateChange(item)) { return ; } else if (isUserMessage(item)) { return ; @@ -95,19 +99,31 @@ export const TrajectoryList: React.FC = ({ trajectory }) => } else if (isEditObservation(item)) { return ; } else { + const source = typeof rawItem.source === "string" ? rawItem.source : undefined; + const timestamp = typeof rawItem.timestamp === "string" ? rawItem.timestamp : undefined; + const message = typeof rawItem.message === "string" ? rawItem.message : undefined; + return ( - -
- - Item #{index + 1} - -
- - {JSON.stringify(item, null, 2)} - + + + {source ? `${source.charAt(0).toUpperCase()}${source.slice(1)} step` : `Item #${index + 1}`} + + + {message && ( +
+ {message} +
+ )} + + {JSON.stringify(item, null, 2)} + +
); } @@ -125,4 +141,4 @@ export const TrajectoryList: React.FC = ({ trajectory }) => ); }; -export default TrajectoryList; \ No newline at end of file +export default TrajectoryList; diff --git a/src/components/upload/UploadTrajectory.tsx b/src/components/upload/UploadTrajectory.tsx index 8870980..90bc1cb 100644 --- a/src/components/upload/UploadTrajectory.tsx +++ b/src/components/upload/UploadTrajectory.tsx @@ -44,6 +44,12 @@ export const UploadTrajectory: React.FC = ({ // Return the history array for the trajectory viewer return content.history; } + + // Check if it is an ATIF trajectory (steps array) + if (content.steps && Array.isArray(content.steps)) { + console.log('Detected ATIF trajectory format - using trajectory viewer'); + return content.steps; + } // If it's not in a recognized format, return as is and let the converter handle it console.log('Unknown format, passing to converter as-is'); @@ -217,4 +223,4 @@ export const UploadTrajectory: React.FC = ({ )}
); -}; \ No newline at end of file +}; diff --git a/src/components/upload/__tests__/UploadTrajectory.test.tsx b/src/components/upload/__tests__/UploadTrajectory.test.tsx index d95780f..e059a29 100644 --- a/src/components/upload/__tests__/UploadTrajectory.test.tsx +++ b/src/components/upload/__tests__/UploadTrajectory.test.tsx @@ -108,6 +108,44 @@ describe('UploadTrajectory', () => { }); }); + it('handles ATIF trajectories correctly', async () => { + render(); + + const steps = [ + { + step_id: 1, + timestamp: '2026-09-14T02:40:44.835462', + source: 'system', + message: 'System prompt' + }, + { + step_id: 2, + timestamp: '2026-09-14T02:40:44.835462', + source: 'user', + message: 'User request' + } + ]; + const file = new File( + [JSON.stringify({ schema_version: 'ATIF-v1.5', steps })], + 'trajectory.json', + { type: 'application/json' } + ); + + const dropzone = screen.getByText(/drag and drop a trajectory file here/i).parentElement!.parentElement!; + await act(async () => { + fireEvent.drop(dropzone, createDropEvent([file])); + }); + + await waitFor(() => { + expect(mockOnUpload).toHaveBeenCalledWith({ + content: { + fileType: 'trajectory', + trajectoryData: steps + } + }); + }); + }); + it('handles invalid JSON file', async () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {}); @@ -145,4 +183,4 @@ function createDropEvent(files: File[]) { types: ['Files'] } }; -} \ No newline at end of file +}