@proofhound/web-ui 0.1.8 → 0.1.10

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