principles-disciple 1.12.0 → 1.14.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.
@@ -1,19 +1,91 @@
1
- import React, { useEffect, useState } from 'react';
2
- import { ChevronLeft } from 'lucide-react';
1
+ import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react';
2
+ import { ChevronLeft, Search, ArrowUpDown, X, Columns, Wrench, Zap, ClipboardList, Loader2 } from 'lucide-react';
3
3
  import { api } from '../api';
4
- import type { ThinkingOverviewResponse, ThinkingModelDetailResponse } from '../types';
5
- import { EmptyState } from '../charts';
4
+ import type { ThinkingOverviewResponse, ThinkingModelDetailResponse, ThinkingModelSummary } from '../types';
5
+ import { EmptyState, LineChart, StatusBadge, CollapsiblePanel, Sparkline, MiniBarChart } from '../charts';
6
6
  import { useI18n } from '../i18n/ui';
7
7
  import { formatPercent, formatDate } from '../utils/format';
8
8
  import { Loading, ErrorState } from '../components';
9
9
 
10
+ // ---------------------------------------------------------------------------
11
+ // Design tokens (mirrors CSS custom properties for inline fallback)
12
+ // ---------------------------------------------------------------------------
13
+
14
+ const TEXT = {
15
+ xs: '0.65rem',
16
+ sm: '0.7rem',
17
+ base: '0.75rem',
18
+ lg: '0.8rem',
19
+ xl: '0.85rem',
20
+ } as const;
21
+
22
+ const SPACE = {
23
+ 1: 'var(--space-1, 4px)',
24
+ 2: 'var(--space-2, 8px)',
25
+ 3: 'var(--space-3, 12px)',
26
+ 4: 'var(--space-4, 16px)',
27
+ 5: 'var(--space-5, 24px)',
28
+ 6: 'var(--space-6, 32px)',
29
+ } as const;
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Recommendation badge helper
33
+ // ---------------------------------------------------------------------------
34
+
35
+ type BadgeVariant = 'success' | 'warning' | 'neutral';
36
+
37
+ const REC_BADGE: Record<string, { variant: BadgeVariant; label: (t: (k: string) => string) => string }> = {
38
+ reinforce: { variant: 'success', label: (t) => t('thinkingModels.reinforce') },
39
+ rework: { variant: 'warning', label: (t) => t('thinkingModels.rework') },
40
+ archive: { variant: 'neutral', label: (t) => t('thinkingModels.archive') },
41
+ };
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // ThinkingModelsPage — Redesigned Layout
45
+ // ---------------------------------------------------------------------------
46
+
10
47
  export function ThinkingModelsPage() {
11
48
  const { t } = useI18n();
12
49
  const [data, setData] = useState<ThinkingOverviewResponse | null>(null);
13
50
  const [detail, setDetail] = useState<ThinkingModelDetailResponse | null>(null);
14
51
  const [selectedModel, setSelectedModel] = useState('');
52
+ const [isLoadingDetail, setIsLoadingDetail] = useState(false);
15
53
  const [error, setError] = useState('');
16
54
 
55
+ // Comparison mode state
56
+ const [selectedForCompare, setSelectedForCompare] = useState<string[]>([]);
57
+ const [comparisonDetails, setComparisonDetails] = useState<Map<string, ThinkingModelDetailResponse>>(new Map());
58
+ const [isComparing, setIsComparing] = useState(false);
59
+ const [comparisonLoadingModels, setComparisonLoadingModels] = useState<Set<string>>(new Set());
60
+
61
+ // Filters
62
+ const [recFilter, setRecFilter] = useState('all');
63
+ const [search, setSearch] = useState('');
64
+ const [sortBy, setSortBy] = useState<'hits' | 'successRate' | 'name'>('hits');
65
+
66
+ // Debounced search
67
+ const searchTimer = useRef<ReturnType<typeof setTimeout>>();
68
+ const [debouncedSearch, setDebouncedSearch] = useState('');
69
+
70
+ useEffect(() => {
71
+ if (searchTimer.current) clearTimeout(searchTimer.current);
72
+ searchTimer.current = setTimeout(() => setDebouncedSearch(search), 200);
73
+ return () => { if (searchTimer.current) clearTimeout(searchTimer.current); };
74
+ }, [search]);
75
+
76
+ // Cleanup timer on unmount
77
+ useEffect(() => {
78
+ return () => { if (searchTimer.current) clearTimeout(searchTimer.current); };
79
+ }, []);
80
+
81
+ // Cache for detail lookups during comparison
82
+ const detailCache = useMemo(() => {
83
+ const cache = new Map<string, ThinkingModelDetailResponse>();
84
+ if (detail) cache.set(selectedModel, detail);
85
+ comparisonDetails.forEach((d, id) => cache.set(id, d));
86
+ return cache;
87
+ }, [detail, selectedModel, comparisonDetails]);
88
+
17
89
  useEffect(() => {
18
90
  api.getThinkingOverview().then((value) => {
19
91
  setData(value);
@@ -23,17 +95,109 @@ export function ThinkingModelsPage() {
23
95
 
24
96
  useEffect(() => {
25
97
  if (!selectedModel) return;
26
- api.getThinkingModelDetail(selectedModel).then(setDetail).catch((err) => setError(String(err)));
98
+ setIsLoadingDetail(true);
99
+ api.getThinkingModelDetail(selectedModel)
100
+ .then((d) => { setDetail(d); setIsLoadingDetail(false); })
101
+ .catch((err) => { setError(String(err)); setIsLoadingDetail(false); });
27
102
  }, [selectedModel]);
28
103
 
104
+ // Comparison mode handlers
105
+ const toggleCompareSelection = useCallback((modelId: string) => {
106
+ setSelectedForCompare(prev => {
107
+ if (prev.includes(modelId)) {
108
+ return prev.filter(id => id !== modelId);
109
+ }
110
+ if (prev.length >= 4) return prev; // Max 4 for layout
111
+ return [...prev, modelId];
112
+ });
113
+ }, []);
114
+
115
+ const startComparison = useCallback(async () => {
116
+ if (selectedForCompare.length < 2) return;
117
+ setIsComparing(true);
118
+ const newDetails = new Map(comparisonDetails);
119
+ const pending = selectedForCompare.filter(id => !newDetails.has(id));
120
+
121
+ // Track per-model loading state
122
+ setComparisonLoadingModels(new Set(pending));
123
+
124
+ const fetches = pending.map(async (id) => {
125
+ try {
126
+ const d = await api.getThinkingModelDetail(id);
127
+ newDetails.set(id, d);
128
+ } catch {
129
+ // Skip failed fetches
130
+ } finally {
131
+ setComparisonLoadingModels(prev => {
132
+ const next = new Set(prev);
133
+ next.delete(id);
134
+ return next;
135
+ });
136
+ }
137
+ });
138
+ await Promise.all(fetches);
139
+ setComparisonDetails(newDetails);
140
+ }, [selectedForCompare, comparisonDetails]);
141
+
142
+ const exitComparison = useCallback(() => {
143
+ setIsComparing(false);
144
+ setSelectedForCompare([]);
145
+ setComparisonDetails(new Map());
146
+ setComparisonLoadingModels(new Set());
147
+ }, []);
148
+
149
+ // Filtered + sorted model list
150
+ const filteredModels = useMemo(() => {
151
+ if (!data) return [];
152
+ let models = [...data.topModels];
153
+ if (recFilter !== 'all') {
154
+ models = models.filter(m => m.recommendation === recFilter);
155
+ }
156
+ if (debouncedSearch) {
157
+ const q = debouncedSearch.toLowerCase();
158
+ models = models.filter(m =>
159
+ m.name.toLowerCase().includes(q) ||
160
+ (m.commonScenarios ?? []).some(s => s.toLowerCase().includes(q))
161
+ );
162
+ }
163
+ models.sort((a, b) => {
164
+ if (sortBy === 'hits') return b.hits - a.hits;
165
+ if (sortBy === 'successRate') return b.successRate - a.successRate;
166
+ return a.name.localeCompare(b.name);
167
+ });
168
+ return models;
169
+ }, [data, recFilter, debouncedSearch, sortBy]);
170
+
171
+ // Scenario heatmap data
172
+ const heatmapData = useMemo(() => {
173
+ if (!data || data.scenarioMatrix.length === 0) return null;
174
+ const allScenarios = [...new Set(data.scenarioMatrix.map(m => m.scenario))].sort();
175
+ const models = [...data.topModels].sort((a, b) => b.hits - a.hits);
176
+ const hitMap = new Map<string, number>();
177
+ for (const entry of data.scenarioMatrix) {
178
+ hitMap.set(`${entry.modelId}::${entry.scenario}`, entry.hits);
179
+ }
180
+ const maxHits = Math.max(...data.scenarioMatrix.map(m => m.hits), 1);
181
+ return { allScenarios, models, hitMap, maxHits };
182
+ }, [data]);
183
+
29
184
  if (error) return <ErrorState error={error} />;
30
185
  if (!data) return <Loading />;
31
186
 
187
+ const totalHits = data.topModels.reduce((sum, m) => sum + m.hits, 0);
188
+ const hasData = totalHits > 0;
189
+
32
190
  return (
33
191
  <div className="page">
192
+ {/* ── Header ── */}
34
193
  <header className="page-header">
35
194
  <div>
36
195
  <h2>{t('thinkingModels.pageTitle')}</h2>
196
+ {data.thinkingSummary?.modelDefinitions && data.thinkingSummary.modelDefinitions.length > 0 && (
197
+ <p style={{ fontSize: TEXT.sm, color: 'var(--text-secondary)', margin: `${SPACE[1]} 0 0` }}>
198
+ {t('thinkingModels.thinkingOsSource')}: <code style={{ fontSize: TEXT.xs, background: 'var(--bg-sunken)', padding: '1px 4px', borderRadius: 3 }}>THINKING_OS.md</code>
199
+ </p>
200
+ )}
37
201
  </div>
38
202
  <div className="pill-row">
39
203
  <span className="badge">{t('thinkingModels.coverage')} {formatPercent(data.summary.coverageRate)}</span>
@@ -43,85 +207,488 @@ export function ThinkingModelsPage() {
43
207
  </div>
44
208
  </header>
45
209
 
46
- <div className="grid two-columns wide-right">
47
- <section className="panel">
48
- <div className="list-table">
49
- {data.topModels.map((item) => (
50
- <button className={`table-row ${selectedModel === item.modelId ? 'active' : ''}`} key={item.modelId} onClick={() => setSelectedModel(item.modelId)}>
51
- <div>
52
- <strong>{item.name}</strong>
53
- <span>{item.commonScenarios.join(', ') || 'No scenarios yet'}</span>
54
- </div>
55
- <div>
56
- <span>{item.hits} hits</span>
57
- <span>{item.recommendation}</span>
58
- </div>
59
- <div className="align-right">
60
- <strong>{formatPercent(item.successRate)}</strong>
61
- <span>{formatPercent(item.failureRate)} failure</span>
210
+ {!hasData ? (
211
+ /* ── No data yet: show model definitions grid ── */
212
+ <section className="panel" style={{ marginBottom: SPACE[4] }}>
213
+ <div style={{ textAlign: 'center', padding: SPACE[5], color: 'var(--text-secondary)' }}>
214
+ <div style={{ fontSize: '2rem', marginBottom: SPACE[2] }}>🧠</div>
215
+ <h3 style={{ marginBottom: SPACE[1] }}>{t('thinkingModels.noDataTitle')}</h3>
216
+ <p style={{ fontSize: TEXT.lg, maxWidth: 500, margin: `0 auto ${SPACE[5]}` }}>
217
+ {t('thinkingModels.noDataDesc')}
218
+ </p>
219
+ </div>
220
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: SPACE[3] }}>
221
+ {data.topModels.map(model => (
222
+ <div
223
+ key={model.modelId}
224
+ role="button"
225
+ tabIndex={0}
226
+ onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedModel(model.modelId); setDetail(null); } }}
227
+ style={{
228
+ padding: SPACE[3],
229
+ border: '1px solid var(--border)',
230
+ borderRadius: 8,
231
+ background: 'var(--bg-sunken)',
232
+ cursor: 'pointer',
233
+ }}
234
+ onClick={() => { setSelectedModel(model.modelId); setDetail(null); }}
235
+ >
236
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: SPACE[2] }}>
237
+ <strong style={{ fontSize: TEXT.xl }}>{model.modelId}: {model.name}</strong>
62
238
  </div>
63
- </button>
239
+ <p style={{ fontSize: TEXT.base, color: 'var(--text-secondary)', margin: `0 0 ${SPACE[2]}`, lineHeight: 1.4 }}>
240
+ {model.description}
241
+ </p>
242
+ {model.commonScenarios.length > 0 && (
243
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: SPACE[1] }}>
244
+ {model.commonScenarios.slice(0, 3).map(s => (
245
+ <span key={s} style={{ fontSize: TEXT.xs, padding: '1px 6px', background: 'rgba(91,139,160,0.1)', borderRadius: 3, color: 'var(--info)' }}>
246
+ {s}
247
+ </span>
248
+ ))}
249
+ </div>
250
+ )}
251
+ </div>
64
252
  ))}
65
253
  </div>
66
254
  </section>
255
+ ) : (
256
+ /* ── Has data: full dashboard ── */
257
+ <>
258
+ {/* Coverage Trend */}
259
+ {data.coverageTrend.length >= 1 && (
260
+ <section className="panel" style={{ marginBottom: SPACE[4] }}>
261
+ <h3 className="section-title">
262
+ {t('thinkingModels.coverageTrend')}
263
+ </h3>
264
+ <LineChart
265
+ data={data.coverageTrend.map(d => ({ label: d.day.slice(5), value: Math.round(d.coverageRate * 100) }))}
266
+ width={560}
267
+ height={140}
268
+ color="var(--accent)"
269
+ showGrid
270
+ showDots
271
+ showArea
272
+ />
273
+ </section>
274
+ )}
67
275
 
68
- <section className="panel">
69
- {!detail && <EmptyState title={t('thinkingModels.emptyTitle')} description={t('thinkingModels.emptyDesc')} />}
70
- {detail && (
71
- <div className="detail-stack">
72
- <div className="detail-header">
276
+ {/* Search + Sort + Filter */}
277
+ <div style={{ display: 'flex', gap: SPACE[2], marginBottom: SPACE[3], alignItems: 'center', flexWrap: 'wrap' }}>
278
+ <div style={{ position: 'relative', flex: '1 1 200px' }}>
279
+ <Search size={14} style={{ position: 'absolute', left: SPACE[2], top: '50%', transform: 'translateY(-50%)', color: 'var(--text-secondary)', pointerEvents: 'none' }} />
280
+ <input
281
+ type="text"
282
+ placeholder={t('thinkingModels.searchPlaceholder')}
283
+ value={search}
284
+ onChange={e => setSearch(e.target.value)}
285
+ style={{ width: '100%', padding: `6px ${SPACE[2]} 6px 28px`, border: '1px solid var(--border)', borderRadius: 6, background: 'var(--bg-panel)', color: 'var(--text-primary)', fontSize: TEXT.lg }}
286
+ />
287
+ </div>
288
+ <button
289
+ onClick={() => setSortBy(prev => prev === 'hits' ? 'successRate' : prev === 'successRate' ? 'name' : 'hits')}
290
+ className="sort-button"
291
+ >
292
+ <ArrowUpDown size={14} />
293
+ {sortBy === 'hits' ? t('thinkingModels.sortByHits') : sortBy === 'successRate' ? t('thinkingModels.sortBySuccessRate') : t('thinkingModels.sortByName')}
294
+ </button>
295
+ <div style={{ display: 'flex', gap: SPACE[1] }}>
296
+ {['all', 'reinforce', 'rework', 'archive'].map(key => (
73
297
  <button
74
- className="back-button"
75
- onClick={() => setDetail(null)}
76
- title={t('common.back') || 'Back'}
298
+ key={key}
299
+ onClick={() => setRecFilter(key)}
300
+ className={`filter-button ${recFilter === key ? 'active' : ''}`}
77
301
  >
78
- <ChevronLeft strokeWidth={1.75} size={18} />
302
+ {key === 'all' ? t('thinkingModels.filterAll') : REC_BADGE[key]?.label(t)}
79
303
  </button>
80
- <div>
81
- <h3>{detail.modelMeta.name}</h3>
82
- <p>{detail.modelMeta.description}</p>
304
+ ))}
305
+ </div>
306
+ </div>
307
+
308
+ {/* Two-column layout: Model List + Detail */}
309
+ <div className="grid two-columns wide-right">
310
+ {/* Left: Model List */}
311
+ <section className="panel">
312
+ {/* Compare button bar */}
313
+ {selectedForCompare.length >= 2 && !isComparing && (
314
+ <div className="compare-bar">
315
+ <span style={{ fontSize: TEXT.base, color: 'var(--text-secondary)' }}>
316
+ {selectedForCompare.length} {t('thinkingModels.compareSelected')}
317
+ </span>
318
+ <button
319
+ onClick={startComparison}
320
+ className="compare-button"
321
+ >
322
+ <Columns size={14} />
323
+ {t('thinkingModels.compare')}
324
+ </button>
83
325
  </div>
84
- <span className="badge">{detail.modelMeta.recommendation}</span>
326
+ )}
327
+ <div className="list-table">
328
+ {filteredModels.map((item) => {
329
+ const isChecked = selectedForCompare.includes(item.modelId);
330
+ return (
331
+ <div
332
+ key={item.modelId}
333
+ className={`table-row ${selectedModel === item.modelId && !isComparing ? 'active' : ''}`}
334
+ style={{ display: 'flex', alignItems: 'center', gap: SPACE[2], padding: `${SPACE[2]} ${SPACE[3]}`, ...(item.recommendation === 'reinforce' ? { borderLeft: '3px solid var(--success)', paddingLeft: `calc(${SPACE[2]} - 3px)` } : {}) }}
335
+ >
336
+ <input
337
+ type="checkbox"
338
+ checked={isChecked}
339
+ onChange={() => toggleCompareSelection(item.modelId)}
340
+ onClick={e => e.stopPropagation()}
341
+ style={{ accentColor: 'var(--accent)', cursor: 'pointer', flexShrink: 0 }}
342
+ aria-label={`${t('thinkingModels.compare')}: ${item.name}`}
343
+ />
344
+ <button
345
+ onClick={() => { setSelectedModel(item.modelId); setDetail(null); setIsComparing(false); }}
346
+ className="model-list-button"
347
+ >
348
+ <div>
349
+ <strong className="text-base">{item.name}</strong>
350
+ <span className="scenario-ellipsis">
351
+ {item.commonScenarios.join(', ') || '—'}
352
+ </span>
353
+ </div>
354
+ <div style={{ display: 'flex', alignItems: 'center', gap: SPACE[2] }}>
355
+ <span className="hits-count">{item.hits}</span>
356
+ {REC_BADGE[item.recommendation] && (
357
+ <StatusBadge variant={REC_BADGE[item.recommendation].variant}>
358
+ {REC_BADGE[item.recommendation].label(t)}
359
+ </StatusBadge>
360
+ )}
361
+ </div>
362
+ </button>
363
+ </div>
364
+ );
365
+ })}
366
+ {filteredModels.length === 0 && (
367
+ <EmptyState
368
+ title={data.topModels.length > 0 ? (t('thinkingModels.noMatches') || 'No models match your filters.') : t('thinkingModels.noModelsYet')}
369
+ description={data.topModels.length > 0 ? '' : t('thinkingModels.noModelsYetDesc')}
370
+ />
371
+ )}
85
372
  </div>
86
- <article>
87
- <h4>{t('thinkingModels.outcomeStats')}</h4>
88
- <div className="pill-row">
89
- <span className="badge">{t('thinkingModels.success')} {formatPercent(detail.outcomeStats.successRate)}</span>
90
- <span className="badge">{t('thinkingModels.failure')} {formatPercent(detail.outcomeStats.failureRate)}</span>
91
- <span className="badge">{t('thinkingModels.pain')} {formatPercent(detail.outcomeStats.painRate)}</span>
92
- <span className="badge">{t('thinkingModels.correction')} {formatPercent(detail.outcomeStats.correctionRate)}</span>
93
- </div>
94
- </article>
95
- <article>
96
- <h4>{t('thinkingModels.scenarioDistribution')}</h4>
97
- <div className="stack">
98
- {detail.scenarioDistribution.map((item) => (
99
- <div className="row-card" key={item.scenario}>
100
- <strong>{item.scenario}</strong>
101
- <span>{item.hits}</span>
373
+ </section>
374
+
375
+ {/* Right: Detail Panel / Comparison View */}
376
+ <section className="panel">
377
+ {isComparing ? (
378
+ /* ── Comparison View ── */
379
+ <div className="comparison-view">
380
+ <div className="detail-header" style={{ marginBottom: SPACE[4] }}>
381
+ <button className="back-button" onClick={exitComparison} title={t('thinkingModels.exitCompare')}>
382
+ <X strokeWidth={1.75} size={18} />
383
+ </button>
384
+ <div>
385
+ <h3>{t('thinkingModels.comparisonTitle')}</h3>
386
+ <p style={{ fontSize: TEXT.lg, color: 'var(--text-secondary)' }}>
387
+ {selectedForCompare.length} {t('thinkingModels.compareSelected')}
388
+ {comparisonLoadingModels.size > 0 && (
389
+ <span style={{ marginLeft: SPACE[2], color: 'var(--info)' }}>
390
+ <Loader2 size={12} className="spin" /> {t('thinkingModels.loadingComparison')}
391
+ </span>
392
+ )}
393
+ </p>
102
394
  </div>
103
- ))}
395
+ </div>
396
+
397
+ {/* Comparison grid: side-by-side metrics */}
398
+ <div style={{ display: 'grid', gridTemplateColumns: `repeat(${selectedForCompare.length}, 1fr)`, gap: SPACE[3], marginBottom: SPACE[4] }}>
399
+ {selectedForCompare.map(modelId => {
400
+ const summary = data.topModels.find(m => m.modelId === modelId);
401
+ const det = comparisonDetails.get(modelId);
402
+ const isLoading = comparisonLoadingModels.has(modelId);
403
+ if (!summary) return null;
404
+ return (
405
+ <div key={modelId} style={{ padding: SPACE[3], border: '1px solid var(--border)', borderRadius: 8, background: 'var(--bg-sunken)', opacity: isLoading ? 0.6 : 1, position: 'relative' }}>
406
+ <strong style={{ fontSize: TEXT.xl, display: 'block', marginBottom: SPACE[2] }}>{summary.name}</strong>
407
+ {isLoading && (
408
+ <div style={{ position: 'absolute', top: SPACE[2], right: SPACE[2] }}>
409
+ <Loader2 size={14} className="spin" style={{ color: 'var(--info)' }} />
410
+ </div>
411
+ )}
412
+ <div className="pill-row" style={{ flexWrap: 'wrap', marginBottom: SPACE[2] }}>
413
+ <span className="badge">{t('thinkingModels.hits')}: {summary.hits}</span>
414
+ <span className="badge">{t('thinkingModels.successRate')}: {formatPercent(summary.successRate)}</span>
415
+ <span className="badge">{t('thinkingModels.failureRate')}: {formatPercent(summary.failureRate)}</span>
416
+ <span className="badge">{t('thinkingModels.pain')}: {formatPercent(summary.painRate)}</span>
417
+ </div>
418
+ {det && det.outcomeStats && (
419
+ <div style={{ fontSize: TEXT.sm, color: 'var(--text-secondary)', marginTop: SPACE[1] }}>
420
+ <div>{t('thinkingModels.correction')}: {formatPercent(det.outcomeStats.correctionRate)}</div>
421
+ <div>{t('thinkingModels.coverage')}: {formatPercent(det.modelMeta.coverageRate)}</div>
422
+ </div>
423
+ )}
424
+ </div>
425
+ );
426
+ })}
427
+ </div>
428
+
429
+ {/* Usage Trends for each model */}
430
+ <div style={{ display: 'grid', gridTemplateColumns: `repeat(${selectedForCompare.length}, 1fr)`, gap: SPACE[3] }}>
431
+ {selectedForCompare.map(modelId => {
432
+ const summary = data.topModels.find(m => m.modelId === modelId);
433
+ const det = comparisonDetails.get(modelId);
434
+ if (!det || det.usageTrend.length < 2) return null;
435
+ return (
436
+ <article key={modelId} style={{ padding: SPACE[2], border: '1px solid var(--border)', borderRadius: 6, background: 'var(--bg-sunken)' }}>
437
+ <h4 className="text-sm text-semibold" style={{ marginBottom: SPACE[2] }}>
438
+ {summary?.name} — {t('thinkingModels.usageTrend')}
439
+ </h4>
440
+ <LineChart
441
+ data={det.usageTrend.map(d => ({ label: d.day.slice(5), value: d.hits }))}
442
+ width="100%"
443
+ height={80}
444
+ color="var(--accent)"
445
+ showGrid={false}
446
+ showDots
447
+ showArea
448
+ />
449
+ </article>
450
+ );
451
+ })}
452
+ </div>
104
453
  </div>
105
- </article>
106
- <article>
107
- <h4>{t('thinkingModels.recentEvents')}</h4>
108
- <div className="stack">
109
- {detail.recentEvents.map((event) => (
110
- <div className="row-card vertical" key={event.id}>
111
- <div>
112
- <strong>{formatDate(event.createdAt)}</strong>
113
- <span>{event.scenarios.join(', ') || 'No scenarios'}</span>
114
- </div>
115
- <pre>{event.triggerExcerpt}</pre>
454
+ ) : (
455
+ <>
456
+ {isLoadingDetail && (
457
+ <div style={{ textAlign: 'center', padding: SPACE[6], color: 'var(--text-secondary)' }}>
458
+ <Loader2 size={24} className="spin" style={{ margin: `0 auto ${SPACE[2]}` }} />
459
+ <p style={{ fontSize: TEXT.lg }}>{t('thinkingModels.loadingDetail')}</p>
460
+ </div>
461
+ )}
462
+ {!isLoadingDetail && !detail && <EmptyState title={t('thinkingModels.emptyTitle')} description={t('thinkingModels.emptyDesc')} />}
463
+ {!isLoadingDetail && detail && (
464
+ <div className="detail-stack">
465
+ <div className="detail-header">
466
+ <button className="back-button" onClick={() => { setDetail(null); setSelectedModel(''); setIsLoadingDetail(true); }} title={t('common.back')}>
467
+ <ChevronLeft strokeWidth={1.75} size={18} />
468
+ </button>
469
+ <div>
470
+ <h3>{detail.modelMeta.name}</h3>
471
+ <p style={{ fontSize: TEXT.lg, color: 'var(--text-secondary)' }}>{detail.modelMeta.description}</p>
472
+ </div>
473
+ {REC_BADGE[detail.modelMeta.recommendation] && (
474
+ <StatusBadge variant={REC_BADGE[detail.modelMeta.recommendation].variant}>
475
+ {REC_BADGE[detail.modelMeta.recommendation].label(t)}
476
+ </StatusBadge>
477
+ )}
478
+ </div>
479
+
480
+ {/* Trigger Conditions */}
481
+ {detail.modelMeta.trigger && (
482
+ <article>
483
+ <h4 className="text-base text-semibold">{t('thinkingModels.trigger')}</h4>
484
+ <code className="code-block code-block-trigger">
485
+ {detail.modelMeta.trigger}
486
+ </code>
487
+ </article>
488
+ )}
489
+
490
+ {/* Anti-Patterns */}
491
+ {detail.modelMeta.antiPattern && (
492
+ <article>
493
+ <h4 className="text-base text-semibold text-error">{t('thinkingModels.antiPattern')}</h4>
494
+ <code className="code-block code-block-antipattern">
495
+ {detail.modelMeta.antiPattern}
496
+ </code>
497
+ </article>
498
+ )}
499
+
500
+ {/* Usage Trend */}
501
+ {detail.usageTrend.length >= 1 ? (
502
+ <article>
503
+ <h4 className="text-lg text-semibold" style={{ marginBottom: SPACE[2] }}>
504
+ {t('thinkingModels.usageTrend')}
505
+ </h4>
506
+ <LineChart
507
+ data={detail.usageTrend.map(d => ({ label: d.day.slice(5), value: d.hits }))}
508
+ width={500}
509
+ height={100}
510
+ color="var(--accent)"
511
+ showGrid
512
+ showDots
513
+ showArea
514
+ />
515
+ </article>
516
+ ) : (
517
+ <EmptyState
518
+ title={t('thinkingModels.emptyUsageTrend')}
519
+ description={t('thinkingModels.emptyUsageTrendDesc')}
520
+ />
521
+ )}
522
+
523
+ {/* Outcome Stats */}
524
+ <article>
525
+ <h4 className="text-base text-semibold">{t('thinkingModels.outcomeStats')}</h4>
526
+ <div className="pill-row">
527
+ <span className="badge">{t('thinkingModels.success')} {formatPercent(detail.outcomeStats.successRate)}</span>
528
+ <span className="badge">{t('thinkingModels.failure')} {formatPercent(detail.outcomeStats.failureRate)}</span>
529
+ <span className="badge">{t('thinkingModels.pain')} {formatPercent(detail.outcomeStats.painRate)}</span>
530
+ <span className="badge">{t('thinkingModels.correction')} {formatPercent(detail.outcomeStats.correctionRate)}</span>
116
531
  </div>
117
- ))}
532
+ </article>
533
+
534
+ {/* Scenario Distribution */}
535
+ {detail.scenarioDistribution.length > 0 && (
536
+ <article>
537
+ <h4 className="text-base text-semibold">{t('thinkingModels.scenarioDistribution')}</h4>
538
+ <div className="stack">
539
+ {detail.scenarioDistribution.map((item) => (
540
+ <div className="row-card" key={item.scenario}>
541
+ <strong className="text-base">{item.scenario}</strong>
542
+ <span>{item.hits}</span>
543
+ </div>
544
+ ))}
545
+ </div>
546
+ </article>
547
+ )}
548
+
549
+ {/* Recent Events */}
550
+ {detail.recentEvents.length > 0 && (
551
+ <article>
552
+ <h4 className="text-base text-semibold">{t('thinkingModels.recentEvents')}</h4>
553
+ <div className="stack">
554
+ {detail.recentEvents.map((event) => (
555
+ <div className="row-card vertical" key={event.id}>
556
+ <div>
557
+ <strong className="text-base">{formatDate(event.createdAt)}</strong>
558
+ <span className="text-sm">{event.scenarios.join(', ') || '—'}</span>
559
+ </div>
560
+ {event.toolContext?.length > 0 && (
561
+ <div className="event-context-tool" aria-label={t('thinkingModels.toolContext')}>
562
+ <Wrench size={12} aria-hidden /> {event.toolContext.map(tc => (
563
+ `${tc.toolName} (${tc.outcome}${tc.errorType ? `: ${tc.errorType}` : ''})`
564
+ )).join(', ')}
565
+ </div>
566
+ )}
567
+ {event.painContext?.length > 0 && (
568
+ <div className="event-context-pain" aria-label={t('thinkingModels.painContext')}>
569
+ <Zap size={12} aria-hidden /> {event.painContext.map(pc => `${pc.source} (${pc.score})`).join(', ')}
570
+ </div>
571
+ )}
572
+ {event.principleContext?.length > 0 && (
573
+ <div className="event-context-principle" aria-label={t('thinkingModels.principleContext')}>
574
+ <ClipboardList size={12} aria-hidden /> {event.principleContext.map(pr => (
575
+ `${pr.principleId ?? '—'} ${pr.eventType ? `(${pr.eventType})` : ''}`
576
+ )).join(', ')}
577
+ </div>
578
+ )}
579
+ {event.matchedPattern && (
580
+ <code className="matched-pattern">
581
+ /{event.matchedPattern}/
582
+ </code>
583
+ )}
584
+ <pre className="event-trigger-excerpt">
585
+ {event.triggerExcerpt}
586
+ </pre>
587
+ </div>
588
+ ))}
589
+ </div>
590
+ </article>
591
+ )}
118
592
  </div>
119
- </article>
120
- </div>
593
+ )}
594
+ </>
595
+ )}
596
+ </section>
597
+ </div>
598
+
599
+ {/* Dormant Models Section */}
600
+ {data.dormantModels.length > 0 ? (
601
+ <CollapsiblePanel
602
+ title={t('thinkingModels.dormantModels')}
603
+ badge={`${data.dormantModels.length}`}
604
+ defaultCollapsed
605
+ >
606
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: SPACE[2], padding: `${SPACE[2]} 0` }}>
607
+ {data.dormantModels.map(model => (
608
+ <div
609
+ key={model.modelId}
610
+ style={{
611
+ padding: `${SPACE[2]} ${SPACE[3]}`,
612
+ border: '1px solid var(--border)',
613
+ borderRadius: 6,
614
+ background: 'var(--bg-sunken)',
615
+ }}
616
+ >
617
+ <strong style={{ fontSize: TEXT.lg }}>{model.name}</strong>
618
+ <p style={{ fontSize: TEXT.sm, color: 'var(--text-secondary)', margin: `${SPACE[1]} 0 0`, lineHeight: 1.3 }}>
619
+ {model.description}
620
+ </p>
621
+ </div>
622
+ ))}
623
+ </div>
624
+ </CollapsiblePanel>
625
+ ) : (
626
+ <CollapsiblePanel
627
+ title={t('thinkingModels.dormantModels')}
628
+ defaultCollapsed
629
+ >
630
+ <EmptyState
631
+ title={t('thinkingModels.emptyAllActive')}
632
+ description={t('thinkingModels.emptyAllActiveDesc')}
633
+ />
634
+ </CollapsiblePanel>
121
635
  )}
122
- </section>
123
- </div>
636
+
637
+ {/* Scenario Heatmap */}
638
+ {heatmapData ? (
639
+ <CollapsiblePanel title={t('thinkingModels.scenarioHeatmap')}>
640
+ <div style={{ overflowX: 'auto' }}>
641
+ <table className="heatmap-table">
642
+ <thead>
643
+ <tr>
644
+ <th className="heatmap-header heatmap-sticky">
645
+ Model
646
+ </th>
647
+ {heatmapData.allScenarios.map(sc => (
648
+ <th key={sc} className="heatmap-header heatmap-scenario">
649
+ {sc}
650
+ </th>
651
+ ))}
652
+ </tr>
653
+ </thead>
654
+ <tbody>
655
+ {heatmapData.models.map(model => (
656
+ <tr key={model.modelId}>
657
+ <td className="heatmap-model heatmap-sticky">
658
+ {model.name}
659
+ </td>
660
+ {heatmapData.allScenarios.map(sc => {
661
+ const hits = heatmapData.hitMap.get(`${model.modelId}::${sc}`) ?? 0;
662
+ const intensity = hits / heatmapData.maxHits;
663
+ const bgColor = hits === 0
664
+ ? 'var(--bg-sunken)'
665
+ : `rgba(91, 139, 160, ${Math.max(0.15, intensity * 0.55).toFixed(2)})`;
666
+ return (
667
+ <td
668
+ key={sc}
669
+ className="heatmap-cell"
670
+ style={{ backgroundColor: bgColor }}
671
+ >
672
+ {hits}
673
+ </td>
674
+ );
675
+ })}
676
+ </tr>
677
+ ))}
678
+ </tbody>
679
+ </table>
680
+ </div>
681
+ </CollapsiblePanel>
682
+ ) : (
683
+ <CollapsiblePanel title={t('thinkingModels.scenarioHeatmap')}>
684
+ <EmptyState
685
+ title={t('thinkingModels.emptyScenarioMatrix')}
686
+ description={t('thinkingModels.emptyScenarioMatrixDesc')}
687
+ />
688
+ </CollapsiblePanel>
689
+ )}
690
+ </>
691
+ )}
124
692
  </div>
125
693
  );
126
694
  }
127
-