principles-disciple 1.13.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,12 +1,33 @@
1
- import React, { useEffect, useState, useMemo } from 'react';
2
- import { ChevronLeft, Search, ArrowUpDown, Info } 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, LineChart, StatusBadge } 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
+
10
31
  // ---------------------------------------------------------------------------
11
32
  // Recommendation badge helper
12
33
  // ---------------------------------------------------------------------------
@@ -28,13 +49,43 @@ export function ThinkingModelsPage() {
28
49
  const [data, setData] = useState<ThinkingOverviewResponse | null>(null);
29
50
  const [detail, setDetail] = useState<ThinkingModelDetailResponse | null>(null);
30
51
  const [selectedModel, setSelectedModel] = useState('');
52
+ const [isLoadingDetail, setIsLoadingDetail] = useState(false);
31
53
  const [error, setError] = useState('');
32
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
+
33
61
  // Filters
34
62
  const [recFilter, setRecFilter] = useState('all');
35
63
  const [search, setSearch] = useState('');
36
64
  const [sortBy, setSortBy] = useState<'hits' | 'successRate' | 'name'>('hits');
37
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
+
38
89
  useEffect(() => {
39
90
  api.getThinkingOverview().then((value) => {
40
91
  setData(value);
@@ -44,9 +95,57 @@ export function ThinkingModelsPage() {
44
95
 
45
96
  useEffect(() => {
46
97
  if (!selectedModel) return;
47
- 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); });
48
102
  }, [selectedModel]);
49
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
+
50
149
  // Filtered + sorted model list
51
150
  const filteredModels = useMemo(() => {
52
151
  if (!data) return [];
@@ -54,8 +153,8 @@ export function ThinkingModelsPage() {
54
153
  if (recFilter !== 'all') {
55
154
  models = models.filter(m => m.recommendation === recFilter);
56
155
  }
57
- if (search) {
58
- const q = search.toLowerCase();
156
+ if (debouncedSearch) {
157
+ const q = debouncedSearch.toLowerCase();
59
158
  models = models.filter(m =>
60
159
  m.name.toLowerCase().includes(q) ||
61
160
  (m.commonScenarios ?? []).some(s => s.toLowerCase().includes(q))
@@ -67,7 +166,20 @@ export function ThinkingModelsPage() {
67
166
  return a.name.localeCompare(b.name);
68
167
  });
69
168
  return models;
70
- }, [data, recFilter, search, sortBy]);
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]);
71
183
 
72
184
  if (error) return <ErrorState error={error} />;
73
185
  if (!data) return <Loading />;
@@ -81,6 +193,11 @@ export function ThinkingModelsPage() {
81
193
  <header className="page-header">
82
194
  <div>
83
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
+ )}
84
201
  </div>
85
202
  <div className="pill-row">
86
203
  <span className="badge">{t('thinkingModels.coverage')} {formatPercent(data.summary.coverageRate)}</span>
@@ -92,20 +209,23 @@ export function ThinkingModelsPage() {
92
209
 
93
210
  {!hasData ? (
94
211
  /* ── No data yet: show model definitions grid ── */
95
- <section className="panel" style={{ marginBottom: 'var(--space-4)' }}>
96
- <div style={{ textAlign: 'center', padding: 'var(--space-5)', color: 'var(--text-secondary)' }}>
97
- <div style={{ fontSize: '2rem', marginBottom: 8 }}>🧠</div>
98
- <h3 style={{ marginBottom: 4 }}>{t('thinkingModels.noDataTitle') || '思维模型定义'}</h3>
99
- <p style={{ fontSize: '0.85rem', maxWidth: 500, margin: '0 auto 24px' }}>
100
- {t('thinkingModels.noDataDesc') || '以下是 10 个思维模型的定义。当 AI 开始使用后,这里会显示每个模型的使用统计。'}
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')}
101
218
  </p>
102
219
  </div>
103
- <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 12 }}>
220
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: SPACE[3] }}>
104
221
  {data.topModels.map(model => (
105
222
  <div
106
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); } }}
107
227
  style={{
108
- padding: 12,
228
+ padding: SPACE[3],
109
229
  border: '1px solid var(--border)',
110
230
  borderRadius: 8,
111
231
  background: 'var(--bg-sunken)',
@@ -113,16 +233,16 @@ export function ThinkingModelsPage() {
113
233
  }}
114
234
  onClick={() => { setSelectedModel(model.modelId); setDetail(null); }}
115
235
  >
116
- <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 6 }}>
117
- <strong style={{ fontSize: '0.85rem' }}>{model.modelId}: {model.name}</strong>
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>
118
238
  </div>
