@machinespirits/eval 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/MobileEvalDashboard.tsx +267 -0
- package/components/comparison/DeltaAnalysisTable.tsx +137 -0
- package/components/comparison/ProfileComparisonCard.tsx +176 -0
- package/components/comparison/RecognitionABMode.tsx +385 -0
- package/components/comparison/RecognitionMetricsPanel.tsx +135 -0
- package/components/comparison/WinnerIndicator.tsx +64 -0
- package/components/comparison/index.ts +5 -0
- package/components/mobile/BottomSheet.tsx +233 -0
- package/components/mobile/DimensionBreakdown.tsx +210 -0
- package/components/mobile/DocsView.tsx +363 -0
- package/components/mobile/LogsView.tsx +481 -0
- package/components/mobile/PsychodynamicQuadrant.tsx +261 -0
- package/components/mobile/QuickTestView.tsx +1098 -0
- package/components/mobile/RecognitionTypeChart.tsx +124 -0
- package/components/mobile/RecognitionView.tsx +809 -0
- package/components/mobile/RunDetailView.tsx +261 -0
- package/components/mobile/RunHistoryView.tsx +367 -0
- package/components/mobile/ScoreRadial.tsx +211 -0
- package/components/mobile/StreamingLogPanel.tsx +230 -0
- package/components/mobile/SynthesisStrategyChart.tsx +140 -0
- package/config/interaction-eval-scenarios.yaml +832 -0
- package/config/learner-agents.yaml +248 -0
- package/docs/research/ABLATION-DIALOGUE-ROUNDS.md +52 -0
- package/docs/research/ABLATION-MODEL-SELECTION.md +53 -0
- package/docs/research/ADVANCED-EVAL-ANALYSIS.md +60 -0
- package/docs/research/ANOVA-RESULTS-2026-01-14.md +257 -0
- package/docs/research/COMPREHENSIVE-EVALUATION-PLAN.md +586 -0
- package/docs/research/COST-ANALYSIS.md +56 -0
- package/docs/research/CRITICAL-REVIEW-RECOGNITION-TUTORING.md +340 -0
- package/docs/research/DYNAMIC-VS-SCRIPTED-ANALYSIS.md +291 -0
- package/docs/research/EVAL-SYSTEM-ANALYSIS.md +306 -0
- package/docs/research/FACTORIAL-RESULTS-2026-01-14.md +301 -0
- package/docs/research/IMPLEMENTATION-PLAN-CRITIQUE-RESPONSE.md +1988 -0
- package/docs/research/LONGITUDINAL-DYADIC-EVALUATION.md +282 -0
- package/docs/research/MULTI-JUDGE-VALIDATION-2026-01-14.md +147 -0
- package/docs/research/PAPER-EXTENSION-DYADIC.md +204 -0
- package/docs/research/PAPER-UNIFIED.md +659 -0
- package/docs/research/PAPER-UNIFIED.pdf +0 -0
- package/docs/research/PROMPT-IMPROVEMENTS-2026-01-14.md +356 -0
- package/docs/research/SESSION-NOTES-2026-01-11-RECOGNITION-EVAL.md +419 -0
- package/docs/research/apa.csl +2133 -0
- package/docs/research/archive/PAPER-DRAFT-RECOGNITION-TUTORING.md +1637 -0
- package/docs/research/archive/paper-multiagent-tutor.tex +978 -0
- package/docs/research/paper-draft/full-paper.md +136 -0
- package/docs/research/paper-draft/images/pasted-image-2026-01-24T03-47-47-846Z-d76a7ae2.png +0 -0
- package/docs/research/paper-draft/references.bib +515 -0
- package/docs/research/transcript-baseline.md +139 -0
- package/docs/research/transcript-recognition-multiagent.md +187 -0
- package/hooks/useEvalData.ts +625 -0
- package/index.js +27 -0
- package/package.json +73 -0
- package/routes/evalRoutes.js +3002 -0
- package/scripts/advanced-eval-analysis.js +351 -0
- package/scripts/analyze-eval-costs.js +378 -0
- package/scripts/analyze-eval-results.js +513 -0
- package/scripts/analyze-interaction-evals.js +368 -0
- package/server-init.js +45 -0
- package/server.js +162 -0
- package/services/benchmarkService.js +1892 -0
- package/services/evaluationRunner.js +739 -0
- package/services/evaluationStore.js +1121 -0
- package/services/learnerConfigLoader.js +385 -0
- package/services/learnerTutorInteractionEngine.js +857 -0
- package/services/memory/learnerMemoryService.js +1227 -0
- package/services/memory/learnerWritingPad.js +577 -0
- package/services/memory/tutorWritingPad.js +674 -0
- package/services/promptRecommendationService.js +493 -0
- package/services/rubricEvaluator.js +826 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RunDetailView Component
|
|
3
|
+
*
|
|
4
|
+
* Detailed view of a single evaluation run, displayed in a bottom sheet.
|
|
5
|
+
* Shows stats, dimension breakdown, and individual results.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React, { useState } from 'react';
|
|
9
|
+
import type { RunDetails } from '../../hooks/useEvalData';
|
|
10
|
+
import type { EvalQuickTestResult } from '../../types';
|
|
11
|
+
import { DimensionBreakdown } from './DimensionBreakdown';
|
|
12
|
+
import { ScoreRadial } from './ScoreRadial';
|
|
13
|
+
import haptics from '../../utils/haptics';
|
|
14
|
+
|
|
15
|
+
interface RunDetailViewProps {
|
|
16
|
+
details: RunDetails;
|
|
17
|
+
onViewDialogue: (logDate: string) => void;
|
|
18
|
+
onClose: () => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Format date for display
|
|
22
|
+
function formatDate(dateStr: string): string {
|
|
23
|
+
const date = new Date(dateStr);
|
|
24
|
+
return date.toLocaleDateString(undefined, {
|
|
25
|
+
weekday: 'short',
|
|
26
|
+
month: 'short',
|
|
27
|
+
day: 'numeric',
|
|
28
|
+
hour: 'numeric',
|
|
29
|
+
minute: '2-digit'
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Calculate average score from results
|
|
34
|
+
function getAverageScore(results: EvalQuickTestResult[]): number | null {
|
|
35
|
+
const scores = results.filter(r => r.overallScore !== null).map(r => r.overallScore as number);
|
|
36
|
+
if (scores.length === 0) return null;
|
|
37
|
+
return scores.reduce((a, b) => a + b, 0) / scores.length;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Count passed tests
|
|
41
|
+
function getPassedCount(results: EvalQuickTestResult[]): number {
|
|
42
|
+
return results.filter(r => r.passed).length;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const RunDetailView: React.FC<RunDetailViewProps> = ({
|
|
46
|
+
details,
|
|
47
|
+
onViewDialogue,
|
|
48
|
+
onClose
|
|
49
|
+
}) => {
|
|
50
|
+
const [selectedTab, setSelectedTab] = useState<'overview' | 'results'>('overview');
|
|
51
|
+
const [expandedResult, setExpandedResult] = useState<string | null>(null);
|
|
52
|
+
|
|
53
|
+
const { run, stats, results } = details;
|
|
54
|
+
const avgScore = getAverageScore(results);
|
|
55
|
+
const passedCount = getPassedCount(results);
|
|
56
|
+
const passRate = results.length > 0 ? (passedCount / results.length) * 100 : 0;
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div className="pb-4">
|
|
60
|
+
{/* Run Header */}
|
|
61
|
+
<div className="px-4 py-3 border-b border-gray-800">
|
|
62
|
+
<p className="text-sm text-white line-clamp-2">{run.description || run.id}</p>
|
|
63
|
+
<p className="text-xs text-gray-500 mt-1">{formatDate(run.createdAt)}</p>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
{/* Tab Switcher */}
|
|
67
|
+
<div className="flex border-b border-gray-800">
|
|
68
|
+
<button
|
|
69
|
+
type="button"
|
|
70
|
+
onClick={() => {
|
|
71
|
+
haptics.light();
|
|
72
|
+
setSelectedTab('overview');
|
|
73
|
+
}}
|
|
74
|
+
className={`flex-1 py-3 text-sm font-medium transition-colors
|
|
75
|
+
${selectedTab === 'overview'
|
|
76
|
+
? 'text-[#E63946] border-b-2 border-[#E63946]'
|
|
77
|
+
: 'text-gray-400'
|
|
78
|
+
}`}
|
|
79
|
+
>
|
|
80
|
+
Overview
|
|
81
|
+
</button>
|
|
82
|
+
<button
|
|
83
|
+
type="button"
|
|
84
|
+
onClick={() => {
|
|
85
|
+
haptics.light();
|
|
86
|
+
setSelectedTab('results');
|
|
87
|
+
}}
|
|
88
|
+
className={`flex-1 py-3 text-sm font-medium transition-colors
|
|
89
|
+
${selectedTab === 'results'
|
|
90
|
+
? 'text-[#E63946] border-b-2 border-[#E63946]'
|
|
91
|
+
: 'text-gray-400'
|
|
92
|
+
}`}
|
|
93
|
+
>
|
|
94
|
+
Results ({results.length})
|
|
95
|
+
</button>
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
{/* Overview Tab */}
|
|
99
|
+
{selectedTab === 'overview' && (
|
|
100
|
+
<div className="p-4 space-y-6">
|
|
101
|
+
{/* Score Summary */}
|
|
102
|
+
<div className="flex items-center justify-center">
|
|
103
|
+
<div className="flex flex-col items-center">
|
|
104
|
+
<ScoreRadial score={avgScore} passed={passRate >= 70} size={120} />
|
|
105
|
+
<p className="text-xs text-gray-400 mt-2">
|
|
106
|
+
{passedCount}/{results.length} passed ({passRate.toFixed(0)}%)
|
|
107
|
+
</p>
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
{/* Stats Grid */}
|
|
112
|
+
<div className="grid grid-cols-2 gap-3">
|
|
113
|
+
<div className="bg-gray-800/50 rounded-lg p-3">
|
|
114
|
+
<div className="text-xl font-semibold text-white">{run.totalTests || results.length}</div>
|
|
115
|
+
<div className="text-xs text-gray-400">Total Tests</div>
|
|
116
|
+
</div>
|
|
117
|
+
<div className="bg-gray-800/50 rounded-lg p-3">
|
|
118
|
+
<div className="text-xl font-semibold text-white">{run.totalScenarios || '-'}</div>
|
|
119
|
+
<div className="text-xs text-gray-400">Scenarios</div>
|
|
120
|
+
</div>
|
|
121
|
+
<div className="bg-gray-800/50 rounded-lg p-3">
|
|
122
|
+
<div className="text-xl font-semibold text-white">{run.totalConfigurations || '-'}</div>
|
|
123
|
+
<div className="text-xs text-gray-400">Configurations</div>
|
|
124
|
+
</div>
|
|
125
|
+
<div className="bg-gray-800/50 rounded-lg p-3">
|
|
126
|
+
<div className="text-xl font-semibold text-white">
|
|
127
|
+
{stats.length > 0 && stats[0].avgLatencyMs
|
|
128
|
+
? `${(stats[0].avgLatencyMs / 1000).toFixed(1)}s`
|
|
129
|
+
: '-'}
|
|
130
|
+
</div>
|
|
131
|
+
<div className="text-xs text-gray-400">Avg Latency</div>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
{/* Profile Stats */}
|
|
136
|
+
{stats.length > 0 && (
|
|
137
|
+
<div className="space-y-3">
|
|
138
|
+
<h4 className="text-sm font-medium text-gray-300">Profile Performance</h4>
|
|
139
|
+
{stats.map((stat, i) => (
|
|
140
|
+
<div key={i} className="bg-gray-800/30 rounded-lg p-3">
|
|
141
|
+
<div className="flex items-center justify-between mb-2">
|
|
142
|
+
<span className="text-sm text-white">{stat.model}</span>
|
|
143
|
+
<span className={`text-sm font-medium ${stat.avgScore && stat.avgScore >= 70 ? 'text-green-400' : 'text-red-400'}`}>
|
|
144
|
+
{stat.avgScore?.toFixed(1) || 'N/A'}
|
|
145
|
+
</span>
|
|
146
|
+
</div>
|
|
147
|
+
<div className="text-xs text-gray-500">
|
|
148
|
+
{stat.successfulTests}/{stat.totalTests} passed • {(stat.successRate * 100).toFixed(0)}% success
|
|
149
|
+
</div>
|
|
150
|
+
{stat.dimensions && (
|
|
151
|
+
<div className="mt-3">
|
|
152
|
+
<DimensionBreakdown scores={stat.dimensions} compact />
|
|
153
|
+
</div>
|
|
154
|
+
)}
|
|
155
|
+
</div>
|
|
156
|
+
))}
|
|
157
|
+
</div>
|
|
158
|
+
)}
|
|
159
|
+
</div>
|
|
160
|
+
)}
|
|
161
|
+
|
|
162
|
+
{/* Results Tab */}
|
|
163
|
+
{selectedTab === 'results' && (
|
|
164
|
+
<div className="divide-y divide-gray-800">
|
|
165
|
+
{results.map((result, i) => {
|
|
166
|
+
const isExpanded = expandedResult === `${result.scenarioId}-${i}`;
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
<div key={`${result.scenarioId}-${i}`}>
|
|
170
|
+
<button
|
|
171
|
+
type="button"
|
|
172
|
+
onClick={() => {
|
|
173
|
+
haptics.light();
|
|
174
|
+
setExpandedResult(isExpanded ? null : `${result.scenarioId}-${i}`);
|
|
175
|
+
}}
|
|
176
|
+
className="w-full p-4 text-left active:bg-gray-800/30 transition-colors"
|
|
177
|
+
>
|
|
178
|
+
<div className="flex items-start justify-between gap-3">
|
|
179
|
+
<div className="flex-1 min-w-0">
|
|
180
|
+
<div className="flex items-center gap-2 mb-1">
|
|
181
|
+
<span className={`w-2 h-2 rounded-full ${result.passed ? 'bg-green-400' : 'bg-red-400'}`} />
|
|
182
|
+
<span className="text-sm text-white line-clamp-1">{result.scenarioName}</span>
|
|
183
|
+
</div>
|
|
184
|
+
<p className="text-xs text-gray-500">{result.profile}</p>
|
|
185
|
+
</div>
|
|
186
|
+
<div className="flex items-center gap-2">
|
|
187
|
+
<span className={`text-lg font-semibold ${result.passed ? 'text-green-400' : 'text-red-400'}`}>
|
|
188
|
+
{result.overallScore?.toFixed(0) || '-'}
|
|
189
|
+
</span>
|
|
190
|
+
<svg
|
|
191
|
+
className={`w-4 h-4 text-gray-500 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
|
|
192
|
+
fill="none"
|
|
193
|
+
viewBox="0 0 24 24"
|
|
194
|
+
stroke="currentColor"
|
|
195
|
+
>
|
|
196
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
197
|
+
</svg>
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
200
|
+
</button>
|
|
201
|
+
|
|
202
|
+
{/* Expanded Details */}
|
|
203
|
+
{isExpanded && (
|
|
204
|
+
<div className="px-4 pb-4 space-y-4 bg-gray-900/30">
|
|
205
|
+
{/* Metrics */}
|
|
206
|
+
<div className="grid grid-cols-3 gap-2">
|
|
207
|
+
<div className="bg-gray-800/50 rounded p-2 text-center">
|
|
208
|
+
<div className="text-sm font-medium text-white">
|
|
209
|
+
{result.latencyMs ? `${(result.latencyMs / 1000).toFixed(1)}s` : '-'}
|
|
210
|
+
</div>
|
|
211
|
+
<div className="text-[10px] text-gray-500">Latency</div>
|
|
212
|
+
</div>
|
|
213
|
+
<div className="bg-gray-800/50 rounded p-2 text-center">
|
|
214
|
+
<div className="text-sm font-medium text-white">
|
|
215
|
+
{result.totalTokens?.toLocaleString() || '-'}
|
|
216
|
+
</div>
|
|
217
|
+
<div className="text-[10px] text-gray-500">Tokens</div>
|
|
218
|
+
</div>
|
|
219
|
+
<div className="bg-gray-800/50 rounded p-2 text-center">
|
|
220
|
+
<div className="text-sm font-medium text-white">
|
|
221
|
+
{result.dialogueRounds || '-'}
|
|
222
|
+
</div>
|
|
223
|
+
<div className="text-[10px] text-gray-500">Rounds</div>
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
|
|
227
|
+
{/* Dimension Scores */}
|
|
228
|
+
{result.scores && (
|
|
229
|
+
<DimensionBreakdown scores={result.scores} compact />
|
|
230
|
+
)}
|
|
231
|
+
|
|
232
|
+
{/* Validation */}
|
|
233
|
+
{result.validation && (
|
|
234
|
+
<div className="flex gap-4 text-xs">
|
|
235
|
+
<span className={result.validation.passesRequired ? 'text-green-400' : 'text-red-400'}>
|
|
236
|
+
{result.validation.passesRequired ? '✓' : '✗'} Required
|
|
237
|
+
</span>
|
|
238
|
+
<span className={result.validation.passesForbidden ? 'text-green-400' : 'text-red-400'}>
|
|
239
|
+
{result.validation.passesForbidden ? '✓' : '✗'} Forbidden
|
|
240
|
+
</span>
|
|
241
|
+
</div>
|
|
242
|
+
)}
|
|
243
|
+
|
|
244
|
+
{/* Suggestions count */}
|
|
245
|
+
{result.suggestions && result.suggestions.length > 0 && (
|
|
246
|
+
<p className="text-xs text-gray-500">
|
|
247
|
+
{result.suggestions.length} suggestion{result.suggestions.length !== 1 ? 's' : ''} generated
|
|
248
|
+
</p>
|
|
249
|
+
)}
|
|
250
|
+
</div>
|
|
251
|
+
)}
|
|
252
|
+
</div>
|
|
253
|
+
);
|
|
254
|
+
})}
|
|
255
|
+
</div>
|
|
256
|
+
)}
|
|
257
|
+
</div>
|
|
258
|
+
);
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
export default RunDetailView;
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RunHistoryView Component
|
|
3
|
+
*
|
|
4
|
+
* Displays list of past evaluation runs with pull-to-refresh support.
|
|
5
|
+
* Tapping a run opens the detail view in a bottom sheet.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
|
9
|
+
import type { EvalRun } from '../../types';
|
|
10
|
+
import haptics from '../../utils/haptics';
|
|
11
|
+
|
|
12
|
+
interface RunHistoryViewProps {
|
|
13
|
+
runs: EvalRun[];
|
|
14
|
+
isLoading: boolean;
|
|
15
|
+
onSelectRun: (runId: string) => void;
|
|
16
|
+
onRefresh: () => Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Format relative time
|
|
20
|
+
function formatRelativeTime(dateStr: string): string {
|
|
21
|
+
const date = new Date(dateStr);
|
|
22
|
+
const now = new Date();
|
|
23
|
+
const diffMs = now.getTime() - date.getTime();
|
|
24
|
+
const diffMins = Math.floor(diffMs / 60000);
|
|
25
|
+
const diffHours = Math.floor(diffMs / 3600000);
|
|
26
|
+
const diffDays = Math.floor(diffMs / 86400000);
|
|
27
|
+
|
|
28
|
+
if (diffMins < 1) return 'Just now';
|
|
29
|
+
if (diffMins < 60) return `${diffMins}m ago`;
|
|
30
|
+
if (diffHours < 24) return `${diffHours}h ago`;
|
|
31
|
+
if (diffDays < 7) return `${diffDays}d ago`;
|
|
32
|
+
|
|
33
|
+
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Get run type badge color - Premium gradient badges
|
|
37
|
+
function getRunTypeBadge(runType?: string): { bg: string; text: string; glow?: string } {
|
|
38
|
+
switch (runType) {
|
|
39
|
+
case 'quick':
|
|
40
|
+
return { bg: 'bg-blue-500/20 border border-blue-500/30', text: 'text-blue-400', glow: 'shadow-blue-500/20' };
|
|
41
|
+
case 'batch':
|
|
42
|
+
return { bg: 'bg-green-500/20 border border-green-500/30', text: 'text-green-400', glow: 'shadow-green-500/20' };
|
|
43
|
+
case 'matrix':
|
|
44
|
+
case 'compare':
|
|
45
|
+
return { bg: 'bg-purple-500/20 border border-purple-500/30', text: 'text-purple-400', glow: 'shadow-purple-500/20' };
|
|
46
|
+
case 'interaction':
|
|
47
|
+
return { bg: 'bg-pink-500/20 border border-pink-500/30', text: 'text-pink-400', glow: 'shadow-pink-500/20' };
|
|
48
|
+
default:
|
|
49
|
+
return { bg: 'bg-gray-500/20 border border-gray-500/30', text: 'text-gray-400' };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Available run type filters with descriptions
|
|
54
|
+
// Database values → User-friendly labels
|
|
55
|
+
const RUN_TYPE_FILTERS = [
|
|
56
|
+
{ value: null, label: 'All', description: null },
|
|
57
|
+
{ value: 'quick', label: 'Quick', description: 'Single scenario test with one AI tutor profile' },
|
|
58
|
+
{ value: 'batch', label: 'Batch', description: 'Multiple scenarios run in sequence' },
|
|
59
|
+
{ value: 'compare', label: 'Compare', description: 'Profile × Scenario grid comparison with rubric scoring' },
|
|
60
|
+
{ value: 'interaction', label: 'Interact', description: 'Simulated learner-tutor dialogue with AI agents on both sides' },
|
|
61
|
+
] as const;
|
|
62
|
+
|
|
63
|
+
type RunTypeFilter = typeof RUN_TYPE_FILTERS[number]['value'];
|
|
64
|
+
|
|
65
|
+
export const RunHistoryView: React.FC<RunHistoryViewProps> = ({
|
|
66
|
+
runs,
|
|
67
|
+
isLoading,
|
|
68
|
+
onSelectRun,
|
|
69
|
+
onRefresh
|
|
70
|
+
}) => {
|
|
71
|
+
const [pullDistance, setPullDistance] = useState(0);
|
|
72
|
+
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
73
|
+
const [typeFilter, setTypeFilter] = useState<string | null>(null);
|
|
74
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
75
|
+
const touchStartY = useRef<number | null>(null);
|
|
76
|
+
const PULL_THRESHOLD = 80;
|
|
77
|
+
|
|
78
|
+
// Filter runs by type (matrix and compare both map to 'compare')
|
|
79
|
+
const filteredRuns = typeFilter
|
|
80
|
+
? runs.filter(run => {
|
|
81
|
+
const rt = run.runType;
|
|
82
|
+
if (typeFilter === 'compare') {
|
|
83
|
+
return rt === 'compare' || rt === 'matrix';
|
|
84
|
+
}
|
|
85
|
+
return rt === typeFilter;
|
|
86
|
+
})
|
|
87
|
+
: runs;
|
|
88
|
+
|
|
89
|
+
// Load runs on mount
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (runs.length === 0) {
|
|
92
|
+
onRefresh();
|
|
93
|
+
}
|
|
94
|
+
}, [runs.length, onRefresh]);
|
|
95
|
+
|
|
96
|
+
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
|
97
|
+
if (containerRef.current?.scrollTop === 0) {
|
|
98
|
+
touchStartY.current = e.touches[0].clientY;
|
|
99
|
+
}
|
|
100
|
+
}, []);
|
|
101
|
+
|
|
102
|
+
const handleTouchMove = useCallback((e: React.TouchEvent) => {
|
|
103
|
+
if (touchStartY.current === null) return;
|
|
104
|
+
if (containerRef.current && containerRef.current.scrollTop > 0) {
|
|
105
|
+
touchStartY.current = null;
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const currentY = e.touches[0].clientY;
|
|
110
|
+
const distance = currentY - touchStartY.current;
|
|
111
|
+
|
|
112
|
+
if (distance > 0 && distance < PULL_THRESHOLD * 1.5) {
|
|
113
|
+
e.preventDefault();
|
|
114
|
+
setPullDistance(distance);
|
|
115
|
+
}
|
|
116
|
+
}, []);
|
|
117
|
+
|
|
118
|
+
const handleTouchEnd = useCallback(async () => {
|
|
119
|
+
if (pullDistance >= PULL_THRESHOLD && !isRefreshing) {
|
|
120
|
+
haptics.medium();
|
|
121
|
+
setIsRefreshing(true);
|
|
122
|
+
await onRefresh();
|
|
123
|
+
setIsRefreshing(false);
|
|
124
|
+
}
|
|
125
|
+
setPullDistance(0);
|
|
126
|
+
touchStartY.current = null;
|
|
127
|
+
}, [pullDistance, isRefreshing, onRefresh]);
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<div
|
|
131
|
+
ref={containerRef}
|
|
132
|
+
className="h-full overflow-y-auto"
|
|
133
|
+
onTouchStart={handleTouchStart}
|
|
134
|
+
onTouchMove={handleTouchMove}
|
|
135
|
+
onTouchEnd={handleTouchEnd}
|
|
136
|
+
>
|
|
137
|
+
{/* Pull-to-refresh indicator - Premium styling */}
|
|
138
|
+
<div
|
|
139
|
+
className="flex items-center justify-center transition-all duration-300 overflow-hidden"
|
|
140
|
+
style={{
|
|
141
|
+
height: isRefreshing ? PULL_THRESHOLD : pullDistance,
|
|
142
|
+
opacity: isRefreshing ? 1 : Math.min(1, pullDistance / PULL_THRESHOLD)
|
|
143
|
+
}}
|
|
144
|
+
>
|
|
145
|
+
<div className="relative">
|
|
146
|
+
{/* Glow ring */}
|
|
147
|
+
<div className={`absolute inset-0 rounded-full bg-[#E63946]/20 ${isRefreshing ? 'animate-ping' : ''}`} />
|
|
148
|
+
<div className="relative w-10 h-10 rounded-full bg-gray-900/80 backdrop-blur-sm border border-white/10
|
|
149
|
+
flex items-center justify-center">
|
|
150
|
+
<svg
|
|
151
|
+
className={`w-5 h-5 text-[#E63946] ${isRefreshing ? 'animate-spin' : ''}`}
|
|
152
|
+
style={{
|
|
153
|
+
transform: isRefreshing ? undefined : `rotate(${(pullDistance / PULL_THRESHOLD) * 360}deg)`
|
|
154
|
+
}}
|
|
155
|
+
fill="none"
|
|
156
|
+
viewBox="0 0 24 24"
|
|
157
|
+
stroke="currentColor"
|
|
158
|
+
>
|
|
159
|
+
<path
|
|
160
|
+
strokeLinecap="round"
|
|
161
|
+
strokeLinejoin="round"
|
|
162
|
+
strokeWidth={2}
|
|
163
|
+
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
|
164
|
+
/>
|
|
165
|
+
</svg>
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
|
|
170
|
+
{/* Type filter bar */}
|
|
171
|
+
<div className="px-3 pt-2 pb-1 flex gap-2 overflow-x-auto scrollbar-hide">
|
|
172
|
+
{RUN_TYPE_FILTERS.map((filter) => {
|
|
173
|
+
const isActive = typeFilter === filter.value;
|
|
174
|
+
const filterBadge = filter.value ? getRunTypeBadge(filter.value) : null;
|
|
175
|
+
const count = filter.value
|
|
176
|
+
? runs.filter(r => {
|
|
177
|
+
const rt = r.runType;
|
|
178
|
+
if (filter.value === 'compare') {
|
|
179
|
+
return rt === 'compare' || rt === 'matrix';
|
|
180
|
+
}
|
|
181
|
+
return rt === filter.value;
|
|
182
|
+
}).length
|
|
183
|
+
: runs.length;
|
|
184
|
+
|
|
185
|
+
return (
|
|
186
|
+
<button
|
|
187
|
+
key={filter.label}
|
|
188
|
+
type="button"
|
|
189
|
+
onClick={() => {
|
|
190
|
+
haptics.light();
|
|
191
|
+
setTypeFilter(filter.value);
|
|
192
|
+
}}
|
|
193
|
+
className={`flex-shrink-0 flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all duration-150
|
|
194
|
+
active:scale-[0.97] ${
|
|
195
|
+
isActive
|
|
196
|
+
? filterBadge
|
|
197
|
+
? `${filterBadge.bg} ${filterBadge.text}`
|
|
198
|
+
: 'bg-white/10 border border-white/20 text-white'
|
|
199
|
+
: 'bg-gray-900/40 border border-white/5 text-gray-500 hover:text-gray-400'
|
|
200
|
+
}`}
|
|
201
|
+
>
|
|
202
|
+
{filter.label}
|
|
203
|
+
<span className="text-[10px] opacity-60">{count}</span>
|
|
204
|
+
</button>
|
|
205
|
+
);
|
|
206
|
+
})}
|
|
207
|
+
</div>
|
|
208
|
+
|
|
209
|
+
{/* Filter description tooltip */}
|
|
210
|
+
{typeFilter && (
|
|
211
|
+
<div className="px-3 pb-2">
|
|
212
|
+
<div className="px-3 py-2 bg-gray-800/40 rounded-lg border border-white/5">
|
|
213
|
+
<span className="text-xs text-gray-400">
|
|
214
|
+
{RUN_TYPE_FILTERS.find(f => f.value === typeFilter)?.description}
|
|
215
|
+
</span>
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
)}
|
|
219
|
+
|
|
220
|
+
{/* Loading state - Premium animated */}
|
|
221
|
+
{isLoading && runs.length === 0 && (
|
|
222
|
+
<div className="flex items-center justify-center h-64">
|
|
223
|
+
<div className="flex flex-col items-center gap-4">
|
|
224
|
+
<div className="relative">
|
|
225
|
+
<div className="absolute inset-0 rounded-full bg-gradient-to-r from-[#E63946]/20 to-[#d62839]/20 animate-spin"
|
|
226
|
+
style={{ animationDuration: '3s' }} />
|
|
227
|
+
<div className="relative w-16 h-16 rounded-full bg-gray-900/80 backdrop-blur-sm border border-white/10
|
|
228
|
+
flex items-center justify-center">
|
|
229
|
+
<svg className="w-8 h-8 text-[#E63946] animate-spin" fill="none" viewBox="0 0 24 24">
|
|
230
|
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
231
|
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
232
|
+
</svg>
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
<span className="text-sm text-gray-400 font-medium">Loading runs...</span>
|
|
236
|
+
</div>
|
|
237
|
+
</div>
|
|
238
|
+
)}
|
|
239
|
+
|
|
240
|
+
{/* Empty state - Enhanced with animation */}
|
|
241
|
+
{!isLoading && filteredRuns.length === 0 && (
|
|
242
|
+
<div className="flex flex-col items-center justify-center h-64 text-gray-500 px-4">
|
|
243
|
+
<div className="relative mb-6">
|
|
244
|
+
<div className="absolute inset-0 rounded-full bg-gradient-to-r from-gray-600/20 via-transparent to-gray-600/20 animate-spin"
|
|
245
|
+
style={{ animationDuration: '8s' }} />
|
|
246
|
+
<div className="relative w-20 h-20 rounded-full bg-gray-900/50 backdrop-blur-sm border border-white/5
|
|
247
|
+
flex items-center justify-center">
|
|
248
|
+
<svg className="w-10 h-10 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
249
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
|
250
|
+
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
251
|
+
</svg>
|
|
252
|
+
</div>
|
|
253
|
+
</div>
|
|
254
|
+
<p className="text-sm font-medium text-gray-400">
|
|
255
|
+
{typeFilter ? `No ${typeFilter} runs found` : 'No evaluation runs yet'}
|
|
256
|
+
</p>
|
|
257
|
+
<p className="text-xs text-gray-600 mt-1">
|
|
258
|
+
{typeFilter ? 'Try a different filter or run a test' : 'Run a test to see results here'}
|
|
259
|
+
</p>
|
|
260
|
+
</div>
|
|
261
|
+
)}
|
|
262
|
+
|
|
263
|
+
{/* Run list - Glass cards */}
|
|
264
|
+
{filteredRuns.length > 0 && (
|
|
265
|
+
<div className="p-3 space-y-2">
|
|
266
|
+
{filteredRuns.map((run) => {
|
|
267
|
+
const typeBadge = getRunTypeBadge(run.runType);
|
|
268
|
+
|
|
269
|
+
return (
|
|
270
|
+
<button
|
|
271
|
+
key={run.id}
|
|
272
|
+
type="button"
|
|
273
|
+
onClick={() => {
|
|
274
|
+
haptics.light();
|
|
275
|
+
onSelectRun(run.id);
|
|
276
|
+
}}
|
|
277
|
+
className="w-full p-4 text-left bg-gray-900/60 backdrop-blur-sm border border-white/5
|
|
278
|
+
rounded-xl active:scale-[0.99] active:bg-gray-800/80 transition-all duration-150"
|
|
279
|
+
>
|
|
280
|
+
<div className="flex items-start justify-between gap-3">
|
|
281
|
+
<div className="flex-1 min-w-0">
|
|
282
|
+
{/* Run type and status */}
|
|
283
|
+
<div className="flex items-center gap-2 mb-2">
|
|
284
|
+
{run.runType && (
|
|
285
|
+
<span className={`text-[10px] px-2.5 py-1 rounded-lg font-semibold uppercase tracking-wide
|
|
286
|
+
${typeBadge.bg} ${typeBadge.text}`}>
|
|
287
|
+
{run.runType}
|
|
288
|
+
</span>
|
|
289
|
+
)}
|
|
290
|
+
{run.status === 'running' && (
|
|
291
|
+
<span className="flex items-center gap-1.5 text-[10px] text-green-400 font-medium">
|
|
292
|
+
<span className="relative flex h-2 w-2">
|
|
293
|
+
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
|
|
294
|
+
<span className="relative inline-flex rounded-full h-2 w-2 bg-green-500" />
|
|
295
|
+
</span>
|
|
296
|
+
Running
|
|
297
|
+
</span>
|
|
298
|
+
)}
|
|
299
|
+
</div>
|
|
300
|
+
|
|
301
|
+
{/* Description */}
|
|
302
|
+
<p className="text-sm font-medium text-white line-clamp-1 mb-2">
|
|
303
|
+
{run.description || run.id}
|
|
304
|
+
</p>
|
|
305
|
+
|
|
306
|
+
{/* Profiles - Glass pills */}
|
|
307
|
+
{run.profiles && run.profiles.length > 0 && (
|
|
308
|
+
<div className="flex flex-wrap gap-1.5 mb-2">
|
|
309
|
+
{run.profiles.slice(0, 3).map((profile) => (
|
|
310
|
+
<span
|
|
311
|
+
key={profile}
|
|
312
|
+
className="text-[10px] px-2 py-0.5 bg-white/5 border border-white/10
|
|
313
|
+
text-gray-400 rounded-full"
|
|
314
|
+
>
|
|
315
|
+
{profile}
|
|
316
|
+
</span>
|
|
317
|
+
))}
|
|
318
|
+
{run.profiles.length > 3 && (
|
|
319
|
+
<span className="text-[10px] text-gray-500 px-1">
|
|
320
|
+
+{run.profiles.length - 3}
|
|
321
|
+
</span>
|
|
322
|
+
)}
|
|
323
|
+
</div>
|
|
324
|
+
)}
|
|
325
|
+
|
|
326
|
+
{/* Stats */}
|
|
327
|
+
<div className="flex items-center gap-3 text-xs text-gray-500">
|
|
328
|
+
{run.totalTests !== undefined && (
|
|
329
|
+
<span className="flex items-center gap-1">
|
|
330
|
+
<span className="w-1 h-1 rounded-full bg-gray-600" />
|
|
331
|
+
{run.totalTests} tests
|
|
332
|
+
</span>
|
|
333
|
+
)}
|
|
334
|
+
{run.totalScenarios !== undefined && (
|
|
335
|
+
<span className="flex items-center gap-1">
|
|
336
|
+
<span className="w-1 h-1 rounded-full bg-gray-600" />
|
|
337
|
+
{run.totalScenarios} scenarios
|
|
338
|
+
</span>
|
|
339
|
+
)}
|
|
340
|
+
</div>
|
|
341
|
+
</div>
|
|
342
|
+
|
|
343
|
+
{/* Right side - time and chevron */}
|
|
344
|
+
<div className="flex flex-col items-end gap-2 flex-shrink-0">
|
|
345
|
+
<span className="text-xs text-gray-500 font-medium">
|
|
346
|
+
{formatRelativeTime(run.createdAt)}
|
|
347
|
+
</span>
|
|
348
|
+
<div className="w-8 h-8 rounded-full bg-white/5 flex items-center justify-center">
|
|
349
|
+
<svg className="w-4 h-4 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
350
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
351
|
+
</svg>
|
|
352
|
+
</div>
|
|
353
|
+
</div>
|
|
354
|
+
</div>
|
|
355
|
+
</button>
|
|
356
|
+
);
|
|
357
|
+
})}
|
|
358
|
+
</div>
|
|
359
|
+
)}
|
|
360
|
+
|
|
361
|
+
{/* Bottom padding for tab bar */}
|
|
362
|
+
<div className="h-4" />
|
|
363
|
+
</div>
|
|
364
|
+
);
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
export default RunHistoryView;
|