@proofhound/web-ui 0.1.7 → 0.1.9

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.
Files changed (108) hide show
  1. package/dist/hooks/annotation.d.ts +37 -16
  2. package/dist/hooks/annotation.d.ts.map +1 -1
  3. package/dist/hooks/canary-release.d.ts +62 -37
  4. package/dist/hooks/canary-release.d.ts.map +1 -1
  5. package/dist/hooks/dataset.d.ts +101 -0
  6. package/dist/hooks/dataset.d.ts.map +1 -1
  7. package/dist/hooks/dataset.js +27 -0
  8. package/dist/hooks/dataset.js.map +1 -1
  9. package/dist/hooks/optimization.d.ts +1 -1
  10. package/dist/hooks/production-release.d.ts +8 -4
  11. package/dist/hooks/production-release.d.ts.map +1 -1
  12. package/dist/hooks/prompt.d.ts +149 -38
  13. package/dist/hooks/prompt.d.ts.map +1 -1
  14. package/dist/hooks/prompt.js +20 -0
  15. package/dist/hooks/prompt.js.map +1 -1
  16. package/dist/hooks/release-line.d.ts +2522 -72
  17. package/dist/hooks/release-line.d.ts.map +1 -1
  18. package/dist/hooks/release-line.js +125 -0
  19. package/dist/hooks/release-line.js.map +1 -1
  20. package/dist/hooks/run-result.d.ts +9 -6
  21. package/dist/hooks/run-result.d.ts.map +1 -1
  22. package/dist/hooks/run-result.js +2 -1
  23. package/dist/hooks/run-result.js.map +1 -1
  24. package/dist/i18n/index.d.ts +652 -160
  25. package/dist/i18n/index.d.ts.map +1 -1
  26. package/dist/i18n/index.js +652 -160
  27. package/dist/i18n/index.js.map +1 -1
  28. package/dist/lib/releases/release-line-model.d.ts +8 -2
  29. package/dist/lib/releases/release-line-model.d.ts.map +1 -1
  30. package/dist/lib/releases/release-line-model.js +66 -29
  31. package/dist/lib/releases/release-line-model.js.map +1 -1
  32. package/dist/screens/annotations/annotation-detail-page.js +1 -1
  33. package/dist/screens/annotations/annotation-new-page.d.ts.map +1 -1
  34. package/dist/screens/annotations/annotation-new-page.js +213 -49
  35. package/dist/screens/annotations/annotation-new-page.js.map +1 -1
  36. package/dist/screens/annotations/annotation-task-model.d.ts +3 -2
  37. package/dist/screens/annotations/annotation-task-model.d.ts.map +1 -1
  38. package/dist/screens/annotations/annotation-task-model.js +5 -4
  39. package/dist/screens/annotations/annotation-task-model.js.map +1 -1
  40. package/dist/screens/annotations/annotation-ui.d.ts.map +1 -1
  41. package/dist/screens/annotations/annotation-ui.js +9 -4
  42. package/dist/screens/annotations/annotation-ui.js.map +1 -1
  43. package/dist/screens/annotations/annotations-list-page.js +1 -1
  44. package/dist/screens/connectors/connector-detail-page.js +2 -2
  45. package/dist/screens/connectors/connector-detail-page.js.map +1 -1
  46. package/dist/screens/connectors/connector-form-page.js +1 -1
  47. package/dist/screens/connectors/connector-form-page.js.map +1 -1
  48. package/dist/screens/connectors/connector-ui.d.ts +6 -0
  49. package/dist/screens/connectors/connector-ui.d.ts.map +1 -1
  50. package/dist/screens/connectors/connector-ui.js +7 -1
  51. package/dist/screens/connectors/connector-ui.js.map +1 -1
  52. package/dist/screens/connectors/connectors-list-page.d.ts.map +1 -1
  53. package/dist/screens/connectors/connectors-list-page.js +5 -5
  54. package/dist/screens/connectors/connectors-list-page.js.map +1 -1
  55. package/dist/screens/dashboard/dashboard-screen.d.ts.map +1 -1
  56. package/dist/screens/dashboard/dashboard-screen.js +27 -15
  57. package/dist/screens/dashboard/dashboard-screen.js.map +1 -1
  58. package/dist/screens/datasets/dataset-mappers.js +1 -1
  59. package/dist/screens/datasets/dataset-mappers.js.map +1 -1
  60. package/dist/screens/datasets/dataset-types.d.ts +1 -1
  61. package/dist/screens/datasets/dataset-types.d.ts.map +1 -1
  62. package/dist/screens/datasets/dataset-ui.d.ts +1 -1
  63. package/dist/screens/datasets/dataset-ui.d.ts.map +1 -1
  64. package/dist/screens/datasets/dataset-ui.js +2 -2
  65. package/dist/screens/datasets/dataset-ui.js.map +1 -1
  66. package/dist/screens/datasets/datasets-list-page.d.ts.map +1 -1
  67. package/dist/screens/datasets/datasets-list-page.js +35 -24
  68. package/dist/screens/datasets/datasets-list-page.js.map +1 -1
  69. package/dist/screens/experiments/experiment-detail-page.js +1 -1
  70. package/dist/screens/experiments/experiment-detail-page.js.map +1 -1
  71. package/dist/screens/experiments/run-result-labels.d.ts.map +1 -1
  72. package/dist/screens/experiments/run-result-labels.js +3 -4
  73. package/dist/screens/experiments/run-result-labels.js.map +1 -1
  74. package/dist/screens/prompts/prompt-detail-page.d.ts.map +1 -1
  75. package/dist/screens/prompts/prompt-detail-page.js +9 -10
  76. package/dist/screens/prompts/prompt-detail-page.js.map +1 -1
  77. package/dist/screens/prompts/prompt-model.d.ts +5 -2
  78. package/dist/screens/prompts/prompt-model.d.ts.map +1 -1
  79. package/dist/screens/prompts/prompt-model.js +3 -1
  80. package/dist/screens/prompts/prompt-model.js.map +1 -1
  81. package/dist/screens/prompts/prompts-list-page.d.ts.map +1 -1
  82. package/dist/screens/prompts/prompts-list-page.js +44 -20
  83. package/dist/screens/prompts/prompts-list-page.js.map +1 -1
  84. package/dist/screens/releases/release-input-route-editor.d.ts +39 -0
  85. package/dist/screens/releases/release-input-route-editor.d.ts.map +1 -0
  86. package/dist/screens/releases/release-input-route-editor.js +355 -0
  87. package/dist/screens/releases/release-input-route-editor.js.map +1 -0
  88. package/dist/screens/releases/release-line-detail-page.d.ts +62 -0
  89. package/dist/screens/releases/release-line-detail-page.d.ts.map +1 -1
  90. package/dist/screens/releases/release-line-detail-page.js +1877 -323
  91. package/dist/screens/releases/release-line-detail-page.js.map +1 -1
  92. package/dist/screens/releases/release-line-ui.d.ts.map +1 -1
  93. package/dist/screens/releases/release-line-ui.js +55 -39
  94. package/dist/screens/releases/release-line-ui.js.map +1 -1
  95. package/dist/screens/releases/release-new-model.d.ts.map +1 -1
  96. package/dist/screens/releases/release-new-model.js +1 -6
  97. package/dist/screens/releases/release-new-model.js.map +1 -1
  98. package/dist/screens/releases/release-new-page.d.ts.map +1 -1
  99. package/dist/screens/releases/release-new-page.js +101 -66
  100. package/dist/screens/releases/release-new-page.js.map +1 -1
  101. package/dist/screens/releases/release-topology-canvas.d.ts +11 -2
  102. package/dist/screens/releases/release-topology-canvas.d.ts.map +1 -1
  103. package/dist/screens/releases/release-topology-canvas.js +1015 -174
  104. package/dist/screens/releases/release-topology-canvas.js.map +1 -1
  105. package/dist/screens/releases/releases-list-page.d.ts.map +1 -1
  106. package/dist/screens/releases/releases-list-page.js +81 -32
  107. package/dist/screens/releases/releases-list-page.js.map +1 -1
  108. package/package.json +5 -4
@@ -1,44 +1,49 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
- import { useCallback, useEffect, useMemo, useState } from 'react';
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
4
  import Link from 'next/link';
5
5
  import { usePathname, useRouter, useSearchParams } from 'next/navigation';
6
6
  import { useQueryClient } from '@tanstack/react-query';
7
- import { Activity, AlertTriangle, ClipboardCheck, CircleDollarSign, Copy, Gauge, Plus, Square, Timer, } from 'lucide-react';
8
- import { CartesianGrid, Line, LineChart as RechartsLineChart, ResponsiveContainer, Tooltip, XAxis, YAxis, } from 'recharts';
7
+ import { Activity, AlertTriangle, Archive, Check, ChevronDown, CircleDollarSign, Gauge, MoreHorizontal, Play, Plus, RotateCcw, ScrollText, Search, SlidersHorizontal, Square, Tag, Trash2, Timer, } from 'lucide-react';
8
+ import * as echarts from 'echarts/core';
9
+ import { LineChart } from 'echarts/charts';
10
+ import { DataZoomComponent, GridComponent, ToolboxComponent, TooltipComponent, } from 'echarts/components';
11
+ import { SVGRenderer } from 'echarts/renderers';
9
12
  import { Main } from '@proofhound/ui/layout';
10
- import { Button, DateRangeSegmented, resolveDateRangePreset, resolveRollingDateRangeValue, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, Input, PlatformLoader, DetailPageSkeleton, ResourcePaginationFooter, Table, TableBody, TableCell, TableEmpty, TableHead, TableHeader, TableRow, cn, } from '@proofhound/ui';
11
- import { useAnnotationTaskList } from '../../hooks';
13
+ import { Button, DateRangeSegmented, resolveDateRangePreset, resolveRollingDateRangeValue, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, Input, PlatformLoader, Popover, PopoverContent, PopoverTrigger, DetailPageSkeleton, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, ResourcePaginationFooter, Table, TableBody, TableCell, TableEmpty, TableHead, TableHeader, TableRow, cn, } from '@proofhound/ui';
12
14
  import { useDelayedLoading } from '../../hooks';
13
15
  import { useProjectModels } from '../../hooks';
14
- import { useProductionReleaseHistory, useStopProductionRelease } from '../../hooks';
16
+ import { useConnectors, useProductionReleaseHistory, useStopCanaryRelease } from '../../hooks';
15
17
  import { useProjectMonitoringStats, useProjectMonitoringTimeseries } from '../../hooks';
16
- import { useReleaseLineEvents, useReleaseLineList, useUpdateReleaseLineRunConfig, useUpdateReleaseLineTrafficRatio, } from '../../hooks';
18
+ import { useArchiveReleaseLine, usePromoteReleaseLineCanary, useDeleteReleaseLine, useReleaseLineDeleteImpact, useReleaseLineEvents, useReleaseLineList, useRestoreReleaseLineHistoryToCanary, useRestoreReleaseLineHistoryToProduction, useStartReleaseLine, useStopReleaseLine, useUnarchiveReleaseLine, useUpdateReleaseLineInputRoute, useUpdateReleaseLineOutputRoute, useUpdateReleaseLineRunConfig, useUpdateReleaseLineTrafficRatio, } from '../../hooks';
17
19
  import { useReleaseRunResults } from '../../hooks';
18
20
  import { AUTO_REFRESH_INTERVAL_MS, useAutoRefresh, useDateTimeFormatter } from '../../hooks';
19
21
  import { useI18n } from '../../i18n';
20
- import { getReleaseLineId, getReleaseStopConfirmationName } from '../../lib';
22
+ import { getApiErrorMessage, getReleaseLineId, getReleaseStopConfirmationName } from '../../lib';
21
23
  import { BigChartCard } from '../monitoring/big-chart-card';
22
- import { ReleaseEventPill, ReleaseMetricCard, formatCount, formatPercent, } from './release-line-ui';
24
+ import { formatCount, formatPercent } from './release-line-ui';
23
25
  import { ReleaseTopologyCanvas } from './release-topology-canvas';
26
+ echarts.use([LineChart, GridComponent, TooltipComponent, DataZoomComponent, ToolboxComponent, SVGRenderer]);
24
27
  const DETAIL_TABS = [
25
28
  { value: 'monitoring', key: 'releases.detail.tab.monitoring' },
26
- { value: 'variants', key: 'releases.detail.tab.variants' },
27
29
  { value: 'results', key: 'releases.detail.tab.results' },
28
30
  { value: 'quality', key: 'releases.detail.tab.quality' },
29
31
  { value: 'history', key: 'releases.detail.tab.history' },
32
+ { value: 'settings', key: 'releases.detail.tab.settings' },
30
33
  ];
31
34
  const RESULT_COLUMNS = [
32
35
  { key: 'externalId', width: 'normal' },
33
36
  { key: 'input', width: 'wide' },
34
37
  { key: 'output', width: 'wide' },
35
38
  { key: 'source', width: 'compact' },
36
- { key: 'variant', width: 'normal' },
39
+ { key: 'version', width: 'normal' },
37
40
  { key: 'latency', width: 'compact' },
38
41
  { key: 'tokens', width: 'compact' },
39
42
  { key: 'createdAt', width: 'normal' },
40
43
  ];
41
44
  const RESULT_PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
45
+ const HISTORY_INITIAL_GROUP_LIMIT = 8;
46
+ const HISTORY_GROUP_PAGE_SIZE = 8;
42
47
  const EMPTY_BY_SOURCE = { prod: 0, canary: 0, iter: 0, exp: 0 };
43
48
  const EMPTY_TIMESERIES_POINTS = [];
44
49
  const RELEASE_MONITORING_SOURCE_KEYS = ['prod', 'canary'];
@@ -46,6 +51,21 @@ function useDateTimeOrDash() {
46
51
  const { formatDateTime } = useDateTimeFormatter();
47
52
  return useCallback((value) => (value ? formatDateTime(value, { fallback: '—' }) : '—'), [formatDateTime]);
48
53
  }