119
- <p style={{ fontSize: '0.75rem', color: 'var(--text-secondary)', margin: '0 0 8px', lineHeight: 1.4 }}>
239
+ <p style={{ fontSize: TEXT.base, color: 'var(--text-secondary)', margin: `0 0 ${SPACE[2]}`, lineHeight: 1.4 }}>
120
240
  {model.description}
121
241
  </p>
122
242
  {model.commonScenarios.length > 0 && (
123
- <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
243
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: SPACE[1] }}>
124
244
  {model.commonScenarios.slice(0, 3).map(s => (
125
- <span key={s} style={{ fontSize: '0.65rem', padding: '1px 6px', background: 'rgba(91,139,160,0.1)', borderRadius: 3, color: 'var(--info)' }}>
245
+ <span key={s} style={{ fontSize: TEXT.xs, padding: '1px 6px', background: 'rgba(91,139,160,0.1)', borderRadius: 3, color: 'var(--info)' }}>
126
246
  {s}
127
247
  </span>
128
248
  ))}
@@ -137,8 +257,8 @@ export function ThinkingModelsPage() {
137
257
  <>
138
258
  {/* Coverage Trend */}
139
259
  {data.coverageTrend.length >= 1 && (
140
- <section className="panel" style={{ marginBottom: 'var(--space-4)' }}>
141
- <h3 style={{ fontSize: '0.85rem', fontWeight: 600, marginBottom: 8 }}>
260
+ <section className="panel" style={{ marginBottom: SPACE[4] }}>
261
+ <h3 className="section-title">
142
262
  {t('thinkingModels.coverageTrend')}
143
263
  </h3>
144
264
  <LineChart
@@ -154,40 +274,32 @@ export function ThinkingModelsPage() {
154
274
  )}
155
275
 
156
276
  {/* Search + Sort + Filter */}
157
- <div style={{ display: 'flex', gap: 8, marginBottom: 'var(--space-3)', alignItems: 'center', flexWrap: 'wrap' }}>
277
+ <div style={{ display: 'flex', gap: SPACE[2], marginBottom: SPACE[3], alignItems: 'center', flexWrap: 'wrap' }}>
158
278
  <div style={{ position: 'relative', flex: '1 1 200px' }}>
159
- <Search size={14} style={{ position: 'absolute', left: 8, top: '50%', transform: 'translateY(-50%)', color: 'var(--text-secondary)' }} />
279
+ <Search size={14} style={{ position: 'absolute', left: SPACE[2], top: '50%', transform: 'translateY(-50%)', color: 'var(--text-secondary)', pointerEvents: 'none' }} />
160
280
  <input
161
281
  type="text"
162
- placeholder={t('common.search') || 'Search...'}
282
+ placeholder={t('thinkingModels.searchPlaceholder')}
163
283
  value={search}
164
284
  onChange={e => setSearch(e.target.value)}
165
- style={{ paddingLeft: 28, width: '100%', padding: '6px 8px 6px 28px', border: '1px solid var(--border)', borderRadius: 6, background: 'var(--bg-panel)', color: 'var(--text-primary)', fontSize: '0.8rem' }}
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 }}
166
286
  />
167
287
  </div>
168
288
  <button
169
289
  onClick={() => setSortBy(prev => prev === 'hits' ? 'successRate' : prev === 'successRate' ? 'name' : 'hits')}
170
- style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '6px 10px', border: '1px solid var(--border)', borderRadius: 6, background: 'var(--bg-panel)', color: 'var(--text-secondary)', cursor: 'pointer', fontSize: '0.75rem' }}
290
+ className="sort-button"
171
291
  >
172
292
  <ArrowUpDown size={14} />
173
- {sortBy === 'hits' ? 'Hits' : sortBy === 'successRate' ? 'Success' : 'Name'}
293
+ {sortBy === 'hits' ? t('thinkingModels.sortByHits') : sortBy === 'successRate' ? t('thinkingModels.sortBySuccessRate') : t('thinkingModels.sortByName')}
174
294
  </button>