54
+ const QUALITY_OVERALL_SCOPE = '__overall__';
55
+ const QUALITY_METRIC_OPTIONS = [
56
+ { key: 'recall', labelKey: 'releases.detail.quality.metric.recall' },
57
+ { key: 'precision', labelKey: 'releases.detail.quality.metric.precision' },
58
+ { key: 'f1', labelKey: 'releases.detail.quality.metric.f1' },
59
+ { key: 'accuracy', labelKey: 'releases.detail.quality.metric.accuracy' },
60
+ ];
61
+ const QUALITY_SERIES_COLORS = [
62
+ 'var(--primary)',
63
+ 'var(--src-iter)',
64
+ 'var(--status-pending-dot)',
65
+ 'var(--destructive)',
66
+ 'var(--foreground)',
67
+ 'var(--muted-foreground)',
68
+ ];
49
69
  const COMPACT_METRIC_DOT_CLASS = {
50
70
  default: 'bg-muted-foreground',
51
71
  production: 'bg-[var(--src-prod-fg)]',
@@ -53,9 +73,6 @@ const COMPACT_METRIC_DOT_CLASS = {
53
73
  success: 'bg-[var(--status-running-fg)]',
54
74
  danger: 'bg-destructive',
55
75
  };
56
- const QUALITY_LINE_COLORS = {
57
- score: 'var(--src-canary)',
58
- };
59
76
  function normalizeLineId(value) {
60
77
  try {
61
78
  return decodeURIComponent(value);
@@ -67,13 +84,17 @@ function normalizeLineId(value) {
67
84
  function resolveTab(value) {
68
85
  if (value === 'annotation')
69
86
  return 'quality';
87
+ if (value === 'variants' || value === 'versions')
88
+ return 'history';
70
89
  if (value === 'monitoring' ||
71
- value === 'variants' ||
72
90
  value === 'results' ||
73
91
  value === 'quality' ||
74
- value === 'history') {
92
+ value === 'history' ||
93
+ value === 'settings') {
75
94
  return value;
76
95
  }
96
+ if (value === 'delete')
97
+ return 'settings';
77
98
  return 'monitoring';
78
99
  }
79
100
  function createDefaultMonitoringRange() {
@@ -87,6 +108,17 @@ function createDefaultMonitoringRange() {
87
108
  to: now.toISOString(),
88
109
  };
89
110
  }
111
+ function createDefaultResultDateRange() {
112
+ const preset = resolveDateRangePreset('d7');
113
+ if (preset)
114
+ return { preset: 'all', ...preset };
115
+ const now = new Date();
116
+ return {
117
+ preset: 'all',
118
+ from: new Date(now.getTime() - 7 * 24 * 60 * 60_000).toISOString(),
119
+ to: now.toISOString(),
120
+ };
121
+ }
90
122
  function getMonitoringRefreshInterval(preset) {
91
123
  if (preset === 'h1')
92
124
  return AUTO_REFRESH_INTERVAL_MS;
@@ -96,6 +128,12 @@ function getMonitoringRefreshInterval(preset) {
96
128
  return 60_000;
97
129
  return false;
98
130
  }
131
+ function isResultDateRangeApplied(value) {
132
+ return value.preset !== 'all';
133
+ }
134
+ function isResultDateRangeRolling(value) {
135
+ return value.preset !== 'all' && value.preset !== 'custom';
136
+ }
99
137
  function hasRunningRelease(line) {
100
138
  return line?.production?.currentEvent?.status === 'running' || line?.canary?.status === 'running';
101
139
  }
@@ -156,29 +194,261 @@ function timeValue(value) {
156
194
  const parsed = Date.parse(value);
157
195
  return Number.isFinite(parsed) ? parsed : 0;
158
196
  }
159
- function buildAnnotationQualityPoints(tasks) {
197
+ function latestTime(values) {
198
+ const latest = values
199
+ .filter((value) => Boolean(value))
200
+ .sort((left, right) => timeValue(right) - timeValue(left))[0];
201
+ return latest ?? null;
202
+ }
203
+ function qualityVersionLane(kind) {
204
+ return kind === 'production' ? 'production' : 'canary';
205
+ }
206
+ function isRecord(value) {
207
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
208
+ }
209
+ function stringFromQualityRecord(record, key) {
210
+ const value = record[key];
211
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
212
+ }
213
+ function numberFromQualityRecord(record, key) {
214
+ const value = record[key];
215
+ const parsed = typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : Number.NaN;
216
+ if (!Number.isFinite(parsed) || parsed < 0)
217
+ return null;
218
+ if (parsed <= 1)
219
+ return toPercentPoint(parsed);
220
+ if (parsed <= 100)
221
+ return parsed;
222
+ return null;
223
+ }
224
+ function countFromQualityRecord(record, key) {
225
+ const value = record[key];
226
+ const parsed = typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : Number.NaN;
227
+ if (!Number.isFinite(parsed) || parsed < 0)
228
+ return null;
229
+ return Math.round(parsed);
230
+ }
231
+ function readReleaseQualityMetricSet(value) {
232
+ if (!isRecord(value))
233
+ return null;
234
+ const recall = numberFromQualityRecord(value, 'recall');
235
+ const precision = numberFromQualityRecord(value, 'precision');
236
+ const f1 = numberFromQualityRecord(value, 'f1');
237
+ const accuracy = numberFromQualityRecord(value, 'accuracy');
238
+ if (recall === null || precision === null || f1 === null || accuracy === null)
239
+ return null;
240
+ const sampleCount = countFromQualityRecord(value, 'sampleCount');
241
+ return {
242
+ recall,
243
+ precision,
244
+ f1,
245
+ accuracy,
246
+ sampleCount,
247
+ };
248
+ }
249
+ function readReleaseQualityScopes(metrics, overallLabel) {
250
+ const quality = isRecord(metrics?.['quality']) ? metrics['quality'] : null;
251
+ if (!quality)
252
+ return [];
253
+ const scopes = [];
254
+ const overallMetrics = readReleaseQualityMetricSet(quality['overall']);
255
+ if (overallMetrics) {
256
+ scopes.push({ scope: QUALITY_OVERALL_SCOPE, label: overallLabel, metrics: overallMetrics });
257
+ }
258
+ const rawScopes = quality['scopes'];
259
+ if (Array.isArray(rawScopes)) {
260
+ for (const item of rawScopes) {
261
+ if (!isRecord(item))
262
+ continue;
263
+ const scope = stringFromQualityRecord(item, 'key') ?? stringFromQualityRecord(item, 'label');
264
+ if (!scope)
265
+ continue;
266
+ const metricSet = readReleaseQualityMetricSet(item['metrics']) ?? readReleaseQualityMetricSet(item);
267
+ if (!metricSet)
268
+ continue;
269
+ scopes.push({
270
+ scope,
271
+ label: stringFromQualityRecord(item, 'label') ?? scope,
272
+ metrics: metricSet,
273
+ });
274
+ }
275
+ }
276
+ else if (isRecord(rawScopes)) {
277
+ for (const [scope, value] of Object.entries(rawScopes)) {
278
+ if (!isRecord(value))
279
+ continue;
280
+ const metricSet = readReleaseQualityMetricSet(value['metrics']) ?? readReleaseQualityMetricSet(value);
281
+ if (!metricSet)
282
+ continue;
283
+ scopes.push({
284
+ scope,
285
+ label: stringFromQualityRecord(value, 'label') ?? scope,
286
+ metrics: metricSet,
287
+ });
288
+ }
289
+ }
290
+ return scopes;
291
+ }
292
+ function buildReleaseQualityPoints(releaseEvents, overallLabel) {
160
293
  const points = [];
161
- for (const task of tasks) {
162
- if (task.quality) {
294
+ for (const event of releaseEvents) {
295
+ if (!event.releaseVersionId)
296
+ continue;
297
+ const releaseVersionKind = event.releaseVersionKind ?? (event.laneType === 'production' ? 'production' : 'candidate');
298
+ for (const scope of readReleaseQualityScopes(event.metrics, overallLabel)) {
163
299
  points.push({
164
- id: task.id,
165
- x: '',
166
- name: task.name,
167
- promptVersionLabel: task.promptVersionLabel ?? '—',
168
- modelName: task.modelName ?? '—',
169
- releaseVariantLabel: task.releaseVariantLabel,
170
- submitted: task.progress.submitted,
171
- total: task.progress.total,
172
- matched: task.quality.matched,
173
- mismatched: task.quality.mismatched,
174
- updatedAt: task.updatedAt,
175
- score: toPercentPoint(task.quality.score),
300
+ id: `${event.id}:${scope.scope}`,
301
+ eventId: event.id,
302
+ eventLabel: event.operation,
303
+ scope: scope.scope,
304
+ scopeLabel: scope.label,
305
+ releaseVersionId: event.releaseVersionId,
306
+ releaseVersionKind,
307
+ lane: qualityVersionLane(releaseVersionKind),
308
+ promptVersionLabel: event.promptVersionLabel ?? formatShortId(event.promptVersionId),
309
+ modelName: event.modelName ?? formatShortId(event.modelId),
310
+ releaseVersionLabel: event.releaseVersionLabel ?? formatShortId(event.releaseVersionId),
311
+ sampleCount: scope.metrics.sampleCount,
312
+ createdAt: event.createdAt,
313
+ updatedAt: event.updatedAt,
314
+ recall: scope.metrics.recall,
315
+ precision: scope.metrics.precision,
316
+ f1: scope.metrics.f1,
317
+ accuracy: scope.metrics.accuracy,
176
318
  });
177
319
  }
178
320
  }
179
- return points
180
- .sort((left, right) => timeValue(left.updatedAt) - timeValue(right.updatedAt))
181
- .map((point, index) => ({ ...point, x: `#${index + 1}` }));
321
+ return points.sort((left, right) => timeValue(left.createdAt) - timeValue(right.createdAt));
322
+ }
323
+ function buildQualityChartSeries(points, metrics, scopes) {
324
+ const sortedPoints = [...points].sort((left, right) => timeValue(left.createdAt) - timeValue(right.createdAt));
325
+ const eventOrder = new Map();
326
+ for (const point of sortedPoints) {
327
+ if (!eventOrder.has(point.eventId))
328
+ eventOrder.set(point.eventId, eventOrder.size + 1);
329
+ }
330
+ const series = [];
331
+ for (const scope of scopes) {
332
+ const scopedPoints = sortedPoints.filter((point) => point.scope === scope.id);
333
+ if (scopedPoints.length === 0)
334
+ continue;
335
+ for (const metric of metrics) {
336
+ const seriesId = `${scope.id}:${metric.id}`;
337
+ const seriesLabel = `${scope.label} · ${metric.label}`;
338
+ const color = QUALITY_SERIES_COLORS[series.length % QUALITY_SERIES_COLORS.length] ?? 'var(--primary)';
339
+ series.push({
340
+ id: seriesId,
341
+ label: seriesLabel,
342
+ color,
343
+ metric: metric.id,
344
+ metricLabel: metric.label,
345
+ scope: scope.id,
346
+ scopeLabel: scope.label,
347
+ points: scopedPoints.map((point) => ({
348
+ ...point,
349
+ xIndex: eventOrder.get(point.eventId) ?? 0,
350
+ xLabel: `#${eventOrder.get(point.eventId) ?? 0}`,
351
+ metric: metric.id,
352
+ metricLabel: metric.label,
353
+ seriesId,
354
+ seriesLabel,
355
+ seriesColor: color,
356
+ value: point[metric.id],
357
+ })),
358
+ });
359
+ }
360
+ }
361
+ return series;
362
+ }
363
+ function buildQualityChartAxisData(series) {
364
+ const points = new Map();
365
+ for (const item of series) {
366
+ for (const point of item.points) {
367
+ points.set(point.eventId, point);
368
+ }
369
+ }
370
+ return [...points.values()].sort((left, right) => left.xIndex - right.xIndex);
371
+ }
372
+ function buildQualityVersionOptions(points) {
373
+ const versions = new Map();
374
+ for (const point of points) {
375
+ const existing = versions.get(point.releaseVersionId);
376
+ const latestAt = latestTime([existing?.latestAt, point.updatedAt ?? point.createdAt]);
377
+ if (existing) {
378
+ versions.set(point.releaseVersionId, {
379
+ ...existing,
380
+ pointCount: existing.pointCount + 1,
381
+ latestAt,
382
+ });
383
+ continue;
384
+ }
385
+ versions.set(point.releaseVersionId, {
386
+ id: point.releaseVersionId,
387
+ label: point.releaseVersionLabel,
388
+ kind: point.releaseVersionKind,
389
+ promptVersion: point.promptVersionLabel,
390
+ model: point.modelName,
391
+ pointCount: 1,
392
+ latestAt,
393
+ });
394
+ }
395
+ return [...versions.values()].sort((left, right) => left.label.localeCompare(right.label, undefined, { numeric: true }));
396
+ }
397
+ function buildQualityScopeOptions(points) {
398
+ const scopes = new Map();
399
+ for (const point of points) {
400
+ scopes.set(point.scope, { id: point.scope, label: point.scopeLabel });
401
+ }
402
+ return [...scopes.values()].sort((left, right) => {
403
+ if (left.id === QUALITY_OVERALL_SCOPE)
404
+ return -1;
405
+ if (right.id === QUALITY_OVERALL_SCOPE)
406
+ return 1;
407
+ return left.label.localeCompare(right.label, undefined, { numeric: true });
408
+ });
409
+ }
410
+ function resolveActiveQualityScopes(selectedScopes, options) {
411
+ if (selectedScopes) {
412
+ const selectedSet = new Set(selectedScopes);
413
+ const activeOptions = options.filter((option) => selectedSet.has(option.id));
414
+ if (activeOptions.length > 0)
415
+ return activeOptions;
416
+ }
417
+ const fallback = options.find((option) => option.id === QUALITY_OVERALL_SCOPE) ?? options[0] ?? null;
418
+ return fallback ? [fallback] : [];
419
+ }
420
+ function resolveActiveQualityMetrics(selectedMetrics, options) {
421
+ if (selectedMetrics) {
422
+ const selectedSet = new Set(selectedMetrics);
423
+ const activeOptions = options.filter((option) => selectedSet.has(option.id));
424
+ if (activeOptions.length > 0)
425
+ return activeOptions;
426
+ }
427
+ return options.filter((option) => option.id === 'f1');
428
+ }
429
+ function filterQualityPoints(points, versionIds, scopes) {
430
+ if (scopes.length === 0)
431
+ return [];
432
+ const versionSet = new Set(versionIds);
433
+ const scopeSet = new Set(scopes.map((scope) => scope.id));
434
+ return points.filter((point) => versionSet.has(point.releaseVersionId) && scopeSet.has(point.scope));
435
+ }
436
+ function toggleQualityFilterValue(values, value) {
437
+ if (!values.includes(value))
438
+ return [...values, value];
439
+ if (values.length <= 1)
440
+ return values;
441
+ return values.filter((item) => item !== value);
442
+ }
443
+ function normalizeQualitySearch(value) {
444
+ return value.trim().toLowerCase();
445
+ }
446
+ function qualitySearchIncludes(query, parts) {
447
+ return parts
448
+ .filter((part) => part !== null && part !== undefined)
449
+ .join(' ')
450
+ .toLowerCase()
451
+ .includes(query);
182
452
  }
183
453
  function comparisonFromDelta(current, previous, formatter, label, unit) {
184
454
  const delta = current - previous;
@@ -282,6 +552,41 @@ function CompactMetricGroup({ title, items, className, }) {
282
552
  return (_jsxs("div", { className: "min-w-0", children: [_jsxs("dt", { className: "flex items-center gap-1.5 text-[12px] text-muted-foreground", children: [_jsx("span", { className: cn('size-1.5 shrink-0 rounded-full', COMPACT_METRIC_DOT_CLASS[tone]) }), _jsx("span", { className: "truncate", children: item.label })] }), _jsx("dd", { className: cn('mt-1 truncate text-[20px] font-semibold leading-none text-foreground', tone === 'danger' && 'text-destructive'), children: item.value })] }, item.label));
283
553
  }) })] }));
284
554
  }
555
+ function ReleaseLineDeleteImpactPanel({ impact, loading, }) {
556
+ const { t } = useI18n();
557
+ if (loading && !impact) {
558
+ return (_jsx("div", { className: "rounded-lg border bg-muted/35 px-3 py-2 text-[12px] text-muted-foreground", "data-testid": "release-line-delete-impact", children: t('releases.deleteImpact.loading') }));
559
+ }
560
+ if (!impact)
561
+ return null;
562
+ const items = [
563
+ {
564
+ key: 'events',
565
+ label: t('releases.deleteImpact.events'),
566
+ hint: t('releases.deleteImpact.eventsHint'),
567
+ count: impact.events.length,
568
+ },
569
+ {
570
+ key: 'versions',
571
+ label: t('releases.deleteImpact.versions'),
572
+ hint: t('releases.deleteImpact.versionsHint'),
573
+ count: impact.versions.length,
574
+ },
575
+ {
576
+ key: 'run-results',
577
+ label: t('releases.deleteImpact.runResults'),
578
+ hint: t('releases.deleteImpact.runResultsHint'),
579
+ count: impact.runResults,
580
+ },
581
+ {
582
+ key: 'annotation-tasks',
583
+ label: t('releases.deleteImpact.annotationTasks'),
584
+ hint: t('releases.deleteImpact.annotationTasksHint'),
585
+ count: impact.annotationTasks.length,
586
+ },
587
+ ];
588
+ return (_jsx("div", { className: "rounded-lg border border-destructive/25 bg-background p-3", "data-testid": "release-line-delete-impact", children: impact.total === 0 ? (_jsx("div", { className: "text-[12px] text-muted-foreground", children: t('releases.deleteImpact.empty') })) : (_jsx("div", { className: "grid gap-2 sm:grid-cols-4", children: items.map((item) => (_jsxs("div", { className: "rounded-md border bg-muted/30 px-3 py-2", "data-testid": `release-line-delete-impact-${item.key}`, children: [_jsx("div", { className: "text-[11px] font-medium text-muted-foreground", children: item.label }), _jsx("div", { className: "mt-1 font-mono text-[18px] font-semibold leading-none", children: formatCount(item.count) }), _jsx("div", { className: "mt-1 truncate text-[10.5px] text-muted-foreground", children: item.hint })] }, item.key))) })) }));
589
+ }
285
590
  export function ReleaseLineDetailPage({ projectId, releaseLineId }) {
286
591
  const router = useRouter();
287
592
  const pathname = usePathname();
@@ -293,17 +598,35 @@ export function ReleaseLineDetailPage({ projectId, releaseLineId }) {
293
598
  const line = useMemo(() => listQuery.data.find((item) => item.id === lineId || getReleaseLineId(item.promptId, item.inputConnectorId) === lineId) ?? null, [lineId, listQuery.data]);
294
599
  const historyQuery = useProductionReleaseHistory(projectId, line?.promptId ?? '');
295
600
  const releaseLineEventsQuery = useReleaseLineEvents(projectId, line?.id ?? '');
296
- const stopProductionMutation = useStopProductionRelease(projectId);
601
+ const stopLineMutation = useStopReleaseLine(projectId);
602
+ const startLineMutation = useStartReleaseLine(projectId);
603
+ const archiveLineMutation = useArchiveReleaseLine(projectId);
604
+ const unarchiveLineMutation = useUnarchiveReleaseLine(projectId);
605
+ const deleteLineMutation = useDeleteReleaseLine(projectId);
606
+ const stopCanaryMutation = useStopCanaryRelease(projectId);
297
607
  const updateTrafficRatioMutation = useUpdateReleaseLineTrafficRatio(projectId);
608
+ const promoteCanaryMutation = usePromoteReleaseLineCanary(projectId);
298
609
  const updateRunConfigMutation = useUpdateReleaseLineRunConfig(projectId);
610
+ const updateOutputRouteMutation = useUpdateReleaseLineOutputRoute(projectId);
611
+ const updateInputRouteMutation = useUpdateReleaseLineInputRoute(projectId);
299
612
  const modelQuery = useProjectModels(projectId);
613
+ const outputConnectorsQuery = useConnectors(projectId, { direction: 'output' });
300
614
  const tab = resolveTab(searchParams.get('tab'));
301
- const selectedReleaseVariantId = searchParams.get('variant') ?? undefined;
615
+ const selectedReleaseVersionId = searchParams.get('version') ?? undefined;
302
616
  const [stopDialogOpen, setStopDialogOpen] = useState(false);
303
617
  const [stopConfirmationText, setStopConfirmationText] = useState('');
618
+ const [archiveDialogOpen, setArchiveDialogOpen] = useState(false);
619
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
620
+ const [deleteState, setDeleteState] = useState({ lineId: '', confirmationText: '', error: null });
621
+ const activeDeleteLineId = line?.id ?? '';
622
+ const deleteConfirmationText = deleteState.lineId === activeDeleteLineId ? deleteState.confirmationText : '';
623
+ const deleteError = deleteState.lineId === activeDeleteLineId ? deleteState.error : null;
624
+ const deleteImpactQuery = useReleaseLineDeleteImpact(projectId, deleteDialogOpen ? activeDeleteLineId : '');
304
625
  const productionReleaseName = useMemo(() => getReleaseStopConfirmationName(line), [line]);
305
626
  const canConfirmStopProduction = stopConfirmationText === productionReleaseName && productionReleaseName.length > 0;
306
- const canAddCanary = Boolean(line && line.production?.currentEvent?.status === 'running' && !line.canary);
627
+ const canConfirmDelete = Boolean(line && deleteConfirmationText === line.label);
628
+ const canAddCanary = Boolean(line && line.production?.currentEvent?.status === 'running');
629
+ const canaryActionPending = stopCanaryMutation.isPending || promoteCanaryMutation.isPending;
307
630
  const isLive = hasRunningRelease(line);
308
631
  const onAutoRefreshTick = useCallback(async () => {
309
632
  await Promise.all([
@@ -318,10 +641,18 @@ export function ReleaseLineDetailPage({ projectId, releaseLineId }) {
318
641
  onTick: onAutoRefreshTick,
319
642
  });
320
643
  useEffect(() => {
321
- if (searchParams.get('tab') !== 'annotation')
644
+ const rawTab = searchParams.get('tab');
645
+ const normalizedTab = rawTab === 'annotation'
646
+ ? 'quality'
647
+ : rawTab === 'variants' || rawTab === 'versions'
648
+ ? 'history'
649
+ : rawTab === 'delete'
650
+ ? 'settings'
651
+ : null;
652
+ if (!normalizedTab)
322
653
  return;
323
654
  const params = new URLSearchParams(searchParams.toString());
324
- params.set('tab', 'quality');
655
+ params.set('tab', normalizedTab);
325
656
  router.replace(`${pathname}?${params.toString()}`, { scroll: false });
326
657
  }, [pathname, router, searchParams]);
327
658
  const selectTab = useCallback((next) => {
@@ -331,7 +662,7 @@ export function ReleaseLineDetailPage({ projectId, releaseLineId }) {
331
662
  else
332
663
  params.set('tab', next);
333
664
  if (next !== 'results')
334
- params.delete('variant');
665
+ params.delete('version');
335
666
  const query = params.toString();
336
667
  router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
337
668
  }, [pathname, router, searchParams]);
@@ -343,22 +674,22 @@ export function ReleaseLineDetailPage({ projectId, releaseLineId }) {
343
674
  return (_jsx(Main, { fixed: true, className: "bg-muted/35", children: _jsx("div", { className: "rounded-lg border bg-card p-10 text-center text-sm text-muted-foreground", children: t('releases.detail.notFound') }) }));
344
675
  }
345
676
  function openStopProductionDialog() {
346
- if (!line?.production?.currentEvent)
677
+ if (!line || line.status !== 'running')
347
678
  return;
348
679
  setStopConfirmationText('');
349
680
  setStopDialogOpen(true);
350
681
  }
351
682
  function closeStopProductionDialog() {
352
- if (stopProductionMutation.isPending)
683
+ if (stopLineMutation.isPending)
353
684
  return;
354
685
  setStopDialogOpen(false);
355
686
  setStopConfirmationText('');
356
687
  }
357
688
  function confirmStopProduction() {
358
- if (!line?.production?.currentEvent || !canConfirmStopProduction)
689
+ if (!line || !canConfirmStopProduction)
359
690
  return;
360
- stopProductionMutation.mutate({
361
- eventId: line.production.currentEvent.id,
691
+ stopLineMutation.mutate({
692
+ releaseLineId: line.id,
362
693
  body: { reason: t('releases.detail.stopReason') },
363
694
  }, {
364
695
  onSuccess: () => {
@@ -367,22 +698,116 @@ export function ReleaseLineDetailPage({ projectId, releaseLineId }) {
367
698
  },
368
699
  });
369
700
  }
701
+ function startReleaseLine() {
702
+ if (!line || line.status !== 'stopped')
703
+ return;
704
+ startLineMutation.mutate({
705
+ releaseLineId: line.id,
706
+ body: { reason: t('releases.detail.startReason') },
707
+ });
708
+ }
709
+ function openArchiveDialog() {
710
+ if (!line || line.status !== 'stopped')
711
+ return;
712
+ setArchiveDialogOpen(true);
713
+ }
714
+ function closeArchiveDialog() {
715
+ if (archiveLineMutation.isPending)
716
+ return;
717
+ setArchiveDialogOpen(false);
718
+ }
719
+ function confirmArchiveReleaseLine() {
720
+ if (!line || line.status !== 'stopped')
721
+ return;
722
+ archiveLineMutation.mutate({
723
+ releaseLineId: line.id,
724
+ body: { reason: t('releases.detail.archiveReason') },
725
+ }, {
726
+ onSuccess: () => setArchiveDialogOpen(false),
727
+ });
728
+ }
729
+ function unarchiveReleaseLine() {
730
+ if (!line || line.status !== 'archived')
731
+ return;
732
+ unarchiveLineMutation.mutate({
733
+ releaseLineId: line.id,
734
+ body: { reason: t('releases.detail.unarchiveReason') },
735
+ });
736
+ }
737
+ function openDeleteDialog() {
738
+ if (!line)
739
+ return;
740
+ setDeleteState({ lineId: line.id, confirmationText: '', error: null });
741
+ setDeleteDialogOpen(true);
742
+ }
743
+ function closeDeleteDialog() {
744
+ if (deleteLineMutation.isPending)
745
+ return;
746
+ setDeleteDialogOpen(false);
747
+ if (line)
748
+ setDeleteState({ lineId: line.id, confirmationText: '', error: null });
749
+ }
750
+ async function confirmDeleteReleaseLine() {
751
+ if (!line || !canConfirmDelete)
752
+ return;
753
+ setDeleteState((current) => ({
754
+ lineId: line.id,
755
+ confirmationText: current.lineId === line.id ? current.confirmationText : '',
756
+ error: null,
757
+ }));
758
+ try {
759
+ await deleteLineMutation.mutateAsync({
760
+ releaseLineId: line.id,
761
+ body: {
762
+ confirmationName: line.label,
763
+ reason: t('releases.detail.deleteReason'),
764
+ },
765
+ });
766
+ setDeleteDialogOpen(false);
767
+ router.push('/releases');
768
+ }
769
+ catch (error) {
770
+ setDeleteState((current) => ({
771
+ lineId: line.id,
772
+ confirmationText: current.lineId === line.id ? current.confirmationText : '',
773
+ error: getApiErrorMessage(error) ?? t('releases.detail.deleteFailed'),
774
+ }));
775
+ }
776
+ }
370
777
  function openAddCanaryPage() {
371
778
  if (!line || !canAddCanary)
372
779
  return;
373
780
  router.push(`/releases/new?mode=canary&line=${encodeURIComponent(line.id)}`);
374
781
  }
375
- return (_jsxs(Main, { fixed: true, className: "gap-5 overflow-auto bg-muted/35 pb-8", children: [_jsxs("div", { className: "mx-auto flex w-full max-w-[1760px] flex-col gap-5", "data-testid": "release-line-detail-page", children: [_jsxs("div", { className: "flex flex-wrap items-start justify-between gap-4", children: [_jsx("div", { className: "min-w-0", children: _jsxs("div", { className: "flex min-w-0 flex-wrap items-center gap-2", children: [_jsx("h1", { className: "truncate text-[22px] font-semibold leading-tight", children: line.promptName }), _jsx("span", { "data-testid": "release-line-detail-status", className: "sr-only", children: line.production?.currentEvent?.status ?? line.canary?.status ?? line.status })] }) }), _jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [line.production?.currentEvent?.status === 'running' ? (_jsxs(Button, { variant: "outline", onClick: openStopProductionDialog, disabled: stopProductionMutation.isPending, className: "text-destructive hover:text-destructive", "data-testid": "release-line-detail-stop", children: [_jsx(Square, { className: "size-4" }), t('releases.detail.action.stopProduction')] })) : null, canAddCanary ? (_jsxs(Button, { onClick: openAddCanaryPage, children: [_jsx(Plus, { className: "size-4" }), t('releases.detail.action.addCanary')] })) : null] })] }), _jsx(ReleaseTopologyCanvas, { line: line, models: modelQuery.data?.data ?? [], modelsLoading: modelQuery.isLoading, onUpdateTrafficRatio: (_canary, trafficRatio) => updateTrafficRatioMutation.mutateAsync({
782
+ return (_jsxs(Main, { fixed: true, className: "gap-5 overflow-auto bg-muted/35 pb-8", children: [_jsxs("div", { className: "mx-auto flex w-full max-w-[1760px] flex-col gap-5", "data-testid": "release-line-detail-page", children: [_jsxs("div", { className: "flex flex-wrap items-start justify-between gap-4", children: [_jsx("div", { className: "min-w-0", children: _jsxs("div", { className: "flex min-w-0 flex-wrap items-center gap-2", children: [_jsx("h1", { className: "truncate text-[22px] font-semibold leading-tight", children: line.promptName }), _jsx("span", { "data-testid": "release-line-detail-status", className: "sr-only", children: line.status })] }) }), _jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [line.status === 'running' ? (_jsxs(Button, { variant: "outline", onClick: openStopProductionDialog, disabled: stopLineMutation.isPending, className: "text-destructive hover:text-destructive", "data-testid": "release-line-detail-stop", children: [_jsx(Square, { className: "size-4" }), t('releases.detail.action.stopProduction')] })) : null, line.status === 'stopped' ? (_jsxs(Button, { variant: "outline", onClick: startReleaseLine, disabled: startLineMutation.isPending, "data-testid": "release-line-detail-start", children: [_jsx(Play, { className: "size-4" }), startLineMutation.isPending ? t('releases.detail.action.starting') : t('releases.detail.action.start')] })) : null, line.status === 'stopped' ? (_jsxs(Button, { variant: "outline", onClick: openArchiveDialog, disabled: archiveLineMutation.isPending, "data-testid": "release-line-detail-archive", children: [_jsx(Archive, { className: "size-4" }), archiveLineMutation.isPending
783
+ ? t('releases.detail.action.archiving')
784
+ : t('releases.detail.action.archive')] })) : null, line.status === 'archived' ? (_jsxs(Button, { variant: "outline", onClick: unarchiveReleaseLine, disabled: unarchiveLineMutation.isPending, "data-testid": "release-line-detail-unarchive", children: [_jsx(RotateCcw, { className: "size-4" }), unarchiveLineMutation.isPending
785
+ ? t('releases.detail.action.unarchiving')
786
+ : t('releases.detail.action.unarchive')] })) : null, canAddCanary ? (_jsxs(Button, { onClick: openAddCanaryPage, children: [_jsx(Plus, { className: "size-4" }), line.canary ? t('releases.detail.action.replaceCanary') : t('releases.detail.action.addCanary')] })) : null] })] }), _jsx(ReleaseTopologyCanvas, { line: line, models: modelQuery.data?.data ?? [], modelsLoading: modelQuery.isLoading, outputConnectors: outputConnectorsQuery.data?.data ?? [], outputConnectorsLoading: outputConnectorsQuery.isLoading, onUpdateTrafficRatio: (_canary, trafficRatio) => updateTrafficRatioMutation.mutateAsync({
376
787
  releaseLineId: line.id,
377
788
  body: { trafficRatio },
378
789
  }), trafficRatioPending: updateTrafficRatioMutation.isPending, onUpdateRunConfig: (body) => updateRunConfigMutation.mutateAsync({
379
790
  releaseLineId: line.id,
380
791
  body,
381
- }), runConfigPending: updateRunConfigMutation.isPending, onAddCanary: canAddCanary ? openAddCanaryPage : undefined }), _jsx("div", { className: "inline-flex w-fit flex-wrap gap-0.5 rounded-lg border bg-card p-1", children: DETAIL_TABS.map((item) => (_jsx("button", { type: "button", onClick: () => selectTab(item.value), className: cn('rounded-md px-3.5 py-1.5 text-[13px] font-medium transition-colors', tab === item.value
792
+ }), runConfigPending: updateRunConfigMutation.isPending, onUpdateOutputRoute: (body) => updateOutputRouteMutation.mutateAsync({
793
+ releaseLineId: line.id,
794
+ body,
795
+ }), outputRoutePending: updateOutputRouteMutation.isPending, onUpdateInputRoute: (body) => updateInputRouteMutation.mutateAsync({
796
+ releaseLineId: line.id,
797
+ body,
798
+ }), inputRoutePending: updateInputRouteMutation.isPending, onAddCanary: canAddCanary ? openAddCanaryPage : undefined, onStopCanary: (canary) => stopCanaryMutation.mutateAsync(canary.id), onPromoteCanary: () => promoteCanaryMutation.mutateAsync(line.id), canaryActionPending: canaryActionPending }), _jsx("div", { className: "inline-flex w-fit flex-wrap gap-0.5 rounded-lg border bg-card p-1", children: DETAIL_TABS.map((item) => (_jsx("button", { type: "button", onClick: () => selectTab(item.value), className: cn('rounded-md px-3.5 py-1.5 text-[13px] font-medium transition-colors', tab === item.value
382
799
  ? 'bg-muted font-semibold text-foreground'
383
- : 'text-muted-foreground hover:text-foreground'), children: t(item.key) }, item.value))) }), tab === 'monitoring' ? (_jsx(MonitoringPane, { projectId: projectId, line: line, releaseEvents: releaseLineEventsQuery.data?.data ?? [] })) : null, tab === 'variants' ? (_jsx(VariantsPane, { line: line, releaseEvents: releaseLineEventsQuery.data?.data ?? [], loading: releaseLineEventsQuery.isLoading })) : null, tab === 'results' ? (_jsx(ResultsPane, { projectId: projectId, line: line, releaseEvents: releaseLineEventsQuery.data?.data ?? [], initialReleaseVariantId: selectedReleaseVariantId })) : null, tab === 'quality' ? _jsx(QualityMetricsPane, { projectId: projectId, line: line }) : null, tab === 'history' ? (_jsx(HistoryPane, { line: line, productionHistory: historyQuery.data?.data ?? [], releaseEvents: releaseLineEventsQuery.data?.data ?? [], loading: historyQuery.isLoading || releaseLineEventsQuery.isLoading })) : null] }), _jsx(Dialog, { open: stopDialogOpen, onOpenChange: (open) => (open ? setStopDialogOpen(true) : closeStopProductionDialog()), children: _jsxs(DialogContent, { "data-testid": "release-stop-production-dialog", children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: t('releases.detail.stopDialog.title') }), _jsx(DialogDescription, { children: t('releases.detail.stopDialog.description') })] }), _jsxs("div", { className: "rounded-lg border bg-muted/40 p-3", children: [_jsx("div", { className: "text-[11.5px] font-medium text-muted-foreground", children: t('releases.detail.stopDialog.releaseName') }), _jsx("div", { className: "mt-1 break-all font-mono text-[13px] font-semibold", children: productionReleaseName || '—' })] }), _jsxs("div", { className: "space-y-2", children: [_jsx("label", { htmlFor: "release-stop-production-name", className: "text-[12.5px] font-medium", children: t('releases.detail.stopDialog.inputLabel') }), _jsx(Input, { id: "release-stop-production-name", value: stopConfirmationText, onChange: (event) => setStopConfirmationText(event.target.value), placeholder: t('releases.detail.stopDialog.inputPlaceholder').replace('{name}', productionReleaseName), autoComplete: "off" }), stopConfirmationText.length > 0 && !canConfirmStopProduction ? (_jsx("p", { className: "text-[12px] text-destructive", children: t('releases.detail.stopDialog.mismatch') })) : null] }), _jsxs(DialogFooter, { children: [_jsx(Button, { type: "button", variant: "outline", onClick: closeStopProductionDialog, disabled: stopProductionMutation.isPending, children: t('common.cancel') }), _jsxs(Button, { type: "button", variant: "destructive", onClick: confirmStopProduction, disabled: !canConfirmStopProduction || stopProductionMutation.isPending, children: [_jsx(Square, { className: "size-4" }), stopProductionMutation.isPending
800
+ : 'text-muted-foreground hover:text-foreground'), children: t(item.key) }, item.value))) }), tab === 'monitoring' ? (_jsx(MonitoringPane, { projectId: projectId, line: line, releaseEvents: releaseLineEventsQuery.data?.data ?? [] })) : null, tab === 'results' ? (_jsx(ResultsPane, { projectId: projectId, line: line, releaseEvents: releaseLineEventsQuery.data?.data ?? [], initialReleaseVersionId: selectedReleaseVersionId })) : null, tab === 'quality' ? (_jsx(QualityMetricsPane, { line: line, releaseEvents: releaseLineEventsQuery.data?.data ?? [] })) : null, tab === 'history' ? (_jsx(HistoryPane, { projectId: projectId, line: line, productionHistory: historyQuery.data?.data ?? [], releaseEvents: releaseLineEventsQuery.data?.data ?? [], loading: historyQuery.isLoading || releaseLineEventsQuery.isLoading })) : null, tab === 'settings' ? (_jsxs("section", { className: "rounded-lg border bg-card", "data-testid": "release-line-settings-tab", children: [_jsxs("div", { className: "border-b px-4 py-3", children: [_jsx("div", { className: "text-[13px] font-semibold", children: t('releases.detail.settings.title') }), _jsx("p", { className: "mt-1 text-[12px] text-muted-foreground", children: t('releases.detail.settings.description') })] }), _jsx("div", { className: "p-4", children: _jsxs("div", { className: "flex flex-col gap-3 rounded-md border border-destructive/30 bg-destructive/5 p-4 sm:flex-row sm:items-center sm:justify-between", children: [_jsxs("div", { className: "min-w-0", children: [_jsxs("div", { className: "flex items-center gap-2 text-[13px] font-semibold text-destructive", children: [_jsx(AlertTriangle, { className: "size-4" }), t('releases.detail.delete.dangerTitle')] }), _jsx("p", { className: "mt-1 max-w-3xl text-[12px] text-muted-foreground", children: t('releases.detail.delete.description') })] }), _jsxs(Button, { type: "button", variant: "destructive", onClick: openDeleteDialog, "data-testid": "release-line-delete-open", children: [_jsx(Trash2, { className: "size-4" }), t('releases.detail.delete.open')] })] }) })] })) : null] }), _jsx(Dialog, { open: stopDialogOpen, onOpenChange: (open) => (open ? setStopDialogOpen(true) : closeStopProductionDialog()), children: _jsxs(DialogContent, { "data-testid": "release-stop-production-dialog", children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: t('releases.detail.stopDialog.title') }), _jsx(DialogDescription, { children: t('releases.detail.stopDialog.description') })] }), _jsxs("div", { className: "rounded-lg border bg-muted/40 p-3", children: [_jsx("div", { className: "text-[11.5px] font-medium text-muted-foreground", children: t('releases.detail.stopDialog.releaseName') }), _jsx("div", { className: "mt-1 break-all font-mono text-[13px] font-semibold", children: productionReleaseName || '—' })] }), _jsxs("div", { className: "space-y-2", children: [_jsx("label", { htmlFor: "release-stop-production-name", className: "text-[12.5px] font-medium", children: t('releases.detail.stopDialog.inputLabel') }), _jsx(Input, { id: "release-stop-production-name", value: stopConfirmationText, onChange: (event) => setStopConfirmationText(event.target.value), placeholder: t('releases.detail.stopDialog.inputPlaceholder').replace('{name}', productionReleaseName), autoComplete: "off" }), stopConfirmationText.length > 0 && !canConfirmStopProduction ? (_jsx("p", { className: "text-[12px] text-destructive", children: t('releases.detail.stopDialog.mismatch') })) : null] }), _jsxs(DialogFooter, { children: [_jsx(Button, { type: "button", variant: "outline", onClick: closeStopProductionDialog, disabled: stopLineMutation.isPending, children: t('common.cancel') }), _jsxs(Button, { type: "button", variant: "destructive", onClick: confirmStopProduction, disabled: !canConfirmStopProduction || stopLineMutation.isPending, "data-testid": "release-stop-production-confirm", children: [_jsx(Square, { className: "size-4" }), stopLineMutation.isPending
384
801
  ? t('releases.detail.stopDialog.stopping')
385
- : t('releases.detail.stopDialog.confirm')] })] })] }) })] }));
802
+ : t('releases.detail.stopDialog.confirm')] })] })] }) }), _jsx(Dialog, { open: archiveDialogOpen, onOpenChange: (open) => (open ? setArchiveDialogOpen(true) : closeArchiveDialog()), children: _jsxs(DialogContent, { "data-testid": "release-line-detail-archive-dialog", children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: t('releases.archiveDialog.title') }), _jsx(DialogDescription, { children: t('releases.archiveDialog.description') })] }), _jsxs("div", { className: "rounded-lg border bg-muted/40 p-3", children: [_jsx("div", { className: "text-[11.5px] font-medium text-muted-foreground", children: t('releases.detail.stopDialog.releaseName') }), _jsx("div", { className: "mt-1 break-all font-mono text-[13px] font-semibold", children: line.label })] }), _jsxs(DialogFooter, { children: [_jsx(Button, { type: "button", variant: "outline", onClick: closeArchiveDialog, disabled: archiveLineMutation.isPending, children: t('common.cancel') }), _jsxs(Button, { type: "button", onClick: confirmArchiveReleaseLine, disabled: archiveLineMutation.isPending, "data-testid": "release-line-detail-archive-confirm", children: [_jsx(Archive, { className: "size-4" }), archiveLineMutation.isPending
803
+ ? t('releases.archiveDialog.archiving')
804
+ : t('releases.archiveDialog.confirm')] })] })] }) }), _jsx(Dialog, { open: deleteDialogOpen, onOpenChange: (open) => (open ? openDeleteDialog() : closeDeleteDialog()), children: _jsxs(DialogContent, { "data-testid": "release-line-delete-dialog", children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: t('releases.detail.delete.title') }), _jsx(DialogDescription, { children: t('releases.detail.delete.dialogDescription') })] }), _jsxs("div", { className: "rounded-lg border border-destructive/30 bg-destructive/5 p-3", children: [_jsx("div", { className: "text-[11.5px] font-medium text-muted-foreground", children: t('releases.detail.delete.releaseName') }), _jsx("div", { className: "mt-1 break-all font-mono text-[13px] font-semibold", children: line.label })] }), _jsx(ReleaseLineDeleteImpactPanel, { impact: deleteImpactQuery.data, loading: deleteImpactQuery.isLoading || deleteImpactQuery.isFetching }), _jsxs("div", { className: "space-y-2", children: [_jsx("label", { htmlFor: "release-line-delete-name", className: "text-[12.5px] font-medium", children: t('releases.detail.delete.inputLabel') }), _jsx(Input, { id: "release-line-delete-name", value: deleteConfirmationText, onChange: (event) => setDeleteState({
805
+ lineId: line.id,
806
+ confirmationText: event.target.value,
807
+ error: null,
808
+ }), placeholder: t('releases.detail.delete.inputPlaceholder').replace('{name}', line.label), autoComplete: "off" }), deleteConfirmationText.length > 0 && !canConfirmDelete ? (_jsx("p", { className: "text-[12px] text-destructive", children: t('releases.detail.delete.mismatch') })) : null] }), deleteError ? (_jsx("div", { className: "rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive", children: deleteError })) : null, _jsxs(DialogFooter, { children: [_jsx(Button, { type: "button", variant: "outline", onClick: closeDeleteDialog, disabled: deleteLineMutation.isPending, children: t('common.cancel') }), _jsxs(Button, { type: "button", variant: "destructive", onClick: () => void confirmDeleteReleaseLine(), disabled: !canConfirmDelete || deleteLineMutation.isPending, "data-testid": "release-line-delete-confirm", children: [_jsx(Trash2, { className: "size-4" }), deleteLineMutation.isPending
809
+ ? t('releases.detail.delete.deleting')
810
+ : t('releases.detail.delete.confirm')] })] })] }) })] }));
386
811
  }