175
- <div style={{ display: 'flex', gap: 4 }}>
295
+ <div style={{ display: 'flex', gap: SPACE[1] }}>
176
296
  {['all', 'reinforce', 'rework', 'archive'].map(key => (
177
297
  <button
178
298
  key={key}
179
299
  onClick={() => setRecFilter(key)}
180
- style={{
181
- padding: '3px 8px',
182
- border: `1px solid ${recFilter === key ? 'var(--accent)' : 'var(--border)'}`,
183
- borderRadius: 4,
184
- background: recFilter === key ? 'rgba(91, 139, 160, 0.15)' : 'transparent',
185
- color: recFilter === key ? 'var(--accent)' : 'var(--text-secondary)',
186
- cursor: 'pointer',
187
- fontSize: '0.7rem',
188
- }}
300
+ className={`filter-button ${recFilter === key ? 'active' : ''}`}
189
301
  >
190
- {key === 'all' ? 'All' : REC_BADGE[key]?.label(t)}
302
+ {key === 'all' ? t('thinkingModels.filterAll') : REC_BADGE[key]?.label(t)}
191
303
  </button>
192
304
  ))}
193
305
  </div>
@@ -197,49 +309,166 @@ export function ThinkingModelsPage() {
197
309
  <div className="grid two-columns wide-right">
198
310
  {/* Left: Model List */}
199
311
  <section className="panel">
200
- <div className="list-table">
201
- {filteredModels.map((item) => (
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>
202
318
  <button
203
- className={`table-row ${selectedModel === item.modelId ? 'active' : ''}`}
204
- key={item.modelId}
205
- onClick={() => { setSelectedModel(item.modelId); setDetail(null); }}
319
+ onClick={startComparison}
320
+ className="compare-button"
206
321
  >
207
- <div>
208
- <strong>{item.name}</strong>
209
- <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 180, display: 'block' }}>
210
- {item.commonScenarios.join(', ') || '—'}
211
- </span>
212
- </div>
213
- <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
214
- <span style={{ fontWeight: 600, fontSize: '0.85rem' }}>{item.hits}</span>
215
- {REC_BADGE[item.recommendation] && (
216
- <StatusBadge variant={REC_BADGE[item.recommendation].variant}>
217
- {REC_BADGE[item.recommendation].label(t)}
218
- </StatusBadge>
219
- )}
220
- </div>
322
+ <Columns size={14} />
323
+ {t('thinkingModels.compare')}
221
324
  </button>
222
- ))}
325
+ </div>
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
+ })}
223
366
  {filteredModels.length === 0 && (
224
- <div style={{ padding: 'var(--space-4)', textAlign: 'center', color: 'var(--text-secondary)', fontSize: '0.85rem' }}>
225
- No models match your filters.
226
- </div>
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
+ />
227
371
  )}
228
372
  </div>
229
373
  </section>
230
374
 
231
- {/* Right: Detail Panel */}
375
+ {/* Right: Detail Panel / Comparison View */}
232
376
  <section className="panel">
233
- {!detail && <EmptyState title={t('thinkingModels.emptyTitle')} description={t('thinkingModels.emptyDesc')} />}
234
- {detail && (
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>
394
+ </div>
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>
453
+ </div>
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 && (
235
464
  <div className="detail-stack">
236
465
  <div className="detail-header">
237
- <button className="back-button" onClick={() => setDetail(null)} title="Back">
466
+ <button className="back-button" onClick={() => { setDetail(null); setSelectedModel(''); setIsLoadingDetail(true); }} title={t('common.back')}>
238
467
  <ChevronLeft strokeWidth={1.75} size={18} />
239
468
  </button>
240
469
  <div>
241
470
  <h3>{detail.modelMeta.name}</h3>
242
- <p style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>{detail.modelMeta.description}</p>
471
+ <p style={{ fontSize: TEXT.lg, color: 'var(--text-secondary)' }}>{detail.modelMeta.description}</p>
243
472
  </div>
244
473
  {REC_BADGE[detail.modelMeta.recommendation] && (
245
474
  <StatusBadge variant={REC_BADGE[detail.modelMeta.recommendation].variant}>
@@ -248,11 +477,31 @@ export function ThinkingModelsPage() {
248
477
  )}
249
478
  </div>
250
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
+
251
500
  {/* Usage Trend */}
252
- {detail.usageTrend.length >= 1 && (
501
+ {detail.usageTrend.length >= 1 ? (
253
502
  <article>
254
- <h4 style={{ fontSize: '0.8rem', fontWeight: 600, marginBottom: 8 }}>
255
- {t('thinkingModels.usageTrend') || 'Usage Trend'}
503
+ <h4 className="text-lg text-semibold" style={{ marginBottom: SPACE[2] }}>
504
+ {t('thinkingModels.usageTrend')}
256
505
  </h4>
257
506
  <LineChart
258
507
  data={detail.usageTrend.map(d => ({ label: d.day.slice(5), value: d.hits }))}
@@ -264,11 +513,16 @@ export function ThinkingModelsPage() {
264
513
  showArea
265
514
  />
266
515
  </article>
516
+ ) : (
517
+ <EmptyState
518
+ title={t('thinkingModels.emptyUsageTrend')}
519
+ description={t('thinkingModels.emptyUsageTrendDesc')}
520
+ />
267
521
  )}
268
522
 
269
523
  {/* Outcome Stats */}
270
524
  <article>
271
- <h4>{t('thinkingModels.outcomeStats')}</h4>
525
+ <h4 className="text-base text-semibold">{t('thinkingModels.outcomeStats')}</h4>
272
526
  <div className="pill-row">
273
527
  <span className="badge">{t('thinkingModels.success')} {formatPercent(detail.outcomeStats.successRate)}</span>
274
528
  <span className="badge">{t('thinkingModels.failure')} {formatPercent(detail.outcomeStats.failureRate)}</span>
@@ -280,11 +534,11 @@ export function ThinkingModelsPage() {
280
534
  {/* Scenario Distribution */}
281
535
  {detail.scenarioDistribution.length > 0 && (
282
536
  <article>
283
- <h4>{t('thinkingModels.scenarioDistribution')}</h4>
537
+ <h4 className="text-base text-semibold">{t('thinkingModels.scenarioDistribution')}</h4>
284
538
  <div className="stack">
285
539
  {detail.scenarioDistribution.map((item) => (
286
540
  <div className="row-card" key={item.scenario}>
287
- <strong>{item.scenario}</strong>
541
+ <strong className="text-base">{item.scenario}</strong>
288
542
  <span>{item.hits}</span>
289
543
  </div>
290
544
  ))}
@@ -295,30 +549,39 @@ export function ThinkingModelsPage() {
295
549
  {/* Recent Events */}
296
550
  {detail.recentEvents.length > 0 && (
297
551
  <article>
298
- <h4>{t('thinkingModels.recentEvents')}</h4>
552
+ <h4 className="text-base text-semibold">{t('thinkingModels.recentEvents')}</h4>
299
553
  <div className="stack">
300
554
  {detail.recentEvents.map((event) => (
301
555
  <div className="row-card vertical" key={event.id}>
302
556
  <div>
303
- <strong>{formatDate(event.createdAt)}</strong>
304
- <span>{event.scenarios.join(', ') || '—'}</span>
557
+ <strong className="text-base">{formatDate(event.createdAt)}</strong>
558
+ <span className="text-sm">{event.scenarios.join(', ') || '—'}</span>
305
559
  </div>
306
- {(event as any).toolContext?.length > 0 && (
307
- <div style={{ fontSize: '0.7rem', color: 'var(--text-secondary)' }}>
308
- 🛠 {(event as any).toolContext.map((tc: any) => `${tc.toolName} (${tc.outcome})`).join(', ')}
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(', ')}
309
565
  </div>
310
566
  )}
311
- {(event as any).painContext?.length > 0 && (
312
- <div style={{ fontSize: '0.7rem', color: 'var(--error)' }}>
313
- {(event as any).painContext.map((pc: any) => `${pc.source} (${pc.score})`).join(', ')}
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(', ')}
314
570
  </div>
315
571
  )}
316
- {(event as any).principleContext?.length > 0 && (
317
- <div style={{ fontSize: '0.7rem', color: 'var(--info)' }}>
318
- 📋 {(event as any).principleContext.map((pr: any) => `${pr.principleId}`).join(', ')}
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(', ')}
319
577
  </div>
320
578
  )}
321
- <pre style={{ fontSize: '0.7rem', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
579
+ {event.matchedPattern && (
580
+ <code className="matched-pattern">
581
+ /{event.matchedPattern}/
582
+ </code>
583
+ )}
584
+ <pre className="event-trigger-excerpt">
322
585
  {event.triggerExcerpt}
323
586
  </pre>
324
587
  </div>
@@ -326,18 +589,104 @@ export function ThinkingModelsPage() {
326
589
  </div>
327
590
  </article>
328
591
  )}
329
-
330
- {/* No data message for detail */}
331
- {detail.usageTrend.length === 0 && detail.recentEvents.length === 0 && (
332
- <div style={{ textAlign: 'center', padding: 'var(--space-4)', color: 'var(--text-secondary)' }}>
333
- <Info size={20} style={{ marginBottom: 8 }} />
334
- <p style={{ fontSize: '0.8rem' }}>No usage data for this model yet.</p>
335
- </div>
336
- )}
337
592
  </div>
338
593
  )}
594
+ </>
595
+ )}
339
596
  </section>
340
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>
635
+ )}
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
+ )}
341
690
  </>
342
691
  )}
343
692
  </div>