387
812
  function MonitoringPane({ projectId, line, releaseEvents, }) {
388
813
  const { t, language } = useI18n();
@@ -505,58 +930,57 @@ function MonitoringPane({ projectId, line, releaseEvents, }) {
505
930
  },
506
931
  ] })] }) }), _jsxs("section", { className: "space-y-3", "aria-label": t('releases.detail.metric.engineering'), children: [_jsx("h3", { className: "text-[14px] font-semibold", children: t('releases.detail.metric.engineering') }), _jsxs("div", { className: "grid grid-cols-1 gap-4 xl:grid-cols-2 2xl:grid-cols-4", children: [_jsx(BigChartCard, { title: t('releases.detail.metric.rpm'), icon: _jsx(Gauge, { className: "size-4", strokeWidth: 2.2 }), iconBg: "var(--status-pending-bg)", iconFg: "var(--status-pending-fg)", total: formatRateValue(timeseriesMax.rpm), comparison: comparisonFromDelta(timeseriesMax.rpm, stats?.rpmPeak.previous ?? 0, formatRateValue, vsPreviousPeriodLabel), subtitle: t('monitoring.delta.rpmSubtitle'), data: pickTimeseries('rpm'), yTickFormatter: formatRateValue, legendFormatter: formatRateValue, bySource: stats?.rpmPeak.bySource ?? EMPTY_BY_SOURCE, sourceLabels: sourceLabels, sourceKeys: RELEASE_MONITORING_SOURCE_KEYS, ...chartLabels }), _jsx(BigChartCard, { title: t('releases.detail.metric.tpm'), icon: _jsx(Activity, { className: "size-4", strokeWidth: 2.2 }), iconBg: "var(--src-iter-soft)", iconFg: "var(--src-iter-fg)", total: formatBigNumber(timeseriesMax.tpm), comparison: comparisonFromDelta(timeseriesMax.tpm, stats?.tpmPeak.previous ?? 0, formatBigNumber, vsPreviousPeriodLabel), subtitle: t('monitoring.delta.tpmSubtitle'), data: pickTimeseries('tpm'), yTickFormatter: formatBigNumber, legendFormatter: formatBigNumber, bySource: stats?.tpmPeak.bySource ?? EMPTY_BY_SOURCE, sourceLabels: sourceLabels, sourceKeys: RELEASE_MONITORING_SOURCE_KEYS, ...chartLabels }), _jsx(BigChartCard, { title: t('releases.detail.metric.averageLatency'), icon: _jsx(Timer, { className: "size-4", strokeWidth: 2.2 }), iconBg: "var(--src-canary-soft)", iconFg: "var(--src-canary-fg)", total: formatLatencyMs(timeseriesMax.latencyAverageMs), comparison: comparisonFromDelta(timeseriesMax.latencyAverageMs, stats?.latencyAverageMs.previous ?? 0, formatLatencyMs, vsPreviousPeriodLabel), data: pickTimeseries('latencyAverageMs'), yTickFormatter: formatLatencyMs, legendFormatter: formatLatencyMs, bySource: stats?.latencyAverageMs.bySource ?? EMPTY_BY_SOURCE, sourceLabels: sourceLabels, sourceKeys: RELEASE_MONITORING_SOURCE_KEYS, ...chartLabels }), _jsx(BigChartCard, { title: t('releases.detail.metric.p50Latency'), icon: _jsx(Timer, { className: "size-4", strokeWidth: 2.2 }), iconBg: "var(--src-prod-soft)", iconFg: "var(--src-prod-fg)", total: formatLatencyMs(timeseriesMax.latencyP50Ms), comparison: comparisonFromDelta(timeseriesMax.latencyP50Ms, stats?.latencyP50Ms.previous ?? 0, formatLatencyMs, vsPreviousPeriodLabel), data: pickTimeseries('latencyP50Ms'), yTickFormatter: formatLatencyMs, legendFormatter: formatLatencyMs, bySource: stats?.latencyP50Ms.bySource ?? EMPTY_BY_SOURCE, sourceLabels: sourceLabels, sourceKeys: RELEASE_MONITORING_SOURCE_KEYS, ...chartLabels }), _jsx(BigChartCard, { title: t('releases.detail.metric.p95Latency'), icon: _jsx(Timer, { className: "size-4", strokeWidth: 2.2 }), iconBg: "var(--status-running-bg)", iconFg: "var(--status-running-fg)", total: formatLatencyMs(timeseriesMax.latencyP95Ms), comparison: comparisonFromDelta(timeseriesMax.latencyP95Ms, stats?.latencyP95Ms.previous ?? 0, formatLatencyMs, vsPreviousPeriodLabel), data: pickTimeseries('latencyP95Ms'), yTickFormatter: formatLatencyMs, legendFormatter: formatLatencyMs, bySource: stats?.latencyP95Ms.bySource ?? EMPTY_BY_SOURCE, sourceLabels: sourceLabels, sourceKeys: RELEASE_MONITORING_SOURCE_KEYS, ...chartLabels }), _jsx(BigChartCard, { title: t('releases.detail.metric.p99Latency'), icon: _jsx(Timer, { className: "size-4", strokeWidth: 2.2 }), iconBg: "var(--status-pending-bg)", iconFg: "var(--status-pending-fg)", total: formatLatencyMs(timeseriesMax.latencyP99Ms), comparison: comparisonFromDelta(timeseriesMax.latencyP99Ms, stats?.latencyP99Ms.previous ?? 0, formatLatencyMs, vsPreviousPeriodLabel), data: pickTimeseries('latencyP99Ms'), yTickFormatter: formatLatencyMs, legendFormatter: formatLatencyMs, bySource: stats?.latencyP99Ms.bySource ?? EMPTY_BY_SOURCE, sourceLabels: sourceLabels, sourceKeys: RELEASE_MONITORING_SOURCE_KEYS, ...chartLabels }), _jsx(BigChartCard, { title: t('releases.detail.metric.cost'), icon: _jsx(CircleDollarSign, { className: "size-4", strokeWidth: 2.2 }), iconBg: "var(--status-running-bg)", iconFg: "var(--status-running-fg)", total: formatCostValue(timeseriesMax.cost), comparison: comparisonFromDelta(timeseriesMax.cost, stats?.cost.previous ?? 0, formatCostValue, vsPreviousPeriodLabel), subtitle: t('monitoring.delta.costSubtitle'), data: pickTimeseries('cost'), yTickFormatter: formatCostValue, legendFormatter: formatCostValue, bySource: stats?.cost.bySource ?? EMPTY_BY_SOURCE, sourceLabels: sourceLabels, sourceKeys: RELEASE_MONITORING_SOURCE_KEYS, ...chartLabels }), _jsx(BigChartCard, { title: t('releases.detail.metric.failureRate'), icon: _jsx(AlertTriangle, { className: "size-4", strokeWidth: 2.2 }), iconBg: "var(--status-pending-bg)", iconFg: "var(--status-pending-fg)", total: timeseriesMax.failureRatePercent.toFixed(2), unit: "%", comparison: comparisonFromDelta(timeseriesMax.failureRatePercent, failureRatePercent(stats, 'previous'), (value) => value.toFixed(2), vsPreviousPeriodLabel, '%'), subtitle: t('monitoring.delta.failureRateSubtitle'), data: pickFailureRateTimeseries(), yTickFormatter: formatPercentValue, legendFormatter: formatPercentValue, bySource: failureRateBySourcePercent(stats), sourceLabels: sourceLabels, sourceKeys: RELEASE_MONITORING_SOURCE_KEYS, sourceDistributionLabel: chartLabels.sourceDistributionLabel, totalLabel: chartLabels.failureRateTotalLabel })] })] })] }));
507
932
  }
508
- function VariantsPane({ line, releaseEvents, loading, }) {
509
- const { t } = useI18n();
510
- const formatDateTimeOrDash = useDateTimeOrDash();
511
- const details = useMemo(() => buildReleaseVariantDetails(line, releaseEvents), [line, releaseEvents]);
512
- const showLoader = useDelayedLoading(loading);
513
- if (loading && details.length === 0) {
514
- return showLoader ? _jsx(PlatformLoader, { className: "py-8", size: "sm" }) : null;
515
- }
516
- if (details.length === 0) {
517
- return (_jsx("div", { className: "rounded-lg border bg-card p-10 text-center text-sm text-muted-foreground", children: t('releases.detail.variants.empty') }));
518
- }
519
- return (_jsx("section", { className: "space-y-3", "data-testid": "release-variants-pane", children: _jsx("div", { className: "grid grid-cols-1 gap-3 xl:grid-cols-2", children: details.map((detail) => (_jsxs("article", { className: "rounded-lg border bg-card p-4", children: [_jsxs("div", { className: "flex flex-wrap items-start justify-between gap-3", children: [_jsxs("div", { className: "min-w-0", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsx("span", { className: "font-mono text-[17px] font-semibold", children: detail.label }), _jsx(ReleaseVariantStageBadge, { stage: detail.stage })] }), _jsxs("div", { className: "mt-1 max-w-full truncate text-[12px] text-muted-foreground", children: [detail.promptName, " \u00B7 ", detail.promptVersionLabel ?? formatShortId(detail.promptVersionId), " \u00B7", ' ', detail.modelName ?? formatShortId(detail.modelId)] })] }), _jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => void navigator.clipboard?.writeText(detail.id), children: [_jsx(Copy, { className: "size-3.5" }), t('releases.detail.variants.copyId')] }), _jsx(Button, { type: "button", variant: "outline", size: "sm", asChild: true, children: _jsxs(Link, { href: `/releases/${encodeURIComponent(line.id)}?tab=results&variant=${encodeURIComponent(detail.id)}`, children: [_jsx(Activity, { className: "size-3.5" }), t('releases.detail.variants.viewResults')] }) }), _jsx(Button, { type: "button", size: "sm", asChild: true, children: _jsxs(Link, { href: `/annotations/new?line=${encodeURIComponent(line.id)}&variant=${encodeURIComponent(detail.id)}`, children: [_jsx(ClipboardCheck, { className: "size-3.5" }), t('releases.detail.variants.newAnnotation')] }) })] })] }), _jsxs("dl", { className: "mt-4 grid grid-cols-2 gap-3 border-t pt-4 md:grid-cols-4", children: [_jsx(VariantMeta, { label: t('releases.detail.variants.promptVersion'), value: detail.promptVersionLabel ?? formatShortId(detail.promptVersionId) }), _jsx(VariantMeta, { label: t('releases.detail.variants.model'), value: detail.modelName ?? formatShortId(detail.modelId) }), _jsx(VariantMeta, { label: t('releases.detail.variants.provider'), value: detail.modelProvider ?? '—' }), _jsx(VariantMeta, { label: t('releases.detail.variants.updatedAt'), value: formatDateTimeOrDash(detail.updatedAt) }), _jsx(VariantMeta, { label: t('releases.detail.variants.productionEvents'), value: formatCount(detail.productionEventCount) }), _jsx(VariantMeta, { label: t('releases.detail.variants.canaryEvents'), value: formatCount(detail.canaryEventCount) }), _jsx(VariantMeta, { label: t('releases.detail.variants.processed'), value: formatCount(detail.totalProcessed) }), _jsx(VariantMeta, { label: t('releases.detail.variants.errors'), value: formatCount(detail.totalErrors) })] }), _jsxs("div", { className: "mt-4 border-t pt-4", children: [_jsx("div", { className: "mb-2 text-[12px] font-medium text-muted-foreground", children: t('releases.detail.variants.events') }), detail.events.length === 0 ? (_jsx("div", { className: "text-[12px] text-muted-foreground", children: t('releases.detail.variants.noEvents') })) : (_jsx("div", { className: "space-y-2", children: detail.events.slice(0, 5).map((event) => (_jsxs("div", { className: "flex min-w-0 flex-wrap items-center gap-2 text-[12px]", children: [_jsx(ReleaseEventPill, { event: event.operation }), _jsx("span", { className: "font-mono text-muted-foreground", children: t(event.laneType === 'production'
520
- ? 'releases.detail.history.productionLane'
521
- : 'releases.detail.history.canaryLane') }), _jsx("span", { className: "min-w-0 flex-1 truncate text-muted-foreground", children: event.submitReason || event.status }), _jsx("span", { className: "font-mono text-[11.5px] text-muted-foreground", children: formatDateTimeOrDash(event.createdAt) })] }, event.id))) }))] }), _jsx("div", { className: "mt-4 border-t pt-3", children: _jsx("div", { className: "font-mono text-[11px] text-muted-foreground", children: detail.id }) })] }, detail.id))) }) }));
522
- }
523
- function VariantMeta({ label, value }) {
524
- return (_jsxs("div", { className: "min-w-0", children: [_jsx("dt", { className: "truncate text-[11.5px] text-muted-foreground", children: label }), _jsx("dd", { className: "mt-1 truncate font-mono text-[12.5px] font-medium text-foreground", children: value })] }));
525
- }
526
- function ReleaseVariantStageBadge({ stage }) {
527
- const { t } = useI18n();
528
- const isProduction = stage === 'production' || stage === 'production_canary';
529
- const isCanary = stage === 'canary' || stage === 'production_canary';
530
- return (_jsx("span", { className: "inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium", style: {
531
- background: isProduction ? 'var(--src-prod-soft)' : isCanary ? 'var(--src-canary-soft)' : 'var(--muted)',
532
- color: isProduction ? 'var(--src-prod-fg)' : isCanary ? 'var(--src-canary-fg)' : 'var(--muted-foreground)',
533
- borderColor: isProduction
534
- ? 'color-mix(in srgb, var(--src-prod) 30%, transparent)'
535
- : isCanary
536
- ? 'color-mix(in srgb, var(--src-canary) 30%, transparent)'
537
- : 'var(--border)',
538
- }, children: t(`releases.detail.variants.stage.${stage}`) }));
539
- }
540
- function ResultsPane({ projectId, line, releaseEvents, initialReleaseVariantId, }) {
541
- const { t } = useI18n();
933
+ function ResultsPane({ projectId, line, releaseEvents, initialReleaseVersionId, }) {
934
+ const { t, language } = useI18n();
542
935
  const formatDateTimeOrDash = useDateTimeOrDash();
543
- const [sourceFilter, setSourceFilter] = useState('all');
544
- const [releaseVariantFilter, setReleaseVariantFilter] = useState(initialReleaseVariantId ?? 'all');
545
- const [promptVersionFilter, setPromptVersionFilter] = useState('all');
936
+ const [dateRange, setDateRange] = useState(() => createDefaultResultDateRange());
937
+ const [releaseVersionFilter, setReleaseVersionFilter] = useState(initialReleaseVersionId ?? 'all');
546
938
  const [pageIndex, setPageIndex] = useState(0);
547
939
  const [pageSize, setPageSize] = useState(20);
548
940
  const sourceIds = useMemo(() => getReleaseResultSourceIds(line, releaseEvents), [line, releaseEvents]);
549
- const releaseVariantOptions = useMemo(() => getReleaseResultVariantOptions(line, releaseEvents), [line, releaseEvents]);
550
- const promptVersionOptions = useMemo(() => getReleaseResultPromptVersionOptions(line, releaseEvents), [line, releaseEvents]);
551
- const activeReleaseVariantFilter = releaseVariantFilter === 'all' || releaseVariantOptions.some((option) => option.id === releaseVariantFilter)
552
- ? releaseVariantFilter
553
- : 'all';
554
- const activePromptVersionFilter = promptVersionFilter === 'all' || promptVersionOptions.some((option) => option.id === promptVersionFilter)
555
- ? promptVersionFilter
941
+ const releaseVersionOptions = useMemo(() => getReleaseResultVersionOptions(line, releaseEvents), [line, releaseEvents]);
942
+ const activeReleaseVersionFilter = releaseVersionFilter === 'all' || releaseVersionOptions.some((option) => option.id === releaseVersionFilter)
943
+ ? releaseVersionFilter
556
944
  : 'all';
557
- const laneFilter = sourceFilter === 'all' ? undefined : [sourceFilter];
558
- const releaseVariantIds = activeReleaseVariantFilter === 'all' ? undefined : [activeReleaseVariantFilter];
559
- const promptVersionIds = activePromptVersionFilter === 'all' ? undefined : [activePromptVersionFilter];
945
+ const releaseVersionIds = activeReleaseVersionFilter === 'all' ? undefined : [activeReleaseVersionFilter];
946
+ const applyDateRange = isResultDateRangeApplied(dateRange);
947
+ const handleDateRangeChange = useCallback((next) => {
948
+ setDateRange(next);
949
+ setPageIndex(0);
950
+ }, []);
951
+ const refreshResultDateRange = useCallback(() => {
952
+ const nextDateRange = resolveRollingDateRangeValue(dateRange);
953
+ if (nextDateRange.preset !== dateRange.preset ||
954
+ nextDateRange.from !== dateRange.from ||
955
+ nextDateRange.to !== dateRange.to) {
956
+ setDateRange(nextDateRange);
957
+ }
958
+ }, [dateRange]);
959
+ useAutoRefresh({
960
+ intervalMs: AUTO_REFRESH_INTERVAL_MS,
961
+ enabled: sourceIds.length > 0 && isResultDateRangeRolling(dateRange),
962
+ onTick: refreshResultDateRange,
963
+ });
964
+ const dateRangePresetLabels = useMemo(() => [
965
+ { value: 'all', label: t('releases.detail.results.dateFilter.all') },
966
+ { value: 'h24', label: t('monitoring.timeRange.preset.h24') },
967
+ { value: 'd7', label: t('monitoring.timeRange.preset.d7') },
968
+ { value: 'd30', label: t('monitoring.timeRange.preset.d30') },
969
+ { value: 'custom', label: t('monitoring.timeRange.preset.custom') },
970
+ ], [t]);
971
+ const dateRangeLabels = useMemo(() => ({
972
+ ariaLabel: t('releases.detail.results.dateFilter.ariaLabel'),
973
+ customRangeAriaLabel: t('releases.detail.results.dateFilter.customRangeAriaLabel'),
974
+ fromLabel: t('monitoring.timeRange.from'),
975
+ toLabel: t('monitoring.timeRange.to'),
976
+ dateLabel: t('monitoring.timeRange.date'),
977
+ timeLabel: t('monitoring.timeRange.time'),
978
+ previousMonth: t('monitoring.timeRange.previousMonth'),
979
+ nextMonth: t('monitoring.timeRange.nextMonth'),
980
+ cancel: t('common.cancel'),
981
+ apply: t('common.apply'),
982
+ invalidRange: t('monitoring.timeRange.invalidRange'),
983
+ }), [t]);
560
984
  const resultsQuery = useReleaseRunResults(projectId, {
561
985
  page: pageIndex + 1,
562
986
  pageSize,
@@ -565,9 +989,10 @@ function ResultsPane({ projectId, line, releaseEvents, initialReleaseVariantId,
565
989
  judgmentStatus: undefined,
566
990
  isCorrect: undefined,
567
991
  sourceIds,
568
- releaseVariantIds,
569
- promptVersionIds,
570
- lane: laneFilter,
992
+ releaseVersionIds,
993
+ releaseVersionScope: 'exact',
994
+ from: applyDateRange ? dateRange.from : undefined,
995
+ to: applyDateRange ? dateRange.to : undefined,
571
996
  }, sourceIds.length > 0);
572
997
  const rows = resultsQuery.data?.data ?? [];
573
998
  const resultsLoading = useDelayedLoading(resultsQuery.isLoading);
@@ -575,16 +1000,10 @@ function ResultsPane({ projectId, line, releaseEvents, initialReleaseVariantId,
575
1000
  const pageCount = Math.max(1, Math.ceil(total / pageSize));
576
1001
  const from = total === 0 ? 0 : pageIndex * pageSize + 1;
577
1002
  const to = Math.min((pageIndex + 1) * pageSize, total);
578
- return (_jsxs("div", { className: "overflow-hidden rounded-lg border bg-card", children: [_jsxs("div", { className: "flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3", children: [_jsx("div", { children: _jsx("h2", { className: "text-[14px] font-semibold", children: t('releases.detail.tab.results') }) }), _jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsx("label", { className: "sr-only", htmlFor: "release-result-variant-filter", children: t('releases.detail.results.variant') }), _jsxs("select", { id: "release-result-variant-filter", name: "releaseVariantFilter", value: activeReleaseVariantFilter, onChange: (event) => {
579
- setReleaseVariantFilter(event.currentTarget.value);
1003
+ return (_jsxs("div", { className: "overflow-hidden rounded-lg border bg-card", children: [_jsxs("div", { className: "flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3", children: [_jsx("div", { children: _jsx("h2", { className: "text-[14px] font-semibold", children: t('releases.detail.tab.results') }) }), _jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsx(DateRangeSegmented, { value: dateRange, onChange: handleDateRangeChange, presetLabels: dateRangePresetLabels, labels: dateRangeLabels, locale: language }), _jsx("label", { className: "sr-only", htmlFor: "release-result-version-filter", children: t('releases.detail.results.version') }), _jsx(ResultReleaseVersionSelect, { id: "release-result-version-filter", options: releaseVersionOptions, value: activeReleaseVersionFilter, onChange: (next) => {
1004
+ setReleaseVersionFilter(next);
580
1005
  setPageIndex(0);
581
- }, className: "h-9 rounded-md border bg-background px-3 text-[12px] font-medium text-foreground shadow-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50", disabled: releaseVariantOptions.length === 0, children: [_jsx("option", { value: "all", children: t('releases.detail.results.variantFilter.all') }), releaseVariantOptions.map((option) => (_jsxs("option", { value: option.id, children: [option.label, " \u00B7 ", option.detail] }, option.id)))] }), _jsx("label", { className: "sr-only", htmlFor: "release-result-prompt-version-filter", children: t('releases.detail.results.promptVersion') }), _jsxs("select", { id: "release-result-prompt-version-filter", name: "promptVersionFilter", value: activePromptVersionFilter, onChange: (event) => {
582
- setPromptVersionFilter(event.currentTarget.value);
583
- setPageIndex(0);
584
- }, className: "h-9 rounded-md border bg-background px-3 text-[12px] font-medium text-foreground shadow-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50", disabled: promptVersionOptions.length === 0, children: [_jsx("option", { value: "all", children: t('releases.detail.results.promptVersionFilter.all') }), promptVersionOptions.map((option) => (_jsx("option", { value: option.id, children: option.label }, option.id)))] }), _jsx("div", { className: "inline-flex rounded-lg border bg-background p-1", children: ['all', 'production', 'canary'].map((value) => (_jsx("button", { type: "button", onClick: () => {
585
- setSourceFilter(value);
586
- setPageIndex(0);
587
- }, className: cn('h-7 rounded-md px-3 text-[12px] font-medium transition-colors', sourceFilter === value ? 'bg-muted text-foreground' : 'text-muted-foreground hover:text-foreground'), children: t(`releases.detail.results.sourceFilter.${value}`) }, value))) })] })] }), _jsxs(Table, { columns: RESULT_COLUMNS, children: [_jsx(TableHeader, { children: _jsxs(TableRow, { children: [_jsx(TableHead, { column: "externalId", children: t('releases.detail.results.externalId') }), _jsx(TableHead, { column: "input", children: t('releases.detail.results.input') }), _jsx(TableHead, { column: "output", children: t('releases.detail.results.output') }), _jsx(TableHead, { column: "source", children: t('releases.detail.results.source') }), _jsx(TableHead, { column: "variant", children: t('releases.detail.results.variant') }), _jsx(TableHead, { column: "latency", children: t('releases.detail.results.latency') }), _jsx(TableHead, { column: "tokens", children: t('releases.detail.results.tokens') }), _jsx(TableHead, { column: "createdAt", children: t('releases.detail.results.createdAt') })] }) }), _jsxs(TableBody, { children: [resultsLoading && rows.length === 0 ? (_jsx(TableEmpty, { children: _jsx(PlatformLoader, { className: "py-1", size: "sm" }) })) : null, resultsQuery.isError ? _jsx(TableEmpty, { children: t('releases.detail.results.loadFailed') }) : null, !resultsQuery.isLoading && !resultsQuery.isError && rows.length === 0 ? (_jsx(TableEmpty, { children: t('releases.detail.results.empty') })) : null, rows.map((row) => (_jsxs(TableRow, { children: [_jsx(TableCell, { column: "externalId", truncate: true, className: "font-mono text-[11.5px] text-muted-foreground", children: _jsx("span", { title: row.externalId ?? undefined, children: row.externalId ?? '—' }) }), _jsx(TableCell, { column: "input", truncate: 2, className: "text-[12px]", children: _jsx("span", { title: formatReleaseRunResultInput(row, 1000), children: formatReleaseRunResultInput(row, 220) }) }), _jsx(TableCell, { column: "output", truncate: 2, className: "text-[12px]", children: _jsx("span", { title: formatReleaseRunResultOutput(row, 1000), children: formatReleaseRunResultOutput(row, 220) }) }), _jsx(TableCell, { column: "source", children: _jsx(ReleaseRunResultLaneBadge, { lane: row.lane }) }), _jsx(TableCell, { column: "variant", className: "text-[12px]", children: _jsx(ReleaseRunResultVariant, { value: row }) }), _jsx(TableCell, { column: "latency", className: "font-mono text-[11.5px] text-muted-foreground", children: formatResultLatency(row.latencyMs) }), _jsx(TableCell, { column: "tokens", className: "font-mono text-[11.5px] text-muted-foreground", children: formatResultTokens(row) }), _jsx(TableCell, { column: "createdAt", className: "font-mono text-[11.5px] text-muted-foreground", children: formatDateTimeOrDash(row.createdAt) })] }, `${row.id}:${row.createdAt}`)))] })] }), _jsx(ResourcePaginationFooter, { summary: _jsx("span", { children: t('releases.detail.results.summary')
1006
+ }, disabled: releaseVersionOptions.length === 0 })] })] }), _jsxs(Table, { columns: RESULT_COLUMNS, children: [_jsx(TableHeader, { children: _jsxs(TableRow, { children: [_jsx(TableHead, { column: "externalId", children: t('releases.detail.results.externalId') }), _jsx(TableHead, { column: "input", children: t('releases.detail.results.input') }), _jsx(TableHead, { column: "output", children: t('releases.detail.results.output') }), _jsx(TableHead, { column: "source", children: t('releases.detail.results.source') }), _jsx(TableHead, { column: "version", children: t('releases.detail.results.version') }), _jsx(TableHead, { column: "latency", children: t('releases.detail.results.latency') }), _jsx(TableHead, { column: "tokens", children: t('releases.detail.results.tokens') }), _jsx(TableHead, { column: "createdAt", children: t('releases.detail.results.createdAt') })] }) }), _jsxs(TableBody, { children: [resultsLoading && rows.length === 0 ? (_jsx(TableEmpty, { children: _jsx(PlatformLoader, { className: "py-1", size: "sm" }) })) : null, resultsQuery.isError ? _jsx(TableEmpty, { children: t('releases.detail.results.loadFailed') }) : null, !resultsQuery.isLoading && !resultsQuery.isError && rows.length === 0 ? (_jsx(TableEmpty, { children: t('releases.detail.results.empty') })) : null, rows.map((row) => (_jsxs(TableRow, { children: [_jsx(TableCell, { column: "externalId", truncate: true, className: "font-mono text-[11.5px] text-muted-foreground", children: _jsx("span", { title: row.externalId ?? undefined, children: row.externalId ?? '—' }) }), _jsx(TableCell, { column: "input", truncate: 2, className: "text-[12px]", children: _jsx("span", { title: formatReleaseRunResultInput(row, 1000), children: formatReleaseRunResultInput(row, 220) }) }), _jsx(TableCell, { column: "output", truncate: 2, className: "text-[12px]", children: _jsx("span", { title: formatReleaseRunResultOutput(row, 1000), children: formatReleaseRunResultOutput(row, 220) }) }), _jsx(TableCell, { column: "source", children: _jsx(ReleaseRunResultLaneBadge, { lane: row.lane }) }), _jsx(TableCell, { column: "version", className: "text-[12px]", children: _jsx(ReleaseRunResultVersion, { value: row }) }), _jsx(TableCell, { column: "latency", className: "font-mono text-[11.5px] text-muted-foreground", children: formatResultLatency(row.latencyMs) }), _jsx(TableCell, { column: "tokens", className: "font-mono text-[11.5px] text-muted-foreground", children: formatResultTokens(row) }), _jsx(TableCell, { column: "createdAt", className: "font-mono text-[11.5px] text-muted-foreground", children: formatDateTimeOrDash(row.createdAt) })] }, `${row.id}:${row.createdAt}`)))] })] }), _jsx(ResourcePaginationFooter, { summary: _jsx("span", { children: t('releases.detail.results.summary')
588
1007
  .replace('{from}', String(from))
589
1008
  .replace('{to}', String(to))
590
1009
  .replace('{total}', formatCount(total)) }), pageIndex: pageIndex, pageCount: pageCount, pageSize: pageSize, pageSizeOptions: RESULT_PAGE_SIZE_OPTIONS, previousPageLabel: t('common.previousPage'), nextPageLabel: t('common.nextPage'), onPageChange: setPageIndex, onPageSizeChange: (nextPageSize) => {
@@ -592,6 +1011,60 @@ function ResultsPane({ projectId, line, releaseEvents, initialReleaseVariantId,
592
1011
  setPageIndex(0);
593
1012
  } })] }));
594
1013
  }
1014
+ function ResultReleaseVersionSelect({ id, options, value, onChange, disabled, }) {
1015
+ const { t } = useI18n();
1016
+ const [open, setOpen] = useState(false);
1017
+ const [query, setQuery] = useState('');
1018
+ const selectedOption = value === 'all' ? null : (options.find((option) => option.id === value) ?? null);
1019
+ const allLabel = t('releases.detail.results.versionFilter.all');
1020
+ const triggerLabel = selectedOption?.label ?? allLabel;
1021
+ const triggerDetail = selectedOption?.detail ?? null;
1022
+ const normalizedQuery = normalizeResultVersionSearch(query);
1023
+ const allOptionVisible = !normalizedQuery || resultVersionSearchIncludes(normalizedQuery, [allLabel, 'all']);
1024
+ const filteredOptions = useMemo(() => {
1025
+ if (!normalizedQuery)
1026
+ return options;
1027
+ return options.filter((option) => resultVersionSearchIncludes(normalizedQuery, [
1028
+ option.id,
1029
+ option.label,
1030
+ option.promptVersion,
1031
+ option.model,
1032
+ option.detail,
1033
+ ]));
1034
+ }, [normalizedQuery, options]);
1035
+ function select(next) {
1036
+ onChange(next);
1037
+ setOpen(false);
1038
+ setQuery('');
1039
+ }
1040
+ return (_jsxs(Popover, { open: open, onOpenChange: (next) => {
1041
+ setOpen(next);
1042
+ if (!next)
1043
+ setQuery('');
1044
+ }, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs(Button, { id: id, type: "button", variant: "outline", disabled: disabled, "data-testid": "release-result-version-filter", className: "h-auto min-h-10 w-full justify-between px-3 py-2 text-left sm:w-[340px]", children: [_jsxs("span", { className: "min-w-0", children: [_jsx("span", { className: "block truncate font-mono text-[13px] font-semibold", children: triggerLabel }), triggerDetail ? (_jsx("span", { className: "mt-0.5 block truncate text-[11px] font-normal text-muted-foreground", children: triggerDetail })) : null] }), _jsx(ChevronDown, { className: "size-4 shrink-0 text-muted-foreground", "aria-hidden": "true" })] }) }), _jsxs(PopoverContent, { align: "end", sideOffset: 6, className: "w-[calc(100vw-2rem)] p-0 sm:w-[720px]", children: [_jsx(ResultDropdownSearchInput, { value: query, onChange: setQuery, placeholder: t('releases.detail.results.versionDropdown.search') }), _jsxs("div", { className: "max-h-[360px] overflow-y-auto p-1.5", children: [allOptionVisible ? (_jsxs("button", { type: "button", "data-testid": "release-result-version-option-all", onClick: () => select('all'), className: cn('flex w-full items-start gap-3 rounded-md px-3 py-2.5 text-left hover:bg-accent', value === 'all' && 'bg-primary/5'), children: [_jsx(ResultVersionSelectionCheck, { selected: value === 'all' }), _jsx("span", { className: "min-w-0 flex-1", children: _jsx("span", { className: "block min-w-0 truncate font-mono text-[13px] font-semibold", children: allLabel }) })] })) : null, filteredOptions.length === 0 && !allOptionVisible ? (_jsx("div", { className: "px-3 py-8 text-center text-[12px] text-muted-foreground", children: t('releases.detail.results.versionDropdown.noMatches') })) : (filteredOptions.map((option) => {
1045
+ const selected = option.id === value;
1046
+ return (_jsxs("button", { type: "button", "data-testid": `release-result-version-option-${option.id}`, onClick: () => select(option.id), className: cn('flex w-full items-start gap-3 rounded-md px-3 py-2.5 text-left hover:bg-accent', selected && 'bg-primary/5'), children: [_jsx(ResultVersionSelectionCheck, { selected: selected }), _jsxs("span", { className: "min-w-0 flex-1", children: [_jsx("span", { className: "block min-w-0 truncate font-mono text-[13px] font-semibold", children: option.label }), _jsxs("span", { className: "mt-1 grid gap-x-4 gap-y-1 text-[11.5px] text-muted-foreground sm:grid-cols-3", children: [_jsx("span", { className: "min-w-0 truncate", children: _jsx(ResultDropdownFieldLabel, { label: t('releases.detail.results.versionDropdown.promptVersion'), value: option.promptVersion }) }), _jsx("span", { className: "min-w-0 truncate", children: _jsx(ResultDropdownFieldLabel, { label: t('releases.detail.results.versionDropdown.model'), value: option.model }) }), _jsx("span", { className: "min-w-0 truncate", children: _jsx(ResultDropdownFieldLabel, { label: t('releases.detail.results.versionDropdown.versionId'), value: formatShortId(option.id) }) })] })] })] }, option.id));
1047
+ }))] })] })] }));
1048
+ }
1049
+ function ResultDropdownSearchInput({ value, onChange, placeholder, }) {
1050
+ return (_jsxs("div", { className: "flex items-center gap-2 border-b px-3 py-2", children: [_jsx(Search, { className: "size-3.5 text-muted-foreground", "aria-hidden": "true" }), _jsx(Input, { value: value, onChange: (event) => onChange(event.target.value), onKeyDown: (event) => event.stopPropagation(), placeholder: placeholder, "data-testid": "release-result-version-search", className: "h-8 border-0 bg-transparent px-0 text-[13px] shadow-none focus-visible:ring-0 focus-visible:ring-offset-0" })] }));
1051
+ }
1052
+ function ResultVersionSelectionCheck({ selected }) {
1053
+ return (_jsx("span", { className: cn('mt-0.5 inline-flex size-4 shrink-0 items-center justify-center rounded-full border', selected ? 'border-primary bg-primary text-primary-foreground' : 'border-muted-foreground/35 bg-background'), "aria-hidden": "true", children: _jsx(Check, { className: cn('size-3', selected ? 'opacity-100' : 'opacity-0') }) }));
1054
+ }
1055
+ function ResultDropdownFieldLabel({ label, value }) {
1056
+ return (_jsxs(_Fragment, { children: [_jsx("span", { className: "text-muted-foreground", children: label }), _jsx("span", { className: "mx-1 text-muted-foreground/60", children: "-" }), _jsx("span", { className: "text-foreground", children: value })] }));
1057
+ }
1058
+ function normalizeResultVersionSearch(value) {
1059
+ return value.trim().toLowerCase();
1060
+ }
1061
+ function resultVersionSearchIncludes(query, parts) {
1062
+ return parts
1063
+ .filter((part) => part !== null && part !== undefined)
1064
+ .join(' ')
1065
+ .toLowerCase()
1066
+ .includes(query);
1067
+ }
595
1068
  function getReleaseLineEventSourceIds(line, releaseEvents) {
596
1069
  const ids = [
597
1070
  ...releaseEvents.flatMap((event) => [
@@ -628,103 +1101,7 @@ function getReleaseLineEventSources(line, releaseEvents) {
628
1101
  function getReleaseResultSourceIds(line, releaseEvents) {
629
1102
  return getReleaseLineEventSourceIds(line, releaseEvents);
630
1103
  }
631
- function buildReleaseVariantDetails(line, releaseEvents) {
632
- const baseById = new Map();
633
- const eventsByVariant = new Map();
634
- const addVariant = (variant) => {
635
- baseById.set(variant.id, {
636
- id: variant.id,
637
- variantNumber: variant.variantNumber,
638
- label: variant.label,
639
- promptName: variant.promptName,
640
- promptVersionId: variant.promptVersionId,
641
- promptVersionLabel: variant.promptVersionLabel,
642
- modelId: variant.modelId,
643
- modelName: variant.modelName,
644
- modelProvider: variant.modelProvider,
645
- createdAt: variant.createdAt,
646
- updatedAt: variant.updatedAt,
647
- });
648
- };
649
- for (const variant of line.variants)
650
- addVariant(variant);
651
- for (const event of releaseEvents) {
652
- if (!event.releaseVariantId)
653
- continue;
654
- const events = eventsByVariant.get(event.releaseVariantId) ?? [];
655
- events.push(event);
656
- eventsByVariant.set(event.releaseVariantId, events);
657
- if (!baseById.has(event.releaseVariantId)) {
658
- baseById.set(event.releaseVariantId, {
659
- id: event.releaseVariantId,
660
- variantNumber: event.releaseVariantNumber,
661
- label: event.releaseVariantLabel ?? formatShortId(event.releaseVariantId),
662
- promptName: event.promptName,
663
- promptVersionId: event.promptVersionId,
664
- promptVersionLabel: event.promptVersionLabel,
665
- modelId: event.modelId,
666
- modelName: event.modelName,
667
- modelProvider: event.modelProvider,
668
- createdAt: event.createdAt,
669
- updatedAt: event.updatedAt,
670
- });
671
- }
672
- }
673
- const currentProductionVariantId = releaseEvents.find((event) => event.id === line.production?.currentEvent?.id)?.releaseVariantId ?? null;
674
- const activeCanaryVariantId = line.canary?.releaseVariantId ??
675
- releaseEvents.find((event) => event.id === line.canary?.id)?.releaseVariantId ??
676
- null;
677
- return [...baseById.values()]
678
- .map((base) => {
679
- const events = (eventsByVariant.get(base.id) ?? []).sort((left, right) => timeValue(right.createdAt) - timeValue(left.createdAt));
680
- return {
681
- ...base,
682
- createdAt: minDateString([base.createdAt, ...events.map((event) => event.createdAt)]),
683
- updatedAt: maxDateString([base.updatedAt, ...events.map((event) => event.updatedAt ?? event.createdAt)]),
684
- stage: resolveReleaseVariantStage(base.id, currentProductionVariantId, activeCanaryVariantId, events),
685
- events,
686
- productionEventCount: events.filter((event) => event.laneType === 'production').length,
687
- canaryEventCount: events.filter((event) => event.laneType === 'canary').length,
688
- totalProcessed: events.reduce((sum, event) => sum + event.totalProcessed, 0),
689
- totalErrors: events.reduce((sum, event) => sum + event.totalErrors, 0),
690
- };
691
- })
692
- .sort((left, right) => {
693
- if (left.variantNumber !== null && right.variantNumber !== null)
694
- return left.variantNumber - right.variantNumber;
695
- if (left.variantNumber !== null)
696
- return -1;
697
- if (right.variantNumber !== null)
698
- return 1;
699
- return left.label.localeCompare(right.label, undefined, { numeric: true });
700
- });
701
- }
702
- function resolveReleaseVariantStage(releaseVariantId, currentProductionVariantId, activeCanaryVariantId, events) {
703
- const isProduction = currentProductionVariantId === releaseVariantId ||
704
- events.some((event) => event.laneType === 'production' && event.status === 'running');
705
- const isCanary = activeCanaryVariantId === releaseVariantId ||
706
- events.some((event) => event.laneType === 'canary' && (event.status === 'running' || event.status === 'stopped'));
707
- if (isProduction && isCanary)
708
- return 'production_canary';
709
- if (isProduction)
710
- return 'production';
711
- if (isCanary)
712
- return 'canary';
713
- return 'history';
714
- }
715
- function minDateString(values) {
716
- const dates = values.filter((value) => Boolean(value));
717
- if (dates.length === 0)
718
- return null;
719
- return dates.reduce((min, value) => (timeValue(value) < timeValue(min) ? value : min));
720
- }
721
- function maxDateString(values) {
722
- const dates = values.filter((value) => Boolean(value));
723
- if (dates.length === 0)
724
- return null;
725
- return dates.reduce((max, value) => (timeValue(value) > timeValue(max) ? value : max));
726
- }
727
- function getReleaseResultVariantOptions(line, releaseEvents) {
1104
+ function getReleaseResultVersionOptions(line, releaseEvents) {
728
1105
  const options = new Map();
729
1106
  const add = (input) => {
730
1107
  if (!input.id)
@@ -734,23 +1111,25 @@ function getReleaseResultVariantOptions(line, releaseEvents) {
734
1111
  options.set(input.id, {
735
1112
  id: input.id,
736
1113
  label: input.label?.trim() || formatShortId(input.id),
1114
+ promptVersion,
1115
+ model,
737
1116
  detail: `${promptVersion} · ${model}`,
738
1117
  });
739
1118
  };
740
- for (const variant of line.variants) {
1119
+ for (const version of line.versions) {
741
1120
  add({
742
- id: variant.id,
743
- label: variant.label,
744
- promptVersionLabel: variant.promptVersionLabel,
745
- promptVersionId: variant.promptVersionId,
746
- modelName: variant.modelName,
747
- modelId: variant.modelId,
1121
+ id: version.id,
1122
+ label: version.label,
1123
+ promptVersionLabel: version.promptVersionLabel,
1124
+ promptVersionId: version.promptVersionId,
1125
+ modelName: version.modelName,
1126
+ modelId: version.modelId,
748
1127
  });
749
1128
  }
750
1129
  for (const event of releaseEvents) {
751
1130
  add({
752
- id: event.releaseVariantId,
753
- label: event.releaseVariantLabel,
1131
+ id: event.releaseVersionId,
1132
+ label: event.releaseVersionLabel,
754
1133
  promptVersionLabel: event.promptVersionLabel,
755
1134
  promptVersionId: event.promptVersionId,
756
1135
  modelName: event.modelName,
@@ -759,20 +1138,6 @@ function getReleaseResultVariantOptions(line, releaseEvents) {
759
1138
  }
760
1139
  return [...options.values()].sort((left, right) => left.label.localeCompare(right.label, undefined, { numeric: true }));
761
1140
  }
762
- function getReleaseResultPromptVersionOptions(line, releaseEvents) {
763
- const options = new Map();
764
- const add = (id, label) => {
765
- if (!id)
766
- return;
767
- options.set(id, label?.trim() || formatShortId(id));
768
- };
769
- add(line.production?.currentEvent?.promptVersionId, line.productionVersionLabel);
770
- add(line.canary?.promptVersionId, line.canaryVersionLabel);
771
- for (const event of releaseEvents) {
772
- add(event.promptVersionId, event.promptVersionLabel);
773
- }
774
- return [...options.entries()].map(([id, label]) => ({ id, label }));
775
- }
776
1141
  function formatReleaseRunResultInput(row, maxLength) {
777
1142
  return compactReleaseRunResultValue(row.inputVariables, maxLength);
778
1143
  }
@@ -849,125 +1214,1314 @@ function ReleaseRunResultLaneBadge({ lane }) {
849
1214
  : 'color-mix(in srgb, var(--src-canary) 30%, transparent)',
850
1215
  }, children: t(isProduction ? 'releases.detail.results.lane.production' : 'releases.detail.results.lane.canary') }));
851
1216
  }
852
- function ReleaseRunResultVariant({ value }) {
853
- const label = value.releaseVariantLabel ?? formatShortId(value.releaseVariantId);
1217
+ function ReleaseRunResultVersion({ value }) {
1218
+ const label = value.releaseVersionLabel ?? formatShortId(value.releaseVersionId);
854
1219
  const promptVersion = formatReleaseRunResultPromptVersion(value);
855
1220
  const model = value.modelName ?? formatShortId(value.modelId);
856
1221
  return (_jsxs("div", { className: "min-w-0", children: [_jsx("div", { className: "truncate font-mono text-[11.5px] font-semibold", children: label }), _jsxs("div", { className: "mt-0.5 truncate text-[11.5px] text-muted-foreground", children: [promptVersion, " \u00B7 ", model] })] }));
857
1222
  }
858
- function QualityMetricsPane({ projectId, line }) {
1223
+ function QualityMetricsPane({ line, releaseEvents }) {
859
1224
  const { t } = useI18n();
860
- const annotationTasksQuery = useAnnotationTaskList(projectId);
861
- const annotationTasksLoading = useDelayedLoading(annotationTasksQuery.isLoading);
862
- const lineTasks = useMemo(() => (annotationTasksQuery.data?.data ?? []).filter((task) => task.releaseLineId === line.id), [annotationTasksQuery.data, line.id]);
863
- const points = useMemo(() => buildAnnotationQualityPoints(lineTasks), [lineTasks]);
864
- const latest = points[points.length - 1] ?? null;
865
- const submitted = lineTasks.reduce((sum, task) => sum + task.progress.submitted, 0);
866
- const matched = lineTasks.reduce((sum, task) => sum + (task.quality?.matched ?? 0), 0);
867
- const mismatched = lineTasks.reduce((sum, task) => sum + (task.quality?.mismatched ?? 0), 0);
868
- const judged = matched + mismatched;
869
- const aggregateScore = judged > 0 ? toPercentPoint(matched / judged) : null;
870
- const annotationHref = `/annotations/new?line=${encodeURIComponent(line.id)}`;
871
- return (_jsxs("div", { className: "space-y-4", "data-testid": "release-quality-metrics-pane", children: [_jsxs("div", { className: "flex flex-wrap items-center justify-between gap-3", children: [_jsx("h2", { className: "text-[14px] font-semibold", children: t('releases.detail.quality.title') }), _jsx(Button, { asChild: true, children: _jsxs(Link, { href: annotationHref, children: [_jsx(ClipboardCheck, { className: "size-4" }), t('releases.detail.action.newAnnotation')] }) })] }), annotationTasksLoading && lineTasks.length === 0 ? (_jsx(PlatformLoader, { className: "rounded-lg border bg-card py-10", size: "sm" })) : null, annotationTasksQuery.isError ? (_jsx("div", { className: "rounded-lg border bg-card px-4 py-3 text-sm text-destructive", children: t('releases.detail.quality.loadFailed') })) : null, !annotationTasksQuery.isLoading && !annotationTasksQuery.isError && lineTasks.length === 0 ? (_jsxs("div", { className: "rounded-lg border bg-card p-10 text-center", "data-testid": "release-quality-empty", children: [_jsx("div", { className: "text-[15px] font-semibold", children: t('releases.detail.quality.empty') }), _jsx(Button, { className: "mt-5", asChild: true, children: _jsxs(Link, { href: annotationHref, children: [_jsx(ClipboardCheck, { className: "size-4" }), t('releases.detail.action.newAnnotation')] }) })] })) : null, lineTasks.length > 0 ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-5", children: [_jsx(ReleaseMetricCard, { label: t('releases.detail.quality.matchRate'), value: formatQualityPercent(aggregateScore), detail: t('releases.detail.quality.tasksCount').replace('{count}', formatCount(lineTasks.length)), tone: "canary" }), _jsx(ReleaseMetricCard, { label: t('releases.detail.quality.latestMatchRate'), value: formatQualityPercent(latest?.score), detail: latest?.name ?? t('common.noData') }), _jsx(ReleaseMetricCard, { label: t('releases.detail.quality.matched'), value: formatCount(matched), detail: t('releases.detail.quality.matchedHint') }), _jsx(ReleaseMetricCard, { label: t('releases.detail.quality.mismatched'), value: formatCount(mismatched), detail: t('releases.detail.quality.mismatchedHint') }), _jsx(ReleaseMetricCard, { label: t('releases.detail.quality.submitted'), value: formatCount(submitted), detail: t('releases.detail.quality.submittedHint') })] }), points.length > 0 ? (_jsxs("div", { className: "rounded-lg border bg-card p-4", children: [_jsxs("div", { className: "mb-4 flex flex-wrap items-center justify-between gap-3", children: [_jsx("div", { children: _jsx("div", { className: "text-[14px] font-semibold", children: t('releases.detail.quality.chartTitle') }) }), _jsx(QualityLegend, {})] }), _jsx(QualityMetricsChart, { data: points })] })) : (_jsx("div", { className: "rounded-lg border bg-card p-8 text-center text-sm text-muted-foreground", children: t('releases.detail.quality.noComparable') }))] })) : null] }));
872
- }
873
- function QualityLegend() {
1225
+ const [selectedVersionIds, setSelectedVersionIds] = useState(null);
1226
+ const [selectedScopeIds, setSelectedScopeIds] = useState(null);
1227
+ const [selectedMetricIds, setSelectedMetricIds] = useState(['f1']);
1228
+ const overallLabel = t('releases.detail.quality.scope.overall');
1229
+ const qualityPoints = useMemo(() => buildReleaseQualityPoints(releaseEvents, overallLabel), [overallLabel, releaseEvents]);
1230
+ const versionOptions = useMemo(() => buildQualityVersionOptions(qualityPoints), [qualityPoints]);
1231
+ const scopeOptions = useMemo(() => buildQualityScopeOptions(qualityPoints), [qualityPoints]);
1232
+ const metricOptions = useMemo(() => QUALITY_METRIC_OPTIONS.map((option) => ({ id: option.key, label: t(option.labelKey) })), [t]);
1233
+ const activeVersionIds = useMemo(() => {
1234
+ const available = versionOptions.map((option) => option.id);
1235
+ if (selectedVersionIds === null)
1236
+ return available;
1237
+ return selectedVersionIds.filter((id) => available.includes(id));
1238
+ }, [selectedVersionIds, versionOptions]);
1239
+ const activeScopes = resolveActiveQualityScopes(selectedScopeIds, scopeOptions);
1240
+ const activeMetrics = resolveActiveQualityMetrics(selectedMetricIds, metricOptions);
1241
+ const visiblePoints = useMemo(() => filterQualityPoints(qualityPoints, activeVersionIds, activeScopes), [activeScopes, activeVersionIds, qualityPoints]);
1242
+ const chartSeries = useMemo(() => buildQualityChartSeries(visiblePoints, activeMetrics, activeScopes), [activeMetrics, activeScopes, visiblePoints]);
1243
+ const chartAxisData = useMemo(() => buildQualityChartAxisData(chartSeries), [chartSeries]);
1244
+ const annotationHref = buildQualityAnnotationHref(line, releaseEvents);
1245
+ return (_jsxs("section", { className: "space-y-4", "data-testid": "release-quality-metrics-pane", children: [_jsxs("div", { className: "min-w-0", children: [_jsx("h2", { className: "text-[14px] font-semibold", children: t('releases.detail.quality.title') }), _jsx("p", { className: "mt-1 max-w-3xl text-[12px] leading-5 text-muted-foreground", children: t('releases.detail.quality.description') })] }), _jsxs("div", { className: "overflow-hidden rounded-lg border bg-card shadow-sm", children: [qualityPoints.length > 0 ? (_jsxs("div", { className: "flex flex-wrap items-end gap-3 border-b px-4 py-3", children: [_jsx(QualityVersionFilter, { options: versionOptions, activeIds: activeVersionIds, onChange: setSelectedVersionIds }), _jsx(QualityScopeFilter, { options: scopeOptions, activeIds: activeScopes.map((scope) => scope.id), onChange: setSelectedScopeIds }), _jsx(QualityMetricFilter, { options: metricOptions, activeIds: activeMetrics.map((metric) => metric.id), onChange: setSelectedMetricIds })] })) : null, _jsx("div", { className: "px-4 pb-2 pt-4", children: _jsx(QualityMetricsChart, { axisData: chartAxisData, series: chartSeries, children: chartSeries.length === 0 ? (qualityPoints.length === 0 ? (_jsx(QualityEmptyChartMessage, { annotationHref: annotationHref })) : (_jsx(QualityFilteredEmptyChartMessage, {}))) : null }) }), chartSeries.length > 0 ? _jsx(QualityLegend, { series: chartSeries }) : null] })] }));
1246
+ }
1247
+ function QualityVersionFilter({ options, activeIds, onChange, }) {
874
1248
  const { t } = useI18n();
875
- const items = [
876
- { key: 'score', label: t('releases.detail.quality.matchRate') },
877
- ];
878
- return (_jsx("div", { className: "flex flex-wrap items-center gap-3 text-[11.5px] text-muted-foreground", children: items.map((item) => (_jsxs("div", { className: "inline-flex items-center gap-1.5", children: [_jsx("span", { className: "size-2 rounded-full", style: { background: QUALITY_LINE_COLORS[item.key] }, "aria-hidden": "true" }), _jsx("span", { children: item.label })] }, item.key))) }));
1249
+ const [open, setOpen] = useState(false);
1250
+ const [query, setQuery] = useState('');
1251
+ const activeSet = useMemo(() => new Set(activeIds), [activeIds]);
1252
+ const normalizedQuery = normalizeQualitySearch(query);
1253
+ const latestOption = useMemo(() => [...options].sort((left, right) => timeValue(right.latestAt) - timeValue(left.latestAt))[0] ?? null, [options]);
1254
+ const productionVersionIds = useMemo(() => options.filter((option) => option.kind === 'production').map((option) => option.id), [options]);
1255
+ const canaryVersionIds = useMemo(() => options.filter((option) => option.kind !== 'production').map((option) => option.id), [options]);
1256
+ const selectedOptions = options.filter((option) => activeSet.has(option.id));
1257
+ const allSelected = options.length > 0 && selectedOptions.length === options.length;
1258
+ const productionSelected = productionVersionIds.length > 0 &&
1259
+ selectedOptions.length === productionVersionIds.length &&
1260
+ productionVersionIds.every((id) => activeSet.has(id));
1261
+ const canarySelected = canaryVersionIds.length > 0 &&
1262
+ selectedOptions.length === canaryVersionIds.length &&
1263
+ canaryVersionIds.every((id) => activeSet.has(id));
1264
+ let triggerLabel = formatTemplate(t('releases.detail.quality.filter.selectedVersions'), {
1265
+ count: formatCount(selectedOptions.length),
1266
+ });
1267
+ if (allSelected) {
1268
+ triggerLabel = t('releases.detail.quality.filter.allVersions');
1269
+ }
1270
+ else if (productionSelected) {
1271
+ triggerLabel = t('releases.detail.quality.filter.allProductionVersions');
1272
+ }
1273
+ else if (canarySelected) {
1274
+ triggerLabel = t('releases.detail.quality.filter.allCanaryVersions');
1275
+ }
1276
+ else if (selectedOptions.length === 1) {
1277
+ triggerLabel = selectedOptions[0]?.label ?? triggerLabel;
1278
+ }
1279
+ const filteredOptions = useMemo(() => {
1280
+ if (!normalizedQuery)
1281
+ return options;
1282
+ return options.filter((option) => qualitySearchIncludes(normalizedQuery, [option.label, option.promptVersion, option.model, option.id]));
1283
+ }, [normalizedQuery, options]);
1284
+ function commit(next) {
1285
+ onChange(next.length === options.length ? null : next);
1286
+ }
1287
+ return (_jsxs("div", { className: "flex min-w-[220px] flex-col gap-1.5", children: [_jsx("span", { className: "text-[11px] font-semibold uppercase tracking-wide text-muted-foreground", children: t('releases.detail.quality.filter.version') }), _jsxs(Popover, { open: open, onOpenChange: (next) => {
1288
+ setOpen(next);
1289
+ if (!next)
1290
+ setQuery('');
1291
+ }, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs(Button, { type: "button", variant: "outline", disabled: options.length === 0, className: "h-10 justify-between gap-2 px-3 text-left", children: [_jsx("span", { className: "min-w-0 truncate font-mono text-[13px] font-semibold", children: triggerLabel }), _jsx(ChevronDown, { className: "size-4 shrink-0 text-muted-foreground", "aria-hidden": true })] }) }), _jsxs(PopoverContent, { align: "start", sideOffset: 6, className: "w-[calc(100vw-2rem)] p-0 sm:w-[560px]", children: [_jsx(ResultDropdownSearchInput, { value: query, onChange: setQuery, placeholder: t('releases.detail.quality.filter.versionSearch') }), _jsxs("div", { className: "flex flex-wrap gap-x-3 gap-y-1.5 border-b px-3 py-2 text-[12px]", children: [_jsx("button", { type: "button", className: "font-medium text-primary", onClick: () => onChange(null), children: t('releases.detail.quality.filter.selectAll') }), _jsx("button", { type: "button", className: "font-medium text-primary disabled:text-muted-foreground", disabled: !latestOption, onClick: () => {
1292
+ if (latestOption)
1293
+ onChange([latestOption.id]);
1294
+ }, children: t('releases.detail.quality.filter.latestOnly') }), _jsx("button", { type: "button", className: "font-medium text-primary disabled:text-muted-foreground", disabled: productionVersionIds.length === 0, onClick: () => commit(productionVersionIds), children: t('releases.detail.quality.filter.allProductionVersions') }), _jsx("button", { type: "button", className: "font-medium text-primary disabled:text-muted-foreground", disabled: canaryVersionIds.length === 0, onClick: () => commit(canaryVersionIds), children: t('releases.detail.quality.filter.allCanaryVersions') })] }), _jsx("div", { className: "max-h-[320px] overflow-y-auto p-1.5", children: filteredOptions.length === 0 ? (_jsx("div", { className: "px-3 py-8 text-center text-[12px] text-muted-foreground", children: t('releases.detail.quality.filter.noVersions') })) : (filteredOptions.map((option) => {
1295
+ const selected = activeSet.has(option.id);
1296
+ return (_jsxs("button", { type: "button", onClick: () => commit(toggleQualityFilterValue(activeIds, option.id)), className: cn('flex w-full items-start gap-3 rounded-md px-3 py-2.5 text-left hover:bg-accent', selected && 'bg-primary/5'), children: [_jsx(ResultVersionSelectionCheck, { selected: selected }), _jsxs("span", { className: "min-w-0 flex-1", children: [_jsxs("span", { className: "flex min-w-0 flex-wrap items-center gap-2", children: [_jsx("span", { className: "min-w-0 truncate font-mono text-[13px] font-semibold", children: option.label }), _jsx(QualityVersionKindBadge, { kind: option.kind })] }), _jsxs("span", { className: "mt-1 grid gap-x-4 gap-y-1 text-[11.5px] text-muted-foreground sm:grid-cols-3", children: [_jsx("span", { className: "min-w-0 truncate", children: _jsx(ResultDropdownFieldLabel, { label: t('releases.detail.results.versionDropdown.promptVersion'), value: option.promptVersion }) }), _jsx("span", { className: "min-w-0 truncate", children: _jsx(ResultDropdownFieldLabel, { label: t('releases.detail.results.versionDropdown.model'), value: option.model }) }), _jsx("span", { className: "min-w-0 truncate", children: _jsx(ResultDropdownFieldLabel, { label: t('releases.detail.quality.filter.points'), value: formatCount(option.pointCount) }) })] })] })] }, option.id));
1297
+ })) })] })] })] }));
1298
+ }
1299
+ function QualityScopeFilter({ options, activeIds, onChange, }) {
1300
+ return (_jsx(QualityMultiSelectFilter, { labelKey: "releases.detail.quality.filter.scope", options: options, activeIds: activeIds, onChange: onChange, allLabelKey: "releases.detail.quality.filter.allScopes", selectedLabelKey: "releases.detail.quality.filter.selectedScopes", emptyLabelKey: "releases.detail.quality.filter.scopeEmpty", minWidthClassName: "min-w-[190px]" }));
879
1301
  }
880
- function QualityMetricsChart({ data }) {
1302
+ function QualityMetricFilter({ options, activeIds, onChange, }) {
1303
+ return (_jsx(QualityMultiSelectFilter, { labelKey: "releases.detail.quality.filter.metric", options: options, activeIds: activeIds, onChange: onChange, allLabelKey: "releases.detail.quality.filter.allMetrics", selectedLabelKey: "releases.detail.quality.filter.selectedMetrics", emptyLabelKey: "releases.detail.quality.filter.metricEmpty", minWidthClassName: "min-w-[170px]" }));
1304
+ }
1305
+ function QualityMultiSelectFilter({ labelKey, options, activeIds, onChange, allLabelKey, selectedLabelKey, emptyLabelKey, minWidthClassName, }) {
881
1306
  const { t } = useI18n();
882
- const metricLabels = useMemo(() => ({
883
- score: t('releases.detail.quality.matchRate'),
884
- }), [t]);
885
- return (_jsx("div", { className: "h-[320px] min-w-0 w-full", children: _jsx(ResponsiveContainer, { width: "100%", height: "100%", minWidth: 1, minHeight: 1, initialDimension: { width: 960, height: 320 }, children: _jsxs(RechartsLineChart, { data: data, margin: { top: 12, right: 16, bottom: 8, left: 0 }, children: [_jsx(CartesianGrid, { strokeDasharray: "2 3", vertical: false, stroke: "var(--border)" }), _jsx(XAxis, { dataKey: "x", axisLine: false, tickLine: false, tick: {
886
- fontSize: 10,
887
- fontFamily: 'JetBrains Mono, ui-monospace, monospace',
888
- fill: 'var(--muted-foreground)',
889
- } }), _jsx(YAxis, { axisLine: false, tickLine: false, domain: [0, 100], tick: {
890
- fontSize: 10,
891
- fontFamily: 'JetBrains Mono, ui-monospace, monospace',
892
- fill: 'var(--muted-foreground)',
893
- }, tickFormatter: (value) => `${value}%`, width: 42 }), _jsx(Tooltip, { cursor: { stroke: 'var(--border)', strokeDasharray: '4 4' }, content: (props) => (_jsx(QualityChartTooltip, { ...props, metricLabels: metricLabels, submittedLabel: t('releases.detail.quality.submitted') })) }), ['score'].map((key) => (_jsx(Line, { type: "monotone", dataKey: key, name: metricLabels[key], stroke: QUALITY_LINE_COLORS[key], strokeWidth: 2, dot: { r: 3, strokeWidth: 1.5 }, activeDot: { r: 4 }, isAnimationActive: false }, key)))] }) }) }));
894
- }
895
- function QualityChartTooltip({ active, payload, label, metricLabels, submittedLabel, }) {
1307
+ const [open, setOpen] = useState(false);
1308
+ const activeSet = useMemo(() => new Set(activeIds), [activeIds]);
1309
+ const selectedOptions = options.filter((option) => activeSet.has(option.id));
1310
+ const allSelected = options.length > 0 && selectedOptions.length === options.length;
1311
+ const triggerLabel = allSelected
1312
+ ? t(allLabelKey)
1313
+ : selectedOptions.length === 1
1314
+ ? selectedOptions[0]?.label
1315
+ : selectedOptions.length > 1
1316
+ ? formatTemplate(t(selectedLabelKey), { count: formatCount(selectedOptions.length) })
1317
+ : t(emptyLabelKey);
1318
+ function commit(next) {
1319
+ if (next.length === 0)
1320
+ return;
1321
+ onChange(next);
1322
+ }
1323
+ return (_jsxs("div", { className: cn('flex flex-col gap-1.5', minWidthClassName), children: [_jsx("span", { className: "text-[11px] font-semibold uppercase tracking-wide text-muted-foreground", children: t(labelKey) }), _jsxs(Popover, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs(Button, { type: "button", variant: "outline", disabled: options.length === 0, className: "h-10 justify-between gap-2 px-3 text-left", children: [_jsx("span", { className: "min-w-0 truncate text-[13px] font-semibold", children: triggerLabel }), _jsx(ChevronDown, { className: "size-4 shrink-0 text-muted-foreground", "aria-hidden": true })] }) }), _jsxs(PopoverContent, { align: "start", sideOffset: 6, className: "w-64 p-0", children: [_jsx("div", { className: "flex gap-3 border-b px-3 py-2 text-[12px]", children: _jsx("button", { type: "button", className: "font-medium text-primary disabled:text-muted-foreground", disabled: allSelected, onClick: () => onChange(options.map((option) => option.id)), children: t('releases.detail.quality.filter.selectAll') }) }), _jsx("div", { className: "max-h-[260px] overflow-y-auto p-1.5", children: options.length === 0 ? (_jsx("div", { className: "px-3 py-8 text-center text-[12px] text-muted-foreground", children: t(emptyLabelKey) })) : (options.map((option) => {
1324
+ const selected = activeSet.has(option.id);
1325
+ return (_jsxs("button", { type: "button", onClick: () => commit(toggleQualityFilterValue(activeIds, option.id)), className: cn('flex w-full items-center gap-3 rounded-md px-3 py-2 text-left hover:bg-accent', selected && 'bg-primary/5'), children: [_jsx(ResultVersionSelectionCheck, { selected: selected }), _jsx("span", { className: "min-w-0 flex-1 truncate text-[13px] font-medium", children: option.label }), option.meta ? (_jsx("span", { className: "shrink-0 text-[11px] text-muted-foreground", children: option.meta })) : null] }, option.id));
1326
+ })) })] })] })] }));
1327
+ }
1328
+ function QualityVersionKindBadge({ kind }) {
1329
+ const { t } = useI18n();
1330
+ const isProduction = kind === 'production';
1331
+ return (_jsx("span", { className: "inline-flex shrink-0 items-center rounded-full border px-2 py-0.5 text-[10.5px] font-medium leading-4", style: {
1332
+ background: isProduction ? 'var(--src-prod-soft)' : 'var(--src-canary-soft)',
1333
+ color: isProduction ? 'var(--src-prod-fg)' : 'var(--src-canary-fg)',
1334
+ borderColor: isProduction
1335
+ ? 'color-mix(in srgb, var(--src-prod) 30%, transparent)'
1336
+ : 'color-mix(in srgb, var(--src-canary) 30%, transparent)',
1337
+ }, children: t(isProduction
1338
+ ? 'releases.detail.history.versionKind.production'
1339
+ : 'releases.detail.history.versionKind.candidate') }));
1340
+ }
1341
+ function QualityLegend({ series }) {
1342
+ const { t } = useI18n();
1343
+ return (_jsxs("div", { className: "flex flex-wrap items-center gap-x-5 gap-y-2 border-t px-4 py-3 text-[11.5px] text-muted-foreground", children: [series.map((item) => (_jsxs("span", { className: "inline-flex items-center gap-1.5", children: [_jsx("span", { className: "h-0.5 w-5 rounded-full", style: { background: item.color }, "aria-hidden": true }), item.label] }, item.id))), _jsxs("span", { className: "inline-flex items-center gap-1.5 border-l pl-4", children: [_jsx("span", { className: "size-2.5 rounded-full bg-[var(--src-prod)]", "aria-hidden": true }), t('releases.detail.quality.legend.production')] }), _jsxs("span", { className: "inline-flex items-center gap-1.5", children: [_jsx("span", { className: "size-2.5 rounded-full border-2 border-[var(--src-canary)] bg-card", "aria-hidden": true }), t('releases.detail.quality.legend.canary')] })] }));
1344
+ }
1345
+ function readChartCssColor(name, fallback) {
1346
+ if (typeof window === 'undefined')
1347
+ return fallback;
1348
+ const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
1349
+ return value || fallback;
1350
+ }
1351
+ function resolveChartColor(value, fallback) {
1352
+ const variableName = value.match(/var\((--[^),\s]+)/)?.[1];
1353
+ return variableName ? readChartCssColor(variableName, fallback) : value;
1354
+ }
1355
+ function escapeHtml(value) {
1356
+ return value.replace(/[&<>"']/g, (char) => {
1357
+ const escaped = {
1358
+ '&': '&amp;',
1359
+ '<': '&lt;',
1360
+ '>': '&gt;',
1361
+ '"': '&quot;',
1362
+ "'": '&#39;',
1363
+ };
1364
+ return escaped[char] ?? char;
1365
+ });
1366
+ }
1367
+ function resolveQualityPercentAxisExtent(extent) {
1368
+ if (!Number.isFinite(extent.min) || !Number.isFinite(extent.max)) {
1369
+ return { min: 0, max: 100 };
1370
+ }
1371
+ const dataMin = Math.max(0, Math.min(100, Math.min(extent.min, extent.max)));
1372
+ const dataMax = Math.max(0, Math.min(100, Math.max(extent.min, extent.max)));
1373
+ const dataSpan = dataMax - dataMin;
1374
+ const padding = Math.max(2, dataSpan * 0.2);
1375
+ let nextMin = Math.max(0, dataMin - padding);
1376
+ let nextMax = Math.min(100, dataMax + padding);
1377
+ if (nextMax - nextMin < 10) {
1378
+ const center = (dataMin + dataMax) / 2;
1379
+ nextMin = Math.max(0, center - 5);
1380
+ nextMax = Math.min(100, center + 5);
1381
+ if (nextMin === 0)
1382
+ nextMax = Math.min(100, Math.max(10, nextMax));
1383
+ if (nextMax === 100)
1384
+ nextMin = Math.max(0, Math.min(90, nextMin));
1385
+ }
1386
+ const roundedMin = Math.max(0, Math.floor(nextMin / 5) * 5);
1387
+ const roundedMax = Math.min(100, Math.ceil(nextMax / 5) * 5);
1388
+ return roundedMax > roundedMin ? { min: roundedMin, max: roundedMax } : { min: 0, max: 100 };
1389
+ }
1390
+ function getQualityPercentAxisMin(extent) {
1391
+ return resolveQualityPercentAxisExtent(extent).min;
1392
+ }
1393
+ function getQualityPercentAxisMax(extent) {
1394
+ return resolveQualityPercentAxisExtent(extent).max;
1395
+ }
1396
+ function QualityMetricsChart({ axisData, series, children, }) {
896
1397
  const { t } = useI18n();
897
1398
  const formatDateTimeOrDash = useDateTimeOrDash();
898
- if (!active || !payload || payload.length === 0)
899
- return null;
900
- const point = payload[0]?.payload;
901
- if (!point)
902
- return null;
903
- return (_jsxs("div", { className: "min-w-[220px] rounded-md border bg-popover px-2.5 py-2 text-[12px] shadow-md", children: [_jsxs("div", { className: "mb-1 font-mono text-[10.5px] text-muted-foreground", children: [label, " \u00B7 ", formatDateTimeOrDash(point.updatedAt)] }), _jsx("div", { className: "font-semibold", children: point.name }), _jsxs("div", { className: "mt-0.5 text-[11.5px] text-muted-foreground", children: [point.releaseVariantLabel, " \u00B7 ", point.promptVersionLabel, " \u00B7 ", point.modelName] }), _jsxs("div", { className: "mt-2 space-y-0.5", children: [['score'].map((key) => (_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "size-2 rounded-full", style: { background: QUALITY_LINE_COLORS[key] }, "aria-hidden": true }), _jsx("span", { className: "text-muted-foreground", children: metricLabels[key] }), _jsx("span", { className: "ml-auto font-mono", children: formatQualityPercent(point[key]) })] }, key))), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "text-muted-foreground", children: t('releases.detail.quality.matched') }), _jsx("span", { className: "ml-auto font-mono", children: formatCount(point.matched) })] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "text-muted-foreground", children: t('releases.detail.quality.mismatched') }), _jsx("span", { className: "ml-auto font-mono", children: formatCount(point.mismatched) })] }), _jsxs("div", { className: "mt-1 flex items-center gap-2 border-t pt-1", children: [_jsx("span", { className: "text-muted-foreground", children: submittedLabel }), _jsxs("span", { className: "ml-auto font-mono", children: [formatCount(point.submitted), " / ", formatCount(point.total)] })] })] })] }));
1399
+ const chartRef = useRef(null);
1400
+ const chartInstanceRef = useRef(null);
1401
+ const [chartColorVersion, setChartColorVersion] = useState(0);
1402
+ useEffect(() => {
1403
+ if (typeof MutationObserver === 'undefined')
1404
+ return undefined;
1405
+ const observer = new MutationObserver(() => setChartColorVersion((current) => current + 1));
1406
+ observer.observe(document.documentElement, {
1407
+ attributes: true,
1408
+ attributeFilter: ['class', 'style', 'data-theme'],
1409
+ });
1410
+ return () => observer.disconnect();
1411
+ }, []);
1412
+ const chartOption = useMemo(() => {
1413
+ void chartColorVersion;
1414
+ const foregroundColor = readChartCssColor('--foreground', '#e5e7eb');
1415
+ const mutedColor = readChartCssColor('--muted-foreground', '#94a3b8');
1416
+ const borderColor = readChartCssColor('--border', '#1f2937');
1417
+ const cardColor = readChartCssColor('--card', '#020617');
1418
+ const popoverColor = readChartCssColor('--popover', cardColor);
1419
+ const primaryColor = readChartCssColor('--primary', '#60a5fa');
1420
+ const productionColor = readChartCssColor('--src-prod', '#22c55e');
1421
+ const canaryColor = readChartCssColor('--src-canary', '#3b82f6');
1422
+ const categoryLabels = axisData.map((point) => point.releaseVersionLabel);
1423
+ const qualitySeries = series.map((item) => {
1424
+ const pointByEvent = new Map(item.points.map((point) => [point.eventId, point]));
1425
+ const lineColor = resolveChartColor(item.color, primaryColor);
1426
+ return {
1427
+ id: item.id,
1428
+ name: item.label,
1429
+ type: 'line',
1430
+ smooth: true,
1431
+ connectNulls: true,
1432
+ showSymbol: true,
1433
+ symbol: 'circle',
1434
+ symbolSize: 8,
1435
+ lineStyle: {
1436
+ width: 2,
1437
+ color: lineColor,
1438
+ opacity: 1,
1439
+ },
1440
+ itemStyle: {
1441
+ color: lineColor,
1442
+ opacity: 1,
1443
+ },
1444
+ emphasis: {
1445
+ disabled: true,
1446
+ },
1447
+ blur: {
1448
+ lineStyle: {
1449
+ opacity: 1,
1450
+ },
1451
+ itemStyle: {
1452
+ opacity: 1,
1453
+ },
1454
+ },
1455
+ data: axisData.map((axisPoint) => {
1456
+ const point = pointByEvent.get(axisPoint.eventId);
1457
+ if (!point)
1458
+ return null;
1459
+ const laneColor = point.lane === 'production' ? productionColor : canaryColor;
1460
+ return {
1461
+ value: point.value,
1462
+ qualityPoint: point,
1463
+ symbol: 'circle',
1464
+ symbolSize: 8,
1465
+ itemStyle: {
1466
+ color: point.lane === 'production' ? laneColor : cardColor,
1467
+ borderColor: laneColor,
1468
+ borderWidth: 2,
1469
+ },
1470
+ };
1471
+ }),
1472
+ };
1473
+ });
1474
+ return {
1475
+ animation: false,
1476
+ backgroundColor: 'transparent',
1477
+ grid: {
1478
+ top: 26,
1479
+ right: 24,
1480
+ bottom: axisData.length > 1 ? 62 : 38,
1481
+ left: 48,
1482
+ containLabel: false,
1483
+ },
1484
+ tooltip: {
1485
+ trigger: 'axis',
1486
+ appendToBody: true,
1487
+ confine: true,
1488
+ borderWidth: 1,
1489
+ borderColor,
1490
+ backgroundColor: popoverColor,
1491
+ textStyle: {
1492
+ color: foregroundColor,
1493
+ fontSize: 12,
1494
+ fontFamily: 'inherit',
1495
+ },
1496
+ extraCssText: 'box-shadow: 0 12px 30px rgba(15, 23, 42, 0.22); border-radius: 6px;',
1497
+ axisPointer: {
1498
+ type: 'line',
1499
+ lineStyle: {
1500
+ color: borderColor,
1501
+ type: 'dashed',
1502
+ },
1503
+ },
1504
+ formatter: (rawParams) => {
1505
+ const params = (Array.isArray(rawParams) ? rawParams : [rawParams]);
1506
+ const points = params
1507
+ .map((param) => param.data?.qualityPoint)
1508
+ .filter((point) => Boolean(point));
1509
+ const point = points[0];
1510
+ if (!point)
1511
+ return '';
1512
+ const kindText = t(point.releaseVersionKind === 'production'
1513
+ ? 'releases.detail.history.versionKind.production'
1514
+ : 'releases.detail.history.versionKind.candidate');
1515
+ const rows = params
1516
+ .map((param) => {
1517
+ const qualityPoint = param.data?.qualityPoint;
1518
+ if (!qualityPoint)
1519
+ return '';
1520
+ const sampleCount = qualityPoint.sampleCount === null
1521
+ ? '—'
1522
+ : formatTemplate(t('releases.detail.quality.sampleCountShort'), {
1523
+ count: formatCount(qualityPoint.sampleCount),
1524
+ });
1525
+ return [
1526
+ '<div style="display:flex;align-items:center;gap:8px;min-width:260px;margin-top:4px;">',
1527
+ param.marker ?? '',
1528
+ `<span style="min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:${mutedColor};">${escapeHtml(param.seriesName ?? qualityPoint.seriesLabel)}</span>`,
1529
+ `<span style="font-family:JetBrains Mono, ui-monospace, monospace;font-weight:600;">${escapeHtml(formatQualityPercent(qualityPoint.value))}</span>`,
1530
+ `<span style="font-family:JetBrains Mono, ui-monospace, monospace;font-size:11px;color:${mutedColor};">${escapeHtml(sampleCount)}</span>`,
1531
+ '</div>',
1532
+ ].join('');
1533
+ })
1534
+ .join('');
1535
+ return [
1536
+ '<div style="min-width:280px;">',
1537
+ `<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;font-family:JetBrains Mono, ui-monospace, monospace;font-size:11px;color:${mutedColor};">`,
1538
+ `<span>${escapeHtml(point.xLabel)}</span><span>·</span><span>${escapeHtml(formatDateTimeOrDash(point.updatedAt ?? point.createdAt))}</span>`,
1539
+ '</div>',
1540
+ '<div style="display:flex;align-items:center;gap:8px;min-width:0;">',
1541
+ `<span style="min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:600;">${escapeHtml(point.eventLabel)}</span>`,
1542
+ `<span style="display:inline-flex;align-items:center;border-radius:999px;border:1px solid ${borderColor};padding:1px 6px;font-size:11px;color:${mutedColor};">${escapeHtml(kindText)}</span>`,
1543
+ '</div>',
1544
+ `<div style="margin-top:2px;font-size:11.5px;color:${mutedColor};">${escapeHtml(point.releaseVersionLabel)} · ${escapeHtml(point.promptVersionLabel)} · ${escapeHtml(point.modelName)}</div>`,
1545
+ `<div style="margin-top:8px;">${rows}</div>`,
1546
+ '</div>',
1547
+ ].join('');
1548
+ },
1549
+ },
1550
+ xAxis: {
1551
+ type: 'category',
1552
+ boundaryGap: false,
1553
+ data: categoryLabels,
1554
+ axisLine: {
1555
+ show: false,
1556
+ },
1557
+ axisTick: {
1558
+ show: false,
1559
+ },
1560
+ axisLabel: {
1561
+ color: mutedColor,
1562
+ fontSize: 10,
1563
+ fontFamily: 'JetBrains Mono, ui-monospace, monospace',
1564
+ hideOverlap: true,
1565
+ margin: 10,
1566
+ },
1567
+ },
1568
+ yAxis: {
1569
+ type: 'value',
1570
+ min: getQualityPercentAxisMin,
1571
+ max: getQualityPercentAxisMax,
1572
+ scale: true,
1573
+ splitNumber: 4,
1574
+ axisLine: {
1575
+ show: false,
1576
+ },
1577
+ axisTick: {
1578
+ show: false,
1579
+ },
1580
+ axisLabel: {
1581
+ color: mutedColor,
1582
+ fontSize: 10,
1583
+ fontFamily: 'JetBrains Mono, ui-monospace, monospace',
1584
+ formatter: '{value}%',
1585
+ },
1586
+ splitLine: {
1587
+ lineStyle: {
1588
+ color: borderColor,
1589
+ type: 'dashed',
1590
+ opacity: 0.7,
1591
+ },
1592
+ },
1593
+ },
1594
+ dataZoom: axisData.length > 1
1595
+ ? [
1596
+ {
1597
+ type: 'inside',
1598
+ xAxisIndex: 0,
1599
+ filterMode: 'filter',
1600
+ zoomOnMouseWheel: true,
1601
+ moveOnMouseWheel: 'shift',
1602
+ moveOnMouseMove: true,
1603
+ preventDefaultMouseMove: true,
1604
+ minValueSpan: 1,
1605
+ },
1606
+ {
1607
+ type: 'slider',
1608
+ xAxisIndex: 0,
1609
+ filterMode: 'filter',
1610
+ bottom: 10,
1611
+ height: 24,
1612
+ showDetail: false,
1613
+ brushSelect: true,
1614
+ minValueSpan: 1,
1615
+ borderColor,
1616
+ fillerColor: 'rgba(59, 130, 246, 0.18)',
1617
+ backgroundColor: 'transparent',
1618
+ dataBackground: {
1619
+ lineStyle: {
1620
+ color: borderColor,
1621
+ },
1622
+ areaStyle: {
1623
+ color: 'rgba(148, 163, 184, 0.14)',
1624
+ },
1625
+ },
1626
+ selectedDataBackground: {
1627
+ lineStyle: {
1628
+ color: primaryColor,
1629
+ },
1630
+ areaStyle: {
1631
+ color: 'rgba(59, 130, 246, 0.22)',
1632
+ },
1633
+ },
1634
+ moveHandleStyle: {
1635
+ color: primaryColor,
1636
+ },
1637
+ handleStyle: {
1638
+ color: cardColor,
1639
+ borderColor: primaryColor,
1640
+ },
1641
+ },
1642
+ ]
1643
+ : [],
1644
+ toolbox: {
1645
+ show: axisData.length > 1,
1646
+ right: 8,
1647
+ top: 0,
1648
+ itemSize: 14,
1649
+ iconStyle: {
1650
+ borderColor: mutedColor,
1651
+ },
1652
+ emphasis: {
1653
+ iconStyle: {
1654
+ borderColor: foregroundColor,
1655
+ },
1656
+ },
1657
+ feature: {
1658
+ restore: {
1659
+ title: t('releases.detail.quality.chart.resetView'),
1660
+ },
1661
+ },
1662
+ },
1663
+ series: qualitySeries,
1664
+ };
1665
+ }, [axisData, chartColorVersion, formatDateTimeOrDash, series, t]);
1666
+ useEffect(() => {
1667
+ const element = chartRef.current;
1668
+ if (!element)
1669
+ return undefined;
1670
+ const instance = echarts.init(element, undefined, { renderer: 'svg' });
1671
+ chartInstanceRef.current = instance;
1672
+ const resizeObserver = typeof ResizeObserver === 'undefined'
1673
+ ? null
1674
+ : new ResizeObserver(() => {
1675
+ instance.resize();
1676
+ });
1677
+ resizeObserver?.observe(element);
1678
+ return () => {
1679
+ resizeObserver?.disconnect();
1680
+ chartInstanceRef.current = null;
1681
+ instance.dispose();
1682
+ };
1683
+ }, []);
1684
+ useEffect(() => {
1685
+ chartInstanceRef.current?.setOption(chartOption, true);
1686
+ }, [chartOption]);
1687
+ return (_jsx("div", { className: "relative min-w-0 w-full", children: _jsxs("div", { className: "relative h-[360px] min-w-0 w-full", children: [_jsx("div", { ref: chartRef, className: "h-full w-full", role: "img", "aria-label": t('releases.detail.quality.chartTitle'), "data-testid": "release-quality-echarts-chart" }), children ? (_jsx("div", { className: "pointer-events-none absolute inset-0 flex items-center justify-center px-4", children: _jsx("div", { className: "pointer-events-auto w-full max-w-[360px] rounded-lg border bg-card/95 px-4 py-4 text-center shadow-sm", children: children }) })) : null] }) }));
1688
+ }
1689
+ function QualityEmptyChartMessage({ annotationHref }) {
1690
+ const { t } = useI18n();
1691
+ return (_jsxs("div", { "data-testid": "release-quality-empty", children: [_jsx("div", { className: "text-[14px] font-semibold", children: t('releases.detail.quality.empty') }), _jsx("p", { className: "mx-auto mt-1 max-w-[300px] text-[12px] leading-5 text-muted-foreground", children: t('releases.detail.quality.emptyDescription') }), _jsx(Button, { asChild: true, size: "sm", className: "mt-3 h-8 gap-1.5", children: _jsxs(Link, { href: annotationHref, children: [_jsx(Plus, { className: "size-3.5", "aria-hidden": true }), t('releases.detail.quality.emptyAction')] }) })] }));
1692
+ }
1693
+ function QualityFilteredEmptyChartMessage() {
1694
+ const { t } = useI18n();
1695
+ return (_jsx("div", { className: "text-[13px] font-medium text-muted-foreground", children: t('releases.detail.quality.filteredEmpty') }));
904
1696
  }
905
- function buildReleaseEventMeta(event) {
1697
+ function formatTemplate(template, values) {
1698
+ return template.replace(/\{(\w+)\}/g, (_, key) => String(values[key] ?? ''));
1699
+ }
1700
+ const RELEASE_CONFIG_OPERATIONS = new Set([
1701
+ 'traffic_updated',
1702
+ 'mode_updated',
1703
+ 'config_changed',
1704
+ ]);
1705
+ export function buildHistoryGroups(line, releaseEvents, productionHistory, t) {
1706
+ if (releaseEvents.length > 0 || line.versions.length > 0) {
1707
+ return buildCanonicalHistoryGroups(line, releaseEvents, t);
1708
+ }
1709
+ return buildLegacyHistoryGroups(line, productionHistory, t);
1710
+ }
1711
+ function buildCanonicalHistoryGroups(line, releaseEvents, t) {
1712
+ const eventsByVersion = groupReleaseEventsByVersion(releaseEvents);
1713
+ const rowsByVersion = new Map();
1714
+ const looseRows = [];
1715
+ for (const version of line.versions) {
1716
+ const events = eventsByVersion.get(version.id) ?? [];
1717
+ rowsByVersion.set(version.id, buildHistoryRowFromVersion(line, version, events, t));
1718
+ }
1719
+ for (const [key, events] of eventsByVersion.entries()) {
1720
+ if (rowsByVersion.has(key))
1721
+ continue;
1722
+ looseRows.push(buildHistoryRowFromEvents(line, events, t));
1723
+ }
1724
+ const grouped = new Map();
1725
+ for (const row of [...rowsByVersion.values(), ...looseRows]) {
1726
+ const key = getHistoryGroupKey(row);
1727
+ const group = ensureHistoryGroup(grouped, key, row);
1728
+ if (isProductionHistoryRow(row)) {
1729
+ group.production = chooseNewestHistoryRow(group.production, row);
1730
+ group.productionNumber = row.productionNumber ?? row.targetProductionNumber ?? group.productionNumber;
1731
+ }
1732
+ else {
1733
+ group.candidates.push(row);
1734
+ }
1735
+ group.isLive = group.isLive || isHistoryRowLive(row);
1736
+ group.sortAt = chooseNewestDate(group.sortAt, row.updatedAt ?? row.createdAt);
1737
+ }
1738
+ return [...grouped.values()]
1739
+ .map((group) => ({
1740
+ ...group,
1741
+ candidates: group.candidates.sort(compareHistoryRows),
1742
+ }))
1743
+ .sort(compareHistoryGroups);
1744
+ }
1745
+ function buildLegacyHistoryGroups(line, productionHistory, t) {
1746
+ const productionRows = productionHistory.map((item) => buildLegacyProductionHistoryRow(item, t));
1747
+ const canaryRows = (line.canaryHistory.length > 0 ? line.canaryHistory : line.canary ? [line.canary] : []).map((item) => buildLegacyCanaryHistoryRow(item, t));
1748
+ if (productionRows.length === 0 && canaryRows.length === 0)
1749
+ return [];
1750
+ const groups = productionRows.map((row) => ({
1751
+ id: row.id,
1752
+ production: row,
1753
+ candidates: [],
1754
+ isLive: isHistoryRowLive(row),
1755
+ sortAt: row.updatedAt ?? row.createdAt,
1756
+ productionNumber: row.productionNumber,
1757
+ }));
1758
+ const fallbackGroup = groups[0] ??
1759
+ {
1760
+ id: canaryRows[0]?.id ?? 'legacy-canary',
1761
+ production: null,
1762
+ candidates: [],
1763
+ isLive: false,
1764
+ sortAt: canaryRows[0]?.updatedAt ?? canaryRows[0]?.createdAt ?? null,
1765
+ productionNumber: null,
1766
+ };
1767
+ if (groups.length === 0)
1768
+ groups.push(fallbackGroup);
1769
+ for (const row of canaryRows) {
1770
+ fallbackGroup.candidates.push(row);
1771
+ fallbackGroup.isLive = fallbackGroup.isLive || isHistoryRowLive(row);
1772
+ fallbackGroup.sortAt = chooseNewestDate(fallbackGroup.sortAt, row.updatedAt ?? row.createdAt);
1773
+ }
1774
+ return groups
1775
+ .map((group) => ({ ...group, candidates: group.candidates.sort(compareHistoryRows) }))
1776
+ .sort(compareHistoryGroups);
1777
+ }
1778
+ function groupReleaseEventsByVersion(events) {
1779
+ const grouped = new Map();
1780
+ for (const event of events) {
1781
+ const key = event.releaseVersionId ?? event.id;
1782
+ const current = grouped.get(key) ?? [];
1783
+ current.push(event);
1784
+ grouped.set(key, current);
1785
+ }
1786
+ return grouped;
1787
+ }
1788
+ function buildHistoryRowFromVersion(line, version, events, t) {
1789
+ const latest = getLatestReleaseEvent(events);
1790
+ const label = latest?.releaseVersionLabel ?? version.label;
1791
+ return {
1792
+ id: latest?.id ?? version.id,
1793
+ sourceEventId: latest?.id ?? null,
1794
+ releaseVersionId: version.id,
1795
+ releaseVersionKind: version.kind,
1796
+ releaseVersionLabel: label,
1797
+ productionNumber: version.productionVersionNumber,
1798
+ targetProductionNumber: version.targetProductionVersionNumber,
1799
+ candidateNumber: version.candidateNumber,
1800
+ event: latest?.operation ?? (version.kind === 'production' ? 'create_production' : 'create_canary'),
1801
+ laneType: latest?.laneType ?? (version.kind === 'production' ? 'production' : 'canary'),
1802
+ promptName: latest?.promptName ?? version.promptName,
1803
+ promptVersionId: latest?.promptVersionId ?? version.promptVersionId,
1804
+ promptVersionLabel: latest?.promptVersionLabel ??
1805
+ version.promptVersionLabel ??
1806
+ (version.promptVersionNumber ? `v${version.promptVersionNumber}` : formatShortId(version.promptVersionId)),
1807
+ modelId: latest?.modelId ?? version.modelId,
1808
+ modelName: formatHistoryModel(latest?.modelName ?? version.modelName, latest?.modelId ?? version.modelId),
1809
+ modelProvider: latest?.modelProvider ?? version.modelProvider,
1810
+ inputConnectorName: latest?.inputConnectorName ?? line.inputConnectorName,
1811
+ inputConnectorType: latest?.inputConnectorType ?? line.inputConnectorType,
1812
+ outputConnectors: latest?.outputConnectors ?? line.outputConnectors,
1813
+ runConfig: normalizeRunConfig(latest?.runConfig),
1814
+ trafficRatio: latest?.trafficRatio ?? null,
1815
+ trafficMode: latest?.trafficMode ?? null,
1816
+ recordMode: latest?.recordMode ?? null,
1817
+ recordCategories: latest?.recordCategories ?? [],
1818
+ status: latest ? formatReleaseEventStatus(latest) : null,
1819
+ isLive: latest?.status === 'running',
1820
+ countSummary: latest ? formatReleaseEventCounts(latest, t) : null,
1821
+ relations: latest ? formatReleaseEventRelations(latest, t) : null,
1822
+ reason: latest?.submitReason.trim() || null,
1823
+ createdAt: latest?.createdAt ?? version.createdAt,
1824
+ updatedAt: latest?.updatedAt ?? version.updatedAt,
1825
+ configChanges: buildReleaseConfigChanges(events, t),
1826
+ };
1827
+ }
1828
+ function buildHistoryRowFromEvents(line, events, t) {
1829
+ const latest = getLatestReleaseEvent(events);
1830
+ if (!latest) {
1831
+ return {
1832
+ id: 'empty',
1833
+ sourceEventId: null,
1834
+ releaseVersionId: null,
1835
+ releaseVersionKind: null,
1836
+ releaseVersionLabel: '—',
1837
+ productionNumber: null,
1838
+ targetProductionNumber: null,
1839
+ candidateNumber: null,
1840
+ event: null,
1841
+ laneType: null,
1842
+ promptName: line.promptName,
1843
+ promptVersionId: null,
1844
+ promptVersionLabel: '—',
1845
+ modelId: null,
1846
+ modelName: '—',
1847
+ modelProvider: null,
1848
+ inputConnectorName: line.inputConnectorName,
1849
+ inputConnectorType: line.inputConnectorType,
1850
+ outputConnectors: line.outputConnectors,
1851
+ runConfig: {},
1852
+ trafficRatio: null,
1853
+ trafficMode: null,
1854
+ recordMode: null,
1855
+ recordCategories: [],
1856
+ status: null,
1857
+ isLive: false,
1858
+ countSummary: null,
1859
+ relations: null,
1860
+ reason: null,
1861
+ createdAt: null,
1862
+ updatedAt: null,
1863
+ configChanges: [],
1864
+ };
1865
+ }
1866
+ return {
1867
+ id: latest.id,
1868
+ sourceEventId: latest.id,
1869
+ releaseVersionId: latest.releaseVersionId,
1870
+ releaseVersionKind: latest.releaseVersionKind,
1871
+ releaseVersionLabel: latest.releaseVersionLabel ?? formatShortId(latest.releaseVersionId),
1872
+ productionNumber: latest.releaseVersionProductionNumber,
1873
+ targetProductionNumber: latest.releaseVersionTargetProductionNumber,
1874
+ candidateNumber: latest.releaseVersionCandidateNumber,
1875
+ event: latest.operation,
1876
+ laneType: latest.laneType,
1877
+ promptName: latest.promptName,
1878
+ promptVersionId: latest.promptVersionId,
1879
+ promptVersionLabel: latest.promptVersionLabel ?? formatShortId(latest.promptVersionId),
1880
+ modelId: latest.modelId,
1881
+ modelName: formatHistoryModel(latest.modelName, latest.modelId),
1882
+ modelProvider: latest.modelProvider,
1883
+ inputConnectorName: latest.inputConnectorName ?? line.inputConnectorName,
1884
+ inputConnectorType: latest.inputConnectorType ?? line.inputConnectorType,
1885
+ outputConnectors: latest.outputConnectors.length > 0 ? latest.outputConnectors : line.outputConnectors,
1886
+ runConfig: normalizeRunConfig(latest.runConfig),
1887
+ trafficRatio: latest.trafficRatio,
1888
+ trafficMode: latest.trafficMode,
1889
+ recordMode: latest.recordMode,
1890
+ recordCategories: latest.recordCategories,
1891
+ status: formatReleaseEventStatus(latest),
1892
+ isLive: latest.status === 'running',
1893
+ countSummary: formatReleaseEventCounts(latest, t),
1894
+ relations: formatReleaseEventRelations(latest, t),
1895
+ reason: latest.submitReason.trim() || null,
1896
+ createdAt: latest.createdAt,
1897
+ updatedAt: latest.updatedAt,
1898
+ configChanges: buildReleaseConfigChanges(events, t),
1899
+ };
1900
+ }
1901
+ function buildLegacyProductionHistoryRow(item, t) {
1902
+ const configChanges = item.eventType === 'config_change'
1903
+ ? [
1904
+ {
1905
+ id: item.id,
1906
+ at: item.updatedAt,
1907
+ event: item.eventType,
1908
+ items: buildLegacyProductionConfigItems(item, t),
1909
+ },
1910
+ ]
1911
+ : [];
1912
+ const relations = [
1913
+ item.sourceExperimentId
1914
+ ? `${t('releases.detail.history.relation.sourceExperiment')} ${formatShortId(item.sourceExperimentId)}`
1915
+ : null,
1916
+ item.sourceCanaryId
1917
+ ? `${t('releases.detail.history.relation.sourceEvent')} ${formatShortId(item.sourceCanaryId)}`
1918
+ : null,
1919
+ item.rollbackTargetEventId
1920
+ ? `${t('releases.detail.history.relation.rollbackTarget')} ${formatShortId(item.rollbackTargetEventId)}`
1921
+ : null,
1922
+ ].filter((value) => Boolean(value));
1923
+ return {
1924
+ id: item.id,
1925
+ sourceEventId: item.id,
1926
+ releaseVersionId: null,
1927
+ releaseVersionKind: 'production',
1928
+ releaseVersionLabel: item.promptVersionLabel ?? formatShortId(item.id),
1929
+ productionNumber: null,
1930
+ targetProductionNumber: null,
1931
+ candidateNumber: null,
1932
+ event: item.eventType,
1933
+ laneType: 'production',
1934
+ promptName: item.promptVersionLabel ?? '—',
1935
+ promptVersionId: item.promptVersionId,
1936
+ promptVersionLabel: item.promptVersionLabel ?? formatShortId(item.promptVersionId),
1937
+ modelId: item.modelId,
1938
+ modelName: item.modelName ?? formatShortId(item.modelId),
1939
+ modelProvider: null,
1940
+ inputConnectorName: item.inputConnectorName,
1941
+ inputConnectorType: null,
1942
+ outputConnectors: [],
1943
+ runConfig: normalizeRunConfig(item.runConfig),
1944
+ trafficRatio: null,
1945
+ trafficMode: null,
1946
+ recordMode: item.recordMode,
1947
+ recordCategories: item.recordCategories ?? [],
1948
+ status: formatLegacyProductionStatus(item),
1949
+ isLive: item.status === 'running',
1950
+ countSummary: null,
1951
+ relations: relations.length > 0 ? relations.join(' · ') : null,
1952
+ reason: item.submitReason.trim() || null,
1953
+ createdAt: item.createdAt,
1954
+ updatedAt: item.updatedAt,
1955
+ configChanges,
1956
+ };
1957
+ }
1958
+ function buildLegacyCanaryHistoryRow(canary, t) {
1959
+ return {
1960
+ id: canary.id,
1961
+ sourceEventId: canary.id,
1962
+ releaseVersionId: canary.releaseVersionId,
1963
+ releaseVersionKind: 'candidate',
1964
+ releaseVersionLabel: canary.releaseVersionLabel ?? canary.promptVersionLabel ?? formatShortId(canary.id),
1965
+ productionNumber: null,
1966
+ targetProductionNumber: null,
1967
+ candidateNumber: null,
1968
+ event: canary.status === 'running' ? 'ratio_change' : 'create_canary',
1969
+ laneType: 'canary',
1970
+ promptName: canary.promptName ?? canary.name ?? '—',
1971
+ promptVersionId: canary.promptVersionId,
1972
+ promptVersionLabel: canary.promptVersionLabel ?? formatShortId(canary.promptVersionId),
1973
+ modelId: canary.modelId,
1974
+ modelName: formatHistoryModel(canary.modelName, canary.modelId, canary.modelProvider),
1975
+ modelProvider: canary.modelProvider,
1976
+ inputConnectorName: canary.inputConnectorName,
1977
+ inputConnectorType: canary.inputConnectorType,
1978
+ outputConnectors: canary.outputConnectors,
1979
+ runConfig: normalizeRunConfig(canary.runConfig),
1980
+ trafficRatio: canary.trafficRatio,
1981
+ trafficMode: canary.trafficMode,
1982
+ recordMode: canary.recordMode,
1983
+ recordCategories: canary.recordCategories ?? [],
1984
+ status: canary.status,
1985
+ isLive: canary.status === 'running',
1986
+ countSummary: formatCanaryCounts(canary, t),
1987
+ relations: null,
1988
+ reason: canary.description?.trim() || null,
1989
+ createdAt: canary.createdAt,
1990
+ updatedAt: canary.updatedAt,
1991
+ configChanges: [],
1992
+ };
1993
+ }
1994
+ function formatReleaseEventStatus(event) {
906
1995
  const parts = [
907
1996
  event.status,
908
- event.trafficRatio !== null ? `${Math.round(event.trafficRatio * 100)}%` : null,
909
- event.trafficMode,
910
- event.submitReason,
1997
+ event.terminalReason ? `${event.terminalReason}` : null,
1998
+ event.controlState ? `${event.controlState}` : null,
1999
+ ].filter((value) => Boolean(value));
2000
+ return parts.join(' · ') || '—';
2001
+ }
2002
+ function formatLegacyProductionStatus(item) {
2003
+ return [item.status, item.stopReason, item.controlState].filter(Boolean).join(' · ') || '—';
2004
+ }
2005
+ function formatHistoryModel(name, id, provider) {
2006
+ const model = name ?? formatShortId(id);
2007
+ return provider ? `${model} · ${provider}` : model;
2008
+ }
2009
+ function formatRecordMode(mode, t, categories = []) {
2010
+ if (mode === 'all')
2011
+ return t('releases.detail.topology.recordMode.all');
2012
+ if (mode === 'selected_categories' || mode === 'correct_only') {
2013
+ const label = t('releases.detail.topology.recordMode.selectedCategories');
2014
+ return categories.length > 0 ? `${label}: ${categories.join('、')}` : label;
2015
+ }
2016
+ return mode || '—';
2017
+ }
2018
+ function formatReleaseEventCounts(event, t) {
2019
+ return [
2020
+ `${t('releases.detail.metric.received')} ${formatCount(event.totalReceived)}`,
2021
+ `${t('releases.detail.metric.processed')} ${formatCount(event.totalProcessed)}`,
2022
+ `${t('releases.detail.metric.errors')} ${formatCount(event.totalErrors)}`,
2023
+ ].join(' · ');
2024
+ }
2025
+ function formatCanaryCounts(canary, t) {
2026
+ return [
2027
+ `${t('releases.detail.metric.received')} ${formatCount(canary.totalReceived)}`,
2028
+ `${t('releases.detail.metric.processed')} ${formatCount(canary.totalProcessed)}`,
2029
+ `${t('releases.detail.metric.errors')} ${formatCount(canary.totalErrors)}`,
2030
+ ].join(' · ');
2031
+ }
2032
+ function formatReleaseEventRelations(event, t) {
2033
+ const parts = [
2034
+ event.sourceEventId
2035
+ ? `${t('releases.detail.history.relation.sourceEvent')} ${formatShortId(event.sourceEventId)}`
2036
+ : null,
2037
+ event.supersedesEventId
2038
+ ? `${t('releases.detail.history.relation.supersedes')} ${formatShortId(event.supersedesEventId)}`
2039
+ : null,
2040
+ event.rollbackTargetEventId
2041
+ ? `${t('releases.detail.history.relation.rollbackTarget')} ${formatShortId(event.rollbackTargetEventId)}`
2042
+ : null,
2043
+ event.sourceExperimentId
2044
+ ? `${t('releases.detail.history.relation.sourceExperiment')} ${formatShortId(event.sourceExperimentId)}`
2045
+ : null,
911
2046
  ].filter((value) => Boolean(value));
912
- return parts.join(' · ') || event.id;
2047
+ return parts.length > 0 ? parts.join(' · ') : null;
2048
+ }
2049
+ function ensureHistoryGroup(map, id, seed) {
2050
+ const current = map.get(id);
2051
+ if (current)
2052
+ return current;
2053
+ const next = {
2054
+ id,
2055
+ production: null,
2056
+ candidates: [],
2057
+ isLive: false,
2058
+ sortAt: seed.updatedAt ?? seed.createdAt,
2059
+ productionNumber: seed.productionNumber ?? seed.targetProductionNumber,
2060
+ };
2061
+ map.set(id, next);
2062
+ return next;
2063
+ }
2064
+ function chooseNewestHistoryRow(current, next) {
2065
+ if (!current)
2066
+ return next;
2067
+ return timeValue(next.updatedAt ?? next.createdAt) >= timeValue(current.updatedAt ?? current.createdAt)
2068
+ ? next
2069
+ : current;
2070
+ }
2071
+ function chooseNewestDate(current, next) {
2072
+ if (!next)
2073
+ return current;
2074
+ if (!current)
2075
+ return next;
2076
+ return timeValue(next) >= timeValue(current) ? next : current;
2077
+ }
2078
+ function compareHistoryRows(left, right) {
2079
+ const candidateDelta = (right.candidateNumber ?? 0) - (left.candidateNumber ?? 0);
2080
+ if (candidateDelta !== 0)
2081
+ return candidateDelta;
2082
+ return timeValue(right.updatedAt ?? right.createdAt) - timeValue(left.updatedAt ?? left.createdAt);
2083
+ }
2084
+ export function compareHistoryGroups(left, right) {
2085
+ // Total order, "newest production first":
2086
+ // 1. Groups WITH a productionNumber rank above those without (candidate-only / legacy),
2087
+ // so a numbered production group never sinks below a null group on timestamp alone.
2088
+ // 2. Within numbered groups: productionNumber descending.
2089
+ // 3. Within null groups (single class): sortAt descending.
2090
+ // 4. Ties: sortAt descending.
2091
+ const leftHasNumber = left.productionNumber !== null;
2092
+ const rightHasNumber = right.productionNumber !== null;
2093
+ if (leftHasNumber !== rightHasNumber)
2094
+ return leftHasNumber ? -1 : 1;
2095
+ if (leftHasNumber && rightHasNumber) {
2096
+ const numberDelta = right.productionNumber - left.productionNumber;
2097
+ if (numberDelta !== 0)
2098
+ return numberDelta;
2099
+ }
2100
+ return timeValue(right.sortAt) - timeValue(left.sortAt);
2101
+ }
2102
+ function getLatestReleaseEvent(events) {
2103
+ return [...events].sort((left, right) => timeValue(right.updatedAt) - timeValue(left.updatedAt))[0] ?? null;
2104
+ }
2105
+ function isProductionHistoryRow(row) {
2106
+ return row.releaseVersionKind === 'production' || row.laneType === 'production';
2107
+ }
2108
+ export function isHistoryRowLive(row) {
2109
+ return row.isLive;
2110
+ }
2111
+ function getHistoryGroupKey(row) {
2112
+ const productionNumber = row.releaseVersionKind === 'candidate'
2113
+ ? row.targetProductionNumber
2114
+ : (row.productionNumber ?? row.targetProductionNumber);
2115
+ if (productionNumber !== null)
2116
+ return `production-${productionNumber}`;
2117
+ return row.releaseVersionId ?? row.id;
2118
+ }
2119
+ function normalizeRunConfig(value) {
2120
+ if (!value || typeof value !== 'object' || Array.isArray(value))
2121
+ return {};
2122
+ return value;
2123
+ }
2124
+ function getRunConfigNumber(config, key) {
2125
+ const value = config[key];
2126
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
2127
+ }
2128
+ function buildReleaseConfigChanges(events, t) {
2129
+ const sorted = [...events].sort((left, right) => timeValue(left.updatedAt) - timeValue(right.updatedAt));
2130
+ const changes = [];
2131
+ sorted.forEach((event, index) => {
2132
+ if (!RELEASE_CONFIG_OPERATIONS.has(event.operation))
2133
+ return;
2134
+ const previous = [...sorted.slice(0, index)].reverse().find((item) => item.releaseVersionId === event.releaseVersionId) ?? null;
2135
+ changes.push({
2136
+ id: event.id,
2137
+ at: event.updatedAt ?? event.createdAt,
2138
+ event: event.operation,
2139
+ items: buildConfigChangeItems(previous, event, t),
2140
+ });
2141
+ });
2142
+ return changes;
2143
+ }
2144
+ function buildLegacyProductionConfigItems(item, t) {
2145
+ const config = normalizeRunConfig(item.runConfig);
2146
+ const changes = [];
2147
+ addConfigChange(changes, t('releases.detail.topology.field.rpmLimit'), '—', formatHistoryNumber(getRunConfigNumber(config, 'rpmLimit')), true);
2148
+ addConfigChange(changes, t('releases.detail.topology.field.tpmLimit'), '—', formatHistoryNumber(getRunConfigNumber(config, 'tpmLimit')), true);
2149
+ addConfigChange(changes, t('releases.detail.topology.field.concurrency'), '—', formatHistoryNumber(getRunConfigNumber(config, 'concurrency')), true);
2150
+ addConfigChange(changes, t('releases.detail.topology.field.temperature'), '—', formatHistoryTemperature(getRunConfigNumber(config, 'temperature')), true);
2151
+ if (changes.length > 0)
2152
+ return changes;
2153
+ return [
2154
+ {
2155
+ field: t('releases.detail.history.field.snapshot'),
2156
+ previous: '—',
2157
+ next: t('releases.detail.history.change.updated'),
2158
+ },
2159
+ ];
2160
+ }
2161
+ function buildConfigChangeItems(previous, current, t) {
2162
+ const previousConfig = normalizeRunConfig(previous?.runConfig);
2163
+ const currentConfig = normalizeRunConfig(current.runConfig);
2164
+ const changes = [];
2165
+ const includeInitial = previous === null;
2166
+ addConfigChange(changes, t('releases.detail.history.traffic'), formatRatioValue(previous?.trafficRatio ?? null), formatRatioValue(current.trafficRatio), includeInitial);
2167
+ addConfigChange(changes, t('releases.detail.history.field.trafficMode'), previous?.trafficMode ?? '—', current.trafficMode ?? '—', includeInitial);
2168
+ addConfigChange(changes, t('releases.detail.topology.field.rpmLimit'), formatHistoryNumber(getRunConfigNumber(previousConfig, 'rpmLimit')), formatHistoryNumber(getRunConfigNumber(currentConfig, 'rpmLimit')), includeInitial);
2169
+ addConfigChange(changes, t('releases.detail.topology.field.tpmLimit'), formatHistoryNumber(getRunConfigNumber(previousConfig, 'tpmLimit')), formatHistoryNumber(getRunConfigNumber(currentConfig, 'tpmLimit')), includeInitial);
2170
+ addConfigChange(changes, t('releases.detail.topology.field.concurrency'), formatHistoryNumber(getRunConfigNumber(previousConfig, 'concurrency')), formatHistoryNumber(getRunConfigNumber(currentConfig, 'concurrency')), includeInitial);
2171
+ addConfigChange(changes, t('releases.detail.topology.field.temperature'), formatHistoryTemperature(getRunConfigNumber(previousConfig, 'temperature')), formatHistoryTemperature(getRunConfigNumber(currentConfig, 'temperature')), includeInitial);
2172
+ addConfigChange(changes, t('releases.detail.history.model'), formatHistoryModel(previous?.modelName, previous?.modelId, previous?.modelProvider), formatHistoryModel(current.modelName, current.modelId, current.modelProvider), includeInitial);
2173
+ addConfigChange(changes, t('releases.detail.field.upstream'), formatConnectorLabel(previous?.inputConnectorName ?? null, previous?.inputConnectorType ?? null), formatConnectorLabel(current.inputConnectorName, current.inputConnectorType), includeInitial);
2174
+ addConfigChange(changes, t('releases.detail.history.field.outputConnectors'), formatOutputConnectors(previous?.outputConnectors ?? []), formatOutputConnectors(current.outputConnectors), includeInitial);
2175
+ addConfigChange(changes, t('releases.detail.history.recordMode'), formatRecordMode(previous?.recordMode, t, previous?.recordCategories ?? []), formatRecordMode(current.recordMode, t, current.recordCategories), includeInitial);
2176
+ if (changes.length > 0)
2177
+ return changes;
2178
+ return [
2179
+ {
2180
+ field: t('releases.detail.history.field.snapshot'),
2181
+ previous: '—',
2182
+ next: t('releases.detail.history.change.updated'),
2183
+ },
2184
+ ];
2185
+ }
2186
+ function addConfigChange(changes, field, previous, next, includeInitial = false) {
2187
+ if (next === '—')
2188
+ return;
2189
+ if (!includeInitial && previous === next)
2190
+ return;
2191
+ changes.push({ field, previous, next });
2192
+ }
2193
+ function formatRatioValue(value) {
2194
+ return typeof value === 'number' && Number.isFinite(value) ? `${Math.round(value * 100)}%` : '—';
2195
+ }
2196
+ function formatHistoryNumber(value) {
2197
+ if (typeof value !== 'number' || !Number.isFinite(value))
2198
+ return '—';
2199
+ return Number.isInteger(value) ? formatCount(value) : formatHistoryTemperature(value);
2200
+ }
2201
+ function formatHistoryTemperature(value) {
2202
+ if (typeof value !== 'number' || !Number.isFinite(value))
2203
+ return '—';
2204
+ return value.toFixed(2).replace(/\.?0+$/, '');
2205
+ }
2206
+ function formatConnectorLabel(name, type) {
2207
+ if (!name && !type)
2208
+ return '—';
2209
+ return [type, name].filter((value) => Boolean(value)).join(' · ');
2210
+ }
2211
+ function formatOutputConnectors(connectors) {
2212
+ if (connectors.length === 0)
2213
+ return '—';
2214
+ return connectors.map((connector) => formatConnectorLabel(connector.name, connector.type)).join(' / ');
2215
+ }
2216
+ function formatHistoryVersionLabel(label) {
2217
+ return label.replace(/^v(?=\d)/, 'V');
2218
+ }
2219
+ function buildHistoryRuntimeItems(row, t) {
2220
+ const items = [];
2221
+ const rpm = getRunConfigNumber(row.runConfig, 'rpmLimit');
2222
+ const tpm = getRunConfigNumber(row.runConfig, 'tpmLimit');
2223
+ const concurrency = getRunConfigNumber(row.runConfig, 'concurrency');
2224
+ const temperature = getRunConfigNumber(row.runConfig, 'temperature');
2225
+ if (row.trafficRatio !== null || row.trafficMode) {
2226
+ items.push({ label: t('releases.detail.history.traffic'), value: formatReleaseRowTraffic(row), mono: true });
2227
+ }
2228
+ if (rpm !== null)
2229
+ items.push({ label: t('releases.detail.topology.field.rpmLimit'), value: formatHistoryNumber(rpm), mono: true });
2230
+ if (tpm !== null)
2231
+ items.push({ label: t('releases.detail.topology.field.tpmLimit'), value: formatHistoryNumber(tpm), mono: true });
2232
+ if (concurrency !== null) {
2233
+ items.push({
2234
+ label: t('releases.detail.topology.field.concurrency'),
2235
+ value: formatHistoryNumber(concurrency),
2236
+ mono: true,
2237
+ });
2238
+ }
2239
+ if (temperature !== null) {
2240
+ items.push({
2241
+ label: t('releases.detail.topology.field.temperature'),
2242
+ value: formatHistoryTemperature(temperature),
2243
+ mono: true,
2244
+ });
2245
+ }
2246
+ if (row.recordMode) {
2247
+ items.push({
2248
+ label: t('releases.detail.history.recordMode'),
2249
+ value: formatRecordMode(row.recordMode, t, row.recordCategories),
2250
+ mono: true,
2251
+ });
2252
+ }
2253
+ return items;
913
2254
  }
914
- function formatReleaseEventVariant(event) {
915
- if (!event.releaseVariantId)
2255
+ function buildHistoryConnectorItems(row, t) {
2256
+ return [
2257
+ {
2258
+ label: t('releases.detail.field.upstream'),
2259
+ value: formatConnectorLabel(row.inputConnectorName, row.inputConnectorType),
2260
+ },
2261
+ {
2262
+ label: t('releases.detail.field.downstream'),
2263
+ value: row.outputConnectors.length > 0
2264
+ ? formatOutputConnectors(row.outputConnectors)
2265
+ : t('releases.detail.history.field.noDownstream'),
2266
+ },
2267
+ ];
2268
+ }
2269
+ function buildHistoryReasonItems(row, t) {
2270
+ if (!row.reason)
2271
+ return [];
2272
+ return [{ label: t('releases.detail.history.reason'), value: row.reason }];
2273
+ }
2274
+ function formatReleaseRowTraffic(row) {
2275
+ return ([formatRatioValue(row.trafficRatio), row.trafficMode].filter((value) => value && value !== '—').join(' · ') || '—');
2276
+ }
2277
+ function buildReleaseResultsHref(line, row) {
2278
+ if (!row.releaseVersionId)
2279
+ return null;
2280
+ return `/releases/${encodeURIComponent(line.id)}?tab=results&version=${encodeURIComponent(row.releaseVersionId)}`;
2281
+ }
2282
+ function buildAnnotationHref(line, row) {
2283
+ if (!row.releaseVersionId)
916
2284
  return null;
917
- const label = event.releaseVariantLabel ?? formatShortId(event.releaseVariantId);
918
- const promptVersion = event.promptVersionLabel ?? formatShortId(event.promptVersionId);
919
- const model = event.modelName ?? formatShortId(event.modelId);
920
- return `${label} · ${promptVersion} · ${model}`;
2285
+ return `/annotations/new?line=${encodeURIComponent(line.id)}&version=${encodeURIComponent(row.releaseVersionId)}`;
921
2286
  }
922
- function HistoryPane({ line, productionHistory, releaseEvents, loading, }) {
2287
+ function buildQualityAnnotationHref(line, releaseEvents) {
2288
+ const latestEvent = [...releaseEvents]
2289
+ .filter((event) => Boolean(event.releaseVersionId))
2290
+ .sort((left, right) => timeValue(right.updatedAt ?? right.createdAt) - timeValue(left.updatedAt ?? left.createdAt))[0];
2291
+ const params = new URLSearchParams({ line: line.id });
2292
+ if (latestEvent?.releaseVersionId)
2293
+ params.set('version', latestEvent.releaseVersionId);
2294
+ return `/annotations/new?${params.toString()}`;
2295
+ }
2296
+ function HistoryPane({ projectId, line, productionHistory, releaseEvents, loading, }) {
923
2297
  const { t } = useI18n();
924
2298
  const formatDateTimeOrDash = useDateTimeOrDash();
925
- const items = useMemo(() => {
926
- if (releaseEvents.length > 0) {
927
- return releaseEvents.map((event) => ({
928
- id: event.id,
929
- event: event.operation,
930
- title: `${event.laneType === 'production' ? t('releases.detail.history.productionLane') : t('releases.detail.history.canaryLane')} · ${event.promptVersionLabel ?? event.id.slice(0, 8)}`,
931
- createdAt: event.createdAt,
932
- meta: buildReleaseEventMeta(event),
933
- variant: formatReleaseEventVariant(event),
934
- }));
935
- }
936
- const prod = productionHistory.map((item) => ({
937
- id: item.id,
938
- event: item.eventType,
939
- title: item.promptVersionLabel ?? item.id.slice(0, 8),
940
- createdAt: item.createdAt,
941
- meta: item.submitReason || item.status,
942
- variant: null,
943
- }));
944
- const canary = line.canary
945
- ? [
946
- {
947
- id: line.canary.id,
948
- event: line.canary.status === 'running' ? 'ratio_change' : 'create_canary',
949
- title: `${line.canary.promptVersionLabel ?? line.canary.id.slice(0, 8)} · ${Math.round(line.canary.trafficRatio * 100)}%`,
950
- createdAt: line.canary.updatedAt,
951
- meta: line.canary.description ?? line.canary.status,
952
- variant: line.canary.releaseVariantLabel
953
- ? `${line.canary.releaseVariantLabel} · ${line.canary.promptVersionLabel ?? '-'} · ${line.canary.modelName ?? '-'}`
954
- : null,
955
- },
956
- ]
957
- : [];
958
- return [...canary, ...prod].sort((left, right) => (right.createdAt ? Date.parse(right.createdAt) : 0) - (left.createdAt ? Date.parse(left.createdAt) : 0));
959
- }, [line.canary, productionHistory, releaseEvents, t]);
2299
+ const restoreToProductionMutation = useRestoreReleaseLineHistoryToProduction(projectId);
2300
+ const restoreToCanaryMutation = useRestoreReleaseLineHistoryToCanary(projectId);
2301
+ const restorePending = restoreToProductionMutation.isPending || restoreToCanaryMutation.isPending;
2302
+ const [restoreFeedback, setRestoreFeedback] = useState(null);
2303
+ const groups = useMemo(() => buildHistoryGroups(line, releaseEvents, productionHistory, t), [line, productionHistory, releaseEvents, t]);
2304
+ const [groupOpen, setGroupOpen] = useState({});
2305
+ const [moreOpen, setMoreOpen] = useState({});
2306
+ const [configOpen, setConfigOpen] = useState({});
2307
+ const historyGroupResetKey = `${line.id}:${groups.length}`;
2308
+ const [visibleGroupState, setVisibleGroupState] = useState(() => ({
2309
+ key: historyGroupResetKey,
2310
+ count: HISTORY_INITIAL_GROUP_LIMIT,
2311
+ }));
2312
+ const [loadingMoreKey, setLoadingMoreKey] = useState(null);
2313
+ const loadingMoreKeyRef = useRef(null);
2314
+ const loadMoreTimerRef = useRef(null);
960
2315
  const showLoader = useDelayedLoading(loading);
2316
+ const visibleGroupCount = visibleGroupState.key === historyGroupResetKey ? visibleGroupState.count : HISTORY_INITIAL_GROUP_LIMIT;
2317
+ const visibleGroups = useMemo(() => groups.slice(0, visibleGroupCount), [groups, visibleGroupCount]);
2318
+ const hasMoreGroups = visibleGroupCount < groups.length;
2319
+ const isLoadingMoreGroups = loadingMoreKey === historyGroupResetKey && hasMoreGroups;
2320
+ const restoreHistoryToProduction = useCallback((row, versionLabel) => {
2321
+ if (!row.sourceEventId || line.status === 'archived')
2322
+ return;
2323
+ setRestoreFeedback(null);
2324
+ restoreToProductionMutation.mutate({
2325
+ releaseLineId: line.id,
2326
+ body: {
2327
+ sourceEventId: row.sourceEventId,
2328
+ reason: formatTemplate(t('releases.detail.history.action.restoreToProductionReason'), {
2329
+ version: versionLabel,
2330
+ }),
2331
+ },
2332
+ }, {
2333
+ onSuccess: () => setRestoreFeedback({
2334
+ tone: 'success',
2335
+ message: t('releases.detail.history.action.restoreSuccess'),
2336
+ }),
2337
+ onError: (error) => setRestoreFeedback({
2338
+ tone: 'error',
2339
+ message: getApiErrorMessage(error) ?? t('releases.detail.history.action.restoreFailed'),
2340
+ }),
2341
+ });
2342
+ }, [line.id, line.status, restoreToProductionMutation, t]);
2343
+ const restoreHistoryToCanary = useCallback((row, versionLabel) => {
2344
+ if (!row.sourceEventId || line.status === 'archived')
2345
+ return;
2346
+ setRestoreFeedback(null);
2347
+ restoreToCanaryMutation.mutate({
2348
+ releaseLineId: line.id,
2349
+ body: {
2350
+ sourceEventId: row.sourceEventId,
2351
+ reason: formatTemplate(t('releases.detail.history.action.restoreToCanaryReason'), {
2352
+ version: versionLabel,
2353
+ }),
2354
+ },
2355
+ }, {
2356
+ onSuccess: () => setRestoreFeedback({
2357
+ tone: 'success',
2358
+ message: t('releases.detail.history.action.restoreSuccess'),
2359
+ }),
2360
+ onError: (error) => setRestoreFeedback({
2361
+ tone: 'error',
2362
+ message: getApiErrorMessage(error) ?? t('releases.detail.history.action.restoreFailed'),
2363
+ }),
2364
+ });
2365
+ }, [line.id, line.status, restoreToCanaryMutation, t]);
2366
+ const loadMoreHistoryGroups = useCallback(() => {
2367
+ if (!hasMoreGroups)
2368
+ return;
2369
+ if (loadingMoreKeyRef.current === historyGroupResetKey)
2370
+ return;
2371
+ loadingMoreKeyRef.current = historyGroupResetKey;
2372
+ setLoadingMoreKey(historyGroupResetKey);
2373
+ setVisibleGroupState((current) => {
2374
+ const currentCount = current.key === historyGroupResetKey ? current.count : HISTORY_INITIAL_GROUP_LIMIT;
2375
+ return {
2376
+ key: historyGroupResetKey,
2377
+ count: Math.min(groups.length, currentCount + HISTORY_GROUP_PAGE_SIZE),
2378
+ };
2379
+ });
2380
+ if (loadMoreTimerRef.current)
2381
+ window.clearTimeout(loadMoreTimerRef.current);
2382
+ loadMoreTimerRef.current = window.setTimeout(() => {
2383
+ loadingMoreKeyRef.current = null;
2384
+ setLoadingMoreKey((current) => (current === historyGroupResetKey ? null : current));
2385
+ loadMoreTimerRef.current = null;
2386
+ }, 360);
2387
+ }, [groups.length, hasMoreGroups, historyGroupResetKey]);
2388
+ useEffect(() => {
2389
+ return () => {
2390
+ if (loadMoreTimerRef.current) {
2391
+ window.clearTimeout(loadMoreTimerRef.current);
2392
+ loadMoreTimerRef.current = null;
2393
+ }
2394
+ loadingMoreKeyRef.current = null;
2395
+ };
2396
+ }, []);
961
2397
  if (loading) {
962
2398
  return showLoader ? _jsx(PlatformLoader, { className: "py-8", size: "sm" }) : null;
963
2399
  }
964
- if (items.length === 0) {
2400
+ if (groups.length === 0) {
965
2401
  return (_jsx("div", { className: "rounded-lg border bg-card p-10 text-center text-sm text-muted-foreground", children: t('releases.detail.history.empty') }));
966
2402
  }
967
- return (_jsxs("div", { className: "relative space-y-3 pl-8", children: [_jsx("div", { className: "absolute bottom-0 left-[11px] top-0 w-0.5 bg-border" }), items.map((item, index) => (_jsxs("div", { className: "relative rounded-lg border bg-card", children: [_jsx("div", { className: "absolute left-[-27px] top-[18px] size-3.5 rounded-full border-2", style: {
968
- background: index === 0 ? 'var(--status-canary-dot)' : 'var(--card)',
969
- borderColor: index === 0 ? 'var(--status-canary-dot)' : 'var(--border)',
970
- boxShadow: index === 0 ? '0 0 0 4px color-mix(in srgb, var(--status-canary-dot) 25%, transparent)' : undefined,
971
- } }), _jsxs("div", { className: "flex flex-wrap items-center gap-2 border-b px-4 py-3", children: [_jsx(ReleaseEventPill, { event: item.event }), _jsx("span", { className: "text-[14px] font-semibold", children: item.title }), _jsxs("span", { className: "ml-auto font-mono text-[11.5px] text-muted-foreground", children: [formatDateTimeOrDash(item.createdAt), " \u00B7 ", item.id.slice(0, 8)] })] }), _jsxs("div", { className: "space-y-2 px-4 py-3 text-[12.5px] text-muted-foreground", children: [item.variant ? (_jsxs("div", { children: [_jsx("span", { className: "font-medium text-foreground", children: t('releases.detail.history.variant') }), _jsx("span", { className: "ml-2 font-mono", children: item.variant })] })) : null, _jsx("div", { children: item.meta })] })] }, item.id)))] }));
2403
+ return (_jsxs("section", { className: "w-full", "aria-label": t('releases.detail.history.title'), children: [restoreFeedback ? (_jsxs("div", { role: restoreFeedback.tone === 'error' ? 'alert' : 'status', className: cn('mb-3 flex items-center gap-2 rounded-md border px-3 py-2 text-[12.5px]', restoreFeedback.tone === 'error'
2404
+ ? 'border-destructive/35 bg-destructive/5 text-destructive'
2405
+ : 'border-border bg-muted/55 text-muted-foreground'), children: [restoreFeedback.tone === 'error' ? _jsx(AlertTriangle, { className: "size-3.5" }) : _jsx(Check, { className: "size-3.5" }), _jsx("span", { children: restoreFeedback.message })] })) : null, _jsx("div", { className: "relative pl-[30px]", children: visibleGroups.map((group, index) => {
2406
+ const headline = group.production ?? group.candidates[0] ?? null;
2407
+ if (!headline)
2408
+ return null;
2409
+ const children = group.production ? group.candidates : group.candidates.slice(1);
2410
+ const defaultOpen = group.isLive || index === 0;
2411
+ const isOpen = groupOpen[group.id] ?? defaultOpen;
2412
+ return (_jsxs("div", { className: "relative mb-4", children: [_jsx("div", { className: "absolute bottom-3.5 left-[-22px] top-[18px] w-[1.5px] bg-border", "aria-hidden": true }), _jsx(HistoryVersionCard, { line: line, row: headline, variant: isProductionHistoryRow(headline) ? 'production' : 'canary', live: group.isLive && isProductionHistoryRow(headline), hasChildren: children.length > 0, childrenOpen: isOpen, moreOpen: Boolean(moreOpen[headline.id]), configOpen: Boolean(configOpen[headline.id]), onToggleChildren: () => setGroupOpen((current) => ({
2413
+ ...current,
2414
+ [group.id]: !(current[group.id] ?? defaultOpen),
2415
+ })), onToggleMore: () => setMoreOpen((current) => ({
2416
+ ...current,
2417
+ [headline.id]: !current[headline.id],
2418
+ })), onToggleConfig: () => setConfigOpen((current) => ({
2419
+ ...current,
2420
+ [headline.id]: !current[headline.id],
2421
+ })), onRestoreToProduction: restoreHistoryToProduction, onRestoreToCanary: restoreHistoryToCanary, restorePending: restorePending, formatDateTimeOrDash: formatDateTimeOrDash }), isOpen && children.length > 0 ? (_jsx("div", { className: "mb-0.5 mt-2.5 flex flex-col gap-2.5", children: children.map((row) => (_jsx(HistoryVersionCard, { line: line, row: row, variant: "canary", compact: true, live: isHistoryRowLive(row), hasChildren: false, childrenOpen: false, moreOpen: Boolean(moreOpen[row.id]), configOpen: Boolean(configOpen[row.id]), onToggleChildren: () => undefined, onToggleMore: () => setMoreOpen((current) => ({
2422
+ ...current,
2423
+ [row.id]: !current[row.id],
2424
+ })), onToggleConfig: () => setConfigOpen((current) => ({
2425
+ ...current,
2426
+ [row.id]: !current[row.id],
2427
+ })), onRestoreToProduction: restoreHistoryToProduction, onRestoreToCanary: restoreHistoryToCanary, restorePending: restorePending, formatDateTimeOrDash: formatDateTimeOrDash }, row.id))) })) : null] }, group.id));
2428
+ }) }), hasMoreGroups ? (_jsx(HistoryLoadMoreIndicator, { loading: isLoadingMoreGroups, label: isLoadingMoreGroups ? t('releases.detail.history.loadingMore') : t('releases.detail.history.moreAvailable'), onClick: loadMoreHistoryGroups })) : null] }));
2429
+ }
2430
+ function HistoryLoadMoreIndicator({ loading, label, onClick, }) {
2431
+ if (!loading) {
2432
+ return (_jsx("div", { className: "flex justify-center py-3", children: _jsxs("button", { type: "button", onClick: onClick, className: "inline-flex h-8 items-center gap-1.5 rounded-full border bg-card px-3.5 text-[12px] font-medium text-muted-foreground shadow-sm transition-colors hover:bg-muted/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", children: [_jsx(ChevronDown, { className: "size-3.5", "aria-hidden": true }), _jsx("span", { children: label })] }) }));
2433
+ }
2434
+ return (_jsxs("div", { className: "flex justify-center py-4", role: "status", "aria-live": "polite", "aria-label": label, children: [_jsx("span", { className: "sr-only", children: label }), _jsxs("span", { className: "relative flex h-8 w-28 items-center justify-center", "aria-hidden": true, children: [_jsx("span", { className: "absolute inset-x-0 top-1/2 h-px -translate-y-1/2 bg-gradient-to-r from-transparent via-border to-transparent" }), _jsx("span", { className: "relative inline-flex items-center gap-1.5 rounded-full border bg-card px-3 py-2 shadow-sm", children: [0, 1, 2].map((index) => (_jsx("span", { className: "size-1.5 rounded-full bg-muted-foreground/80 motion-safe:animate-bounce", style: { animationDelay: `${index * 120}ms` } }, index))) })] })] }));
2435
+ }
2436
+ function HistoryVersionCard({ line, row, variant, live, compact = false, hasChildren, childrenOpen, moreOpen, configOpen, onToggleChildren, onToggleMore, onToggleConfig, onRestoreToProduction, onRestoreToCanary, restorePending, formatDateTimeOrDash, }) {
2437
+ const { t } = useI18n();
2438
+ const isProduction = variant === 'production';
2439
+ const runtimeItems = buildHistoryRuntimeItems(row, t);
2440
+ const connectorItems = buildHistoryConnectorItems(row, t);
2441
+ const reasonItems = buildHistoryReasonItems(row, t);
2442
+ const hasConfig = row.configChanges.length > 0;
2443
+ const dateLabel = formatDateTimeOrDash(row.updatedAt ?? row.createdAt);
2444
+ const resultsHref = buildReleaseResultsHref(line, row);
2445
+ const annotationHref = buildAnnotationHref(line, row);
2446
+ const versionLabel = formatHistoryVersionLabel(row.releaseVersionLabel || '—');
2447
+ const expandLabel = formatTemplate(t(childrenOpen ? 'releases.detail.history.action.collapseVersion' : 'releases.detail.history.action.expandVersion'), { version: versionLabel });
2448
+ const moreLabel = formatTemplate(t('releases.detail.history.action.moreInfo'), { version: versionLabel });
2449
+ const configLabel = formatTemplate(t('releases.detail.history.action.configChanges'), { version: versionLabel });
2450
+ const resultLabel = formatTemplate(t('releases.detail.history.action.viewResults'), { version: versionLabel });
2451
+ const annotationLabel = formatTemplate(t('releases.detail.history.action.createAnnotation'), {
2452
+ version: versionLabel,
2453
+ });
2454
+ const actionMenuLabel = formatTemplate(t('releases.detail.history.action.moreActions'), { version: versionLabel });
2455
+ const restoreDisabled = !row.sourceEventId || line.status === 'archived' || restorePending;
2456
+ const restoreHistoryRow = () => {
2457
+ if (isProduction)
2458
+ onRestoreToProduction(row, versionLabel);
2459
+ else
2460
+ onRestoreToCanary(row, versionLabel);
2461
+ };
2462
+ const rowClickProps = hasChildren
2463
+ ? {
2464
+ role: 'button',
2465
+ tabIndex: 0,
2466
+ 'aria-expanded': childrenOpen,
2467
+ 'aria-label': expandLabel,
2468
+ onClick: onToggleChildren,
2469
+ onKeyDown: (event) => {
2470
+ if (event.key !== 'Enter' && event.key !== ' ')
2471
+ return;
2472
+ event.preventDefault();
2473
+ onToggleChildren();
2474
+ },
2475
+ }
2476
+ : {};
2477
+ return (_jsxs("div", { ...rowClickProps, className: cn('relative flex flex-wrap items-center gap-x-3 gap-y-2 rounded-[10px] border bg-card px-[15px] shadow-sm transition-shadow hover:shadow-md', hasChildren && 'cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', compact ? 'py-[11px]' : 'py-[13px]'), style: live && isProduction
2478
+ ? {
2479
+ borderColor: 'color-mix(in srgb, var(--src-prod) 35%, var(--border))',
2480
+ boxShadow: '0 1px 3px color-mix(in srgb, var(--src-prod) 12%, transparent)',
2481
+ }
2482
+ : undefined, children: [_jsx("span", { className: cn('absolute z-10 rounded-full bg-card', isProduction
2483
+ ? 'left-[-29px] top-[14px] size-4 border-[2.5px]'
2484
+ : 'left-[-26px] top-[15px] size-2.5 border-[2.5px]'), style: {
2485
+ background: live ? (isProduction ? 'var(--src-prod)' : 'var(--src-canary)') : 'var(--card)',
2486
+ borderColor: isProduction ? (live ? 'var(--src-prod)' : 'var(--muted-foreground)') : 'var(--src-canary)',
2487
+ boxShadow: live
2488
+ ? `0 0 0 4px color-mix(in srgb, ${isProduction ? 'var(--src-prod)' : 'var(--src-canary)'} 16%, transparent)`
2489
+ : undefined,
2490
+ }, "aria-hidden": true }), _jsx("span", { className: "absolute left-[-14px] top-[19px] h-[1.5px] w-[11px] bg-border", style: !isProduction ? { background: 'color-mix(in srgb, var(--src-canary) 35%, var(--border))' } : undefined, "aria-hidden": true }), _jsx(HistoryVersionBadge, { label: versionLabel, variant: variant, compact: compact }), _jsxs("div", { className: "flex min-w-0 flex-1 items-center", children: [_jsxs("div", { className: "flex w-[200px] min-w-0 shrink-0 items-baseline gap-[7px] whitespace-nowrap", children: [_jsx("span", { className: "shrink-0 text-[11.5px] text-muted-foreground", children: t('releases.detail.history.model') }), _jsx("span", { className: "min-w-0 truncate text-[13.5px] font-medium text-foreground", children: row.modelName })] }), _jsxs("div", { className: "flex w-[212px] min-w-0 shrink-0 items-baseline gap-[7px] whitespace-nowrap", children: [_jsx("span", { className: "shrink-0 text-[11.5px] text-muted-foreground", children: t('releases.detail.history.field.prompt') }), _jsx("span", { className: "min-w-0 truncate text-[13.5px] font-medium text-foreground", children: row.promptName }), _jsx("span", { className: "shrink-0 text-muted-foreground", children: "\u00B7" }), _jsx("span", { className: "shrink-0 font-mono text-[12.5px] font-semibold text-muted-foreground", children: row.promptVersionLabel })] }), _jsx("div", { className: "shrink-0 whitespace-nowrap font-mono text-[11.5px] text-muted-foreground", children: dateLabel })] }), _jsxs("div", { className: "ml-auto flex shrink-0 items-center gap-2", onClick: (event) => event.stopPropagation(), children: [_jsx("button", { type: "button", onClick: onToggleMore, "aria-pressed": moreOpen, "aria-label": moreLabel, title: moreLabel, className: cn('inline-flex size-[30px] items-center justify-center rounded-lg border bg-card p-0 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', moreOpen && 'border-ring bg-muted text-foreground', compact && 'size-7 rounded-md'), children: _jsx(ChevronDown, { className: cn(compact ? 'size-3.5' : 'size-4', 'transition-transform', moreOpen && 'rotate-180') }) }), _jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx("button", { type: "button", "aria-label": actionMenuLabel, title: actionMenuLabel, className: cn('inline-flex size-[30px] items-center justify-center rounded-lg border bg-card p-0 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', compact && 'size-7 rounded-md'), children: _jsx(MoreHorizontal, { className: compact ? 'size-3.5' : 'size-4' }) }) }), _jsxs(DropdownMenuContent, { align: "end", className: "w-56", onClick: (event) => event.stopPropagation(), children: [_jsxs(DropdownMenuItem, { disabled: !hasConfig, onSelect: onToggleConfig, className: "gap-2", children: [_jsx(SlidersHorizontal, { className: "size-3.5" }), configLabel] }), _jsx(HistoryDropdownLink, { href: resultsHref, label: resultLabel, children: _jsx(ScrollText, { className: "size-3.5" }) }), _jsx(HistoryDropdownLink, { href: annotationHref, label: annotationLabel, children: _jsx(Tag, { className: "size-3.5" }) }), _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { disabled: restoreDisabled, onSelect: (event) => {
2491
+ event.preventDefault();
2492
+ event.stopPropagation();
2493
+ restoreHistoryRow();
2494
+ }, className: "gap-2", children: [_jsx(RotateCcw, { className: "size-3.5" }), restorePending
2495
+ ? t('releases.detail.history.action.restoring')
2496
+ : t('releases.detail.history.action.restore')] })] })] })] }), moreOpen ? (_jsx(HistoryMorePanel, { runtimeItems: runtimeItems, connectorItems: connectorItems, reasonItems: reasonItems })) : null, configOpen && hasConfig ? (_jsx(HistoryConfigPanel, { changes: row.configChanges, formatDateTimeOrDash: formatDateTimeOrDash })) : null] }));
2497
+ }
2498
+ function HistoryDropdownLink({ href, label, children }) {
2499
+ if (!href) {
2500
+ return (_jsxs(DropdownMenuItem, { disabled: true, className: "gap-2", children: [children, label] }));
2501
+ }
2502
+ return (_jsx(DropdownMenuItem, { asChild: true, className: "gap-2", children: _jsxs(Link, { href: href, children: [children, label] }) }));
2503
+ }
2504
+ function HistoryMorePanel({ runtimeItems, connectorItems, reasonItems, }) {
2505
+ const { t } = useI18n();
2506
+ return (_jsxs("div", { className: "mt-3 basis-full rounded-lg border bg-muted/55 px-[13px] py-[11px]", children: [_jsx(HistoryPanelRow, { title: t('releases.detail.history.runtimeSection'), items: runtimeItems }), _jsx(HistoryPanelRow, { title: t('releases.detail.history.connectorSection'), items: connectorItems }), reasonItems.length > 0 ? (_jsx(HistoryPanelRow, { title: t('releases.detail.history.reasonSection'), items: reasonItems })) : null] }));
2507
+ }
2508
+ function HistoryPanelRow({ title, items }) {
2509
+ if (items.length === 0)
2510
+ return null;
2511
+ return (_jsxs("div", { className: "flex flex-col gap-2 py-[7px] first:pt-0 last:pb-0 sm:flex-row sm:items-baseline [&+&]:border-t", children: [_jsx("div", { className: "w-[58px] shrink-0 text-[11.5px] font-semibold text-muted-foreground", children: title }), _jsx("dl", { className: "flex min-w-0 flex-1 flex-wrap gap-x-[30px] gap-y-[9px]", children: items.map((item) => (_jsxs("div", { className: "min-w-0", children: [_jsx("dt", { className: "text-[11px] text-muted-foreground", children: item.label }), _jsx("dd", { className: cn('mt-0.5 max-w-[360px] break-words text-[13px] font-semibold text-foreground', item.mono && 'font-mono'), children: item.value })] }, item.label))) })] }));
2512
+ }
2513
+ function HistoryConfigPanel({ changes, formatDateTimeOrDash, }) {
2514
+ const { t } = useI18n();
2515
+ return (_jsxs("div", { className: "mt-3 basis-full rounded-lg border bg-muted/55 px-[13px] py-[11px]", children: [_jsx("div", { className: "mb-2 text-[11.5px] font-semibold text-muted-foreground", children: t('releases.detail.history.configChanges') }), _jsx("div", { className: "ml-[3px] flex flex-col gap-2.5 border-l-[1.5px] border-dotted border-muted-foreground/60 pl-[13px]", children: changes.map((change) => (_jsxs("div", { className: "relative flex flex-col gap-1.5 sm:flex-row sm:items-baseline sm:gap-4", children: [_jsx("span", { className: "absolute left-[-16.5px] top-1.5 size-1.5 rounded-full bg-muted-foreground", "aria-hidden": true }), _jsx("span", { className: "min-w-[118px] shrink-0 font-mono text-[11.5px] text-muted-foreground", children: formatDateTimeOrDash(change.at) }), _jsx("div", { className: "flex min-w-0 flex-wrap gap-x-0 gap-y-1", children: change.items.map((item) => (_jsxs("span", { className: "inline-flex items-baseline border-l px-3 text-[12.5px] first:border-l-0 first:pl-0", children: [_jsx("span", { className: "mr-2 font-semibold text-foreground", children: item.field }), _jsx("span", { className: "font-mono text-muted-foreground", children: item.previous }), _jsx("span", { className: "mx-1.5 text-muted-foreground", children: "\u2192" }), _jsx("span", { className: "font-mono font-semibold text-foreground", children: item.next })] }, `${change.id}-${item.field}-${item.next}`))) })] }, change.id))) })] }));
2516
+ }
2517
+ function HistoryVersionBadge({ label, variant, compact, }) {
2518
+ const isProduction = variant === 'production';
2519
+ return (_jsx("span", { className: cn('inline-flex h-[26px] w-[54px] shrink-0 items-center justify-center truncate rounded-md border px-2 font-mono text-[13px] font-semibold leading-none', compact && 'h-[25px] text-[12px]'), style: {
2520
+ background: isProduction ? 'var(--src-prod-soft)' : 'var(--src-canary-soft)',
2521
+ color: isProduction ? 'var(--src-prod-fg)' : 'var(--src-canary-fg)',
2522
+ borderColor: isProduction
2523
+ ? 'color-mix(in srgb, var(--src-prod) 35%, var(--border))'
2524
+ : 'color-mix(in srgb, var(--src-canary) 35%, var(--border))',
2525
+ }, children: label }));
972
2526
  }
973
2527
  //# sourceMappingURL=release-line-detail-page.js.map