mirror of
https://github.com/alphaXiv/OpenResearch.git
synced 2026-11-12 10:05:24 +08:00
Add lineage navigation to experiment table and overview (#570)
* Add a History view of experiment lineage and run attempts Co-authored-by: lc140159_gsk <211554985+lc140159_gsk@users.noreply.github.com> * Address review: archived filter, overlay focus, fold counts and narrow layout 1. History ignored "Show archived experiments". App now passes the setting, and History hides archived experiments unless it is on while still building lineage from every experiment, so a shown child of an archived parent keeps its "from ..." lead-in. When every experiment in scope is archived, History shows the same hint as the table. 2. In a narrow view the detail pane covered the list but focus stayed on a covered row. The covered list is now inert until the pane closes, and the pane takes focus when it covers the list. Closing it still returns focus to the row. 3. A folded branch counted later same-chapter experiments reached through another chapter, which also render as their own roots. The fold now follows the same in-chapter children the layout draws. 4. Search could fold a side branch holding a match. Nothing folds while a search is active. 5. The From column stayed at compact widths. It now follows the compact rule like Attempts and Latest. 6. Chapter headers kept status counts and dates on one line at compact widths, squeezing the title. The summary is dropped at compact widths. The width rules move into historyFit() in the model so they can be tested without a browser. Co-authored-by: lc140159_gsk <211554985+lc140159_gsk@users.noreply.github.com> * Add Polish translations for the new strings Upstream added Polish as a ninth catalogue (#626), so the strings this change introduces need a Polish entry for the catalogue lint to pass. Co-authored-by: lc140159_gsk <211554985+lc140159_gsk@users.noreply.github.com> * Integrate lineage into experiment table and overview * Trim trailing whitespace in lineage component * Hide experiment lineage by default * Preserve tab gestures in experiment lineage links --------- Co-authored-by: lc140159_gsk <211554985+lc140159_gsk@users.noreply.github.com> Co-authored-by: Daniel Kim <sox8502@gmail.com>
This commit is contained in:
co-authored by
lc140159_gsk
Daniel Kim
parent
6285fee7f4
commit
cbbde3890f
@@ -144,6 +144,7 @@ pub enum Scope {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ExperimentsView {
|
||||
Tree,
|
||||
#[serde(alias = "history")]
|
||||
Table,
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
var a1=Object.defineProperty;var cw=h=>{throw TypeError(h)};var o1=(h,e,t)=>e in h?a1(h,e,{enumerable:!0,configurable:!0,writable:!0,value:t}):h[e]=t;var L=(h,e,t)=>o1(h,typeof e!="symbol"?e+"":e,t),By=(h,e,t)=>e.has(h)||cw("Cannot "+t);var a=(h,e,t)=>(By(h,e,"read from private field"),t?t.call(h):e.get(h)),b=(h,e,t)=>e.has(h)?cw("Cannot add the same private member more than once"):e instanceof WeakSet?e.add(h):e.set(h,t),g=(h,e,t,n)=>(By(h,e,"write to private field"),n?n.call(h,t):e.set(h,t),t),y=(h,e,t)=>(By(h,e,"access private method"),t);var Zt=(h,e,t,n)=>({set _(i){g(h,e,i,t)},get _(){return a(h,e,n)}});import{g as vy,c as LA,r as gi,M as l1,j as fe,f as ku,I as Bu,a as c1,i as hw,P as h1,b as dw,S as d1,d as u1,C as f1,e as p1,h as g1}from"./index-BkLbyxCd.js";const m1=()=>"Find in document",v1=()=>"在文档中查找",y1=()=>"جستوجو در سند",b1=()=>"البحث في المستند",E1=()=>"Buscar en el documento",w1=()=>"दस्तावेज़ में खोजें",A1=()=>"문서에서 찾기",x1=()=>"文書内を検索",S1=()=>"Znajdź w dokumencie",uw=((h={},e={})=>{const t=e.locale??vy();return t==="zh-CN"?v1():t==="fa"?y1():t==="ar"?b1():t==="es"?E1():t==="hi"?w1():t==="ko"?A1():t==="ja"?x1():t==="pl"?S1():m1()}),T1=()=>"Fit to width",C1=()=>"适应宽度",P1=()=>"متناسب با عرض",I1=()=>"ملاءمة العرض",R1=()=>"Ajustar al ancho",O1=()=>"चौड़ाई में फ़िट करें",M1=()=>"너비에 맞추기",D1=()=>"幅に合わせる",L1=()=>"Dopasuj do szerokości",fw=((h={},e={})=>{const t=e.locale??vy();return t==="zh-CN"?C1():t==="fa"?P1():t==="ar"?I1():t==="es"?R1():t==="hi"?O1():t==="ko"?M1():t==="ja"?D1():t==="pl"?L1():T1()}),N1=()=>"Next match",F1=()=>"下一个匹配项",k1=()=>"مورد بعدی",B1=()=>"التطابق التالي",j1=()=>"Coincidencia siguiente",U1=()=>"अगला मिलान",$1=()=>"다음 일치 항목",H1=()=>"次の一致",V1=()=>"Następne dopasowanie",pw=((h={},e={})=>{const t=e.locale??vy();return t==="zh-CN"?F1():t==="fa"?k1():t==="ar"?B1():t==="es"?j1():t==="hi"?U1():t==="ko"?$1():t==="ja"?H1():t==="pl"?V1():N1()}),z1=()=>"Previous match",W1=()=>"上一个匹配项",G1=()=>"مورد قبلی",_1=()=>"التطابق السابق",X1=()=>"Coincidencia anterior",Y1=()=>"पिछला मिलान",K1=()=>"이전 일치 항목",J1=()=>"前の一致",Z1=()=>"Poprzednie dopasowanie",gw=((h={},e={})=>{const t=e.locale??vy();return t==="zh-CN"?W1():t==="fa"?G1():t==="ar"?_1():t==="es"?X1():t==="hi"?Y1():t==="ko"?K1():t==="ja"?J1():t==="pl"?Z1():z1()});/**
|
||||
var a1=Object.defineProperty;var cw=h=>{throw TypeError(h)};var o1=(h,e,t)=>e in h?a1(h,e,{enumerable:!0,configurable:!0,writable:!0,value:t}):h[e]=t;var L=(h,e,t)=>o1(h,typeof e!="symbol"?e+"":e,t),By=(h,e,t)=>e.has(h)||cw("Cannot "+t);var a=(h,e,t)=>(By(h,e,"read from private field"),t?t.call(h):e.get(h)),b=(h,e,t)=>e.has(h)?cw("Cannot add the same private member more than once"):e instanceof WeakSet?e.add(h):e.set(h,t),g=(h,e,t,n)=>(By(h,e,"write to private field"),n?n.call(h,t):e.set(h,t),t),y=(h,e,t)=>(By(h,e,"access private method"),t);var Zt=(h,e,t,n)=>({set _(i){g(h,e,i,t)},get _(){return a(h,e,n)}});import{g as vy,c as LA,r as gi,M as l1,j as fe,f as ku,I as Bu,a as c1,i as hw,P as h1,b as dw,S as d1,d as u1,C as f1,e as p1,h as g1}from"./index-Ci3ZemNB.js";const m1=()=>"Find in document",v1=()=>"在文档中查找",y1=()=>"جستوجو در سند",b1=()=>"البحث في المستند",E1=()=>"Buscar en el documento",w1=()=>"दस्तावेज़ में खोजें",A1=()=>"문서에서 찾기",x1=()=>"文書内を検索",S1=()=>"Znajdź w dokumencie",uw=((h={},e={})=>{const t=e.locale??vy();return t==="zh-CN"?v1():t==="fa"?y1():t==="ar"?b1():t==="es"?E1():t==="hi"?w1():t==="ko"?A1():t==="ja"?x1():t==="pl"?S1():m1()}),T1=()=>"Fit to width",C1=()=>"适应宽度",P1=()=>"متناسب با عرض",I1=()=>"ملاءمة العرض",R1=()=>"Ajustar al ancho",O1=()=>"चौड़ाई में फ़िट करें",M1=()=>"너비에 맞추기",D1=()=>"幅に合わせる",L1=()=>"Dopasuj do szerokości",fw=((h={},e={})=>{const t=e.locale??vy();return t==="zh-CN"?C1():t==="fa"?P1():t==="ar"?I1():t==="es"?R1():t==="hi"?O1():t==="ko"?M1():t==="ja"?D1():t==="pl"?L1():T1()}),N1=()=>"Next match",F1=()=>"下一个匹配项",k1=()=>"مورد بعدی",B1=()=>"التطابق التالي",j1=()=>"Coincidencia siguiente",U1=()=>"अगला मिलान",$1=()=>"다음 일치 항목",H1=()=>"次の一致",V1=()=>"Następne dopasowanie",pw=((h={},e={})=>{const t=e.locale??vy();return t==="zh-CN"?F1():t==="fa"?k1():t==="ar"?B1():t==="es"?j1():t==="hi"?U1():t==="ko"?$1():t==="ja"?H1():t==="pl"?V1():N1()}),z1=()=>"Previous match",W1=()=>"上一个匹配项",G1=()=>"مورد قبلی",_1=()=>"التطابق السابق",X1=()=>"Coincidencia anterior",Y1=()=>"पिछला मिलान",K1=()=>"이전 일치 항목",J1=()=>"前の一致",Z1=()=>"Poprzednie dopasowanie",gw=((h={},e={})=>{const t=e.locale??vy();return t==="zh-CN"?W1():t==="fa"?G1():t==="ar"?_1():t==="es"?X1():t==="hi"?Y1():t==="ko"?K1():t==="ja"?J1():t==="pl"?Z1():z1()});/**
|
||||
* @license lucide-react v1.23.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
Vendored
-1191
File diff suppressed because one or more lines are too long
Vendored
+1201
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -49,8 +49,8 @@
|
||||
html { background: #ffffff; }
|
||||
html[data-theme="dark"] { background: #0e0c0c; }
|
||||
</style>
|
||||
<script type="module" crossorigin src="/assets/index-BkLbyxCd.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CwaOERbv.css">
|
||||
<script type="module" crossorigin src="/assets/index-Ci3ZemNB.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-czf9JGyY.css">
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
|
||||
+70
-1
@@ -1305,5 +1305,74 @@
|
||||
"settings_github_enable_under": "تفعيل المزامنة في…",
|
||||
"settings_overleaf_file_sync": "مزامنة ملفات الورقة",
|
||||
"settings_overleaf_live_sync": "مزامنة التعديلات المباشرة",
|
||||
"settings_overleaf_intro": "تبقى بيانات الدخول على هذا الجهاز. اربط كل ورقة من المحرر."
|
||||
"settings_overleaf_intro": "تبقى بيانات الدخول على هذا الجهاز. اربط كل ورقة من المحرر.",
|
||||
"app_history": "السجل",
|
||||
"detail_drawer_run_provenance": "مصدر التشغيل المحدد",
|
||||
"detail_drawer_provenance_run": "التشغيل",
|
||||
"detail_drawer_provenance_commit": "الإيداع",
|
||||
"detail_drawer_provenance_backend": "بيئة التنفيذ",
|
||||
"detail_drawer_provenance_scheduler": "المجدول",
|
||||
"history_origin_from": "مشتقة من {name}",
|
||||
"history_origin_missing": "مشتقة من تجربة مفقودة",
|
||||
"history_origin_start": "نقطة البداية",
|
||||
"history_not_in_task": "ليست ضمن مهمة",
|
||||
"history_untitled_task": "مهمة بلا عنوان",
|
||||
"history_attempt_initial": "المحاولة الأولى",
|
||||
"history_attempt_no_provenance": "مصدر الشيفرة غير متاح",
|
||||
"history_attempt_retry": "الإيداع نفسه · إعادة محاولة",
|
||||
"history_attempt_new_commit": "إيداع جديد · {sha}",
|
||||
"history_status_not_run": "لم يُشغَّل",
|
||||
"history_count_done": "المكتملة: {count}",
|
||||
"history_count_failed": "الفاشلة: {count}",
|
||||
"history_count_cancelled": "الملغاة: {count}",
|
||||
"history_count_running": "قيد التشغيل: {count}",
|
||||
"history_count_not_run": "غير المُشغَّلة: {count}",
|
||||
"history_attempts_label": "المحاولات ({count}): {summary}",
|
||||
"history_details_for": "تفاصيل {name}",
|
||||
"history_all_experiments": "جميع التجارب",
|
||||
"history_copy_branch_name": "نسخ اسم الفرع",
|
||||
"history_started": "بدأت في {time}",
|
||||
"history_close_details": "إغلاق التفاصيل",
|
||||
"history_how_it_got_here": "كيف وصلت إلى هنا",
|
||||
"history_earlier_steps": "السابقة ({count}): {steps}",
|
||||
"history_led_to": "أدّت إلى",
|
||||
"history_what_changed": "ما الذي تغيّر",
|
||||
"history_what_changed_from": "ما الذي تغيّر عن {base}",
|
||||
"history_show_all": "عرض الكل",
|
||||
"history_no_description": "لم يوضّح الوكيل ما الذي تغيّر.",
|
||||
"history_no_description_diff": "لم يوضّح الوكيل ما الذي تغيّر. يعرض «الفرق عن {base}» الشيفرة.",
|
||||
"history_result": "النتيجة",
|
||||
"history_attempts_heading": "المحاولات ({count})",
|
||||
"history_not_run_yet": "لم يُشغَّل بعد.",
|
||||
"history_earlier_attempts": "المحاولات السابقة: {count}",
|
||||
"history_attempt_commit": "الإيداع {sha}",
|
||||
"history_attempt_job": "المهمة {id}",
|
||||
"history_commit_unavailable": "غير متاح",
|
||||
"history_diff_from": "الفرق عن {base}",
|
||||
"history_no_parent_to_diff": "لا يوجد أصل للمقارنة",
|
||||
"history_no_parent_hint": "لم تعد التجربة الأصل مسجّلة، لذا لا يوجد ما يُقارَن به.",
|
||||
"history_empty": "لا توجد تجارب بعد. عندما يبدأ وكيل تجربة، ستظهر هنا مع ما غيّرته وكيف جرى تشغيلها.",
|
||||
"history_column_attempts": "المحاولات",
|
||||
"history_column_latest": "الأحدث",
|
||||
"history_column_from": "المصدر",
|
||||
"history_group": "التجميع",
|
||||
"history_group_task": "المهمة",
|
||||
"history_group_none": "بدون",
|
||||
"history_lineage": "النسب",
|
||||
"history_order": "الترتيب",
|
||||
"history_order_oldest": "الأقدم",
|
||||
"history_order_newest": "الأحدث",
|
||||
"history_path_only": "المسار فقط",
|
||||
"history_path_only_hint": "عرض أسلاف التجربة المحددة وأحفادها فقط",
|
||||
"history_filtered_count": "{shown} من {total}",
|
||||
"history_list_label": "سجل التجارب",
|
||||
"history_no_match": "لا توجد تجارب تطابق «{query}».",
|
||||
"history_off_path": "لا شيء في هذا العرض يقع على مسار التجربة المحددة.",
|
||||
"history_collapsed_more": "{count} أخرى",
|
||||
"history_collapsed_hint": "اكتملت كلها · عرض الفرع",
|
||||
"history_show_lineage": "إظهار تسلسل التجارب",
|
||||
"history_ancestors": "الأسلاف",
|
||||
"history_children": "التجارب الفرعية",
|
||||
"experiment_overview_diff": "الفروقات",
|
||||
"experiment_overview_diff_from_parent": "الفروقات عن التجربة الأصلية"
|
||||
}
|
||||
|
||||
+70
-1
@@ -1305,5 +1305,74 @@
|
||||
"settings_github_enable_under": "Enable syncing in…",
|
||||
"settings_overleaf_file_sync": "Sync paper files",
|
||||
"settings_overleaf_live_sync": "Sync live edits",
|
||||
"settings_overleaf_intro": "Credentials stay on this machine. Link individual papers from the editor."
|
||||
"settings_overleaf_intro": "Credentials stay on this machine. Link individual papers from the editor.",
|
||||
"app_history": "History",
|
||||
"detail_drawer_run_provenance": "Selected run provenance",
|
||||
"detail_drawer_provenance_run": "run",
|
||||
"detail_drawer_provenance_commit": "commit",
|
||||
"detail_drawer_provenance_backend": "backend",
|
||||
"detail_drawer_provenance_scheduler": "scheduler",
|
||||
"history_origin_from": "from {name}",
|
||||
"history_origin_missing": "from a missing experiment",
|
||||
"history_origin_start": "starting point",
|
||||
"history_not_in_task": "Not in a task",
|
||||
"history_untitled_task": "Untitled task",
|
||||
"history_attempt_initial": "Initial attempt",
|
||||
"history_attempt_no_provenance": "Code provenance unavailable",
|
||||
"history_attempt_retry": "Same commit · retry",
|
||||
"history_attempt_new_commit": "New commit · {sha}",
|
||||
"history_status_not_run": "Not run",
|
||||
"history_count_done": "{count} done",
|
||||
"history_count_failed": "{count} failed",
|
||||
"history_count_cancelled": "{count} cancelled",
|
||||
"history_count_running": "{count} running",
|
||||
"history_count_not_run": "{count} not run",
|
||||
"history_attempts_label": "{count} attempts: {summary}",
|
||||
"history_details_for": "Details for {name}",
|
||||
"history_all_experiments": "All experiments",
|
||||
"history_copy_branch_name": "Copy branch name",
|
||||
"history_started": "started {time}",
|
||||
"history_close_details": "Close details",
|
||||
"history_how_it_got_here": "How it got here",
|
||||
"history_earlier_steps": "{count} earlier: {steps}",
|
||||
"history_led_to": "led to",
|
||||
"history_what_changed": "What changed",
|
||||
"history_what_changed_from": "What changed from {base}",
|
||||
"history_show_all": "Show all",
|
||||
"history_no_description": "The agent didn't say what changed.",
|
||||
"history_no_description_diff": "The agent didn't say what changed. Diff from {base} shows the code.",
|
||||
"history_result": "Result",
|
||||
"history_attempts_heading": "Attempts ({count})",
|
||||
"history_not_run_yet": "Not run yet.",
|
||||
"history_earlier_attempts": "{count} earlier attempts",
|
||||
"history_attempt_commit": "commit {sha}",
|
||||
"history_attempt_job": "job {id}",
|
||||
"history_commit_unavailable": "unavailable",
|
||||
"history_diff_from": "Diff from {base}",
|
||||
"history_no_parent_to_diff": "No parent to diff",
|
||||
"history_no_parent_hint": "The parent experiment is no longer recorded, so there is nothing to diff against.",
|
||||
"history_empty": "No experiments yet. When an agent starts one, it appears here with what it changed and how it ran.",
|
||||
"history_column_attempts": "Attempts",
|
||||
"history_column_latest": "Latest",
|
||||
"history_column_from": "From",
|
||||
"history_group": "Group",
|
||||
"history_group_task": "Task",
|
||||
"history_group_none": "None",
|
||||
"history_lineage": "Lineage",
|
||||
"history_order": "Order",
|
||||
"history_order_oldest": "Oldest",
|
||||
"history_order_newest": "Newest",
|
||||
"history_path_only": "Path only",
|
||||
"history_path_only_hint": "Show only the selected experiment's ancestors and descendants",
|
||||
"history_filtered_count": "{shown} of {total}",
|
||||
"history_list_label": "Experiment history",
|
||||
"history_no_match": "No experiments match “{query}”.",
|
||||
"history_off_path": "Nothing in this view is on the selected experiment's path.",
|
||||
"history_collapsed_more": "{count} more",
|
||||
"history_collapsed_hint": "All done · show branch",
|
||||
"history_show_lineage": "Show lineage",
|
||||
"history_ancestors": "Ancestors",
|
||||
"history_children": "Children",
|
||||
"experiment_overview_diff": "Diff",
|
||||
"experiment_overview_diff_from_parent": "Diff from parent"
|
||||
}
|
||||
|
||||
+70
-1
@@ -1305,5 +1305,74 @@
|
||||
"settings_github_enable_under": "Activar sincronización en…",
|
||||
"settings_overleaf_file_sync": "Sincronizar archivos del artículo",
|
||||
"settings_overleaf_live_sync": "Sincronizar ediciones en vivo",
|
||||
"settings_overleaf_intro": "Las credenciales se guardan en este equipo. Vincula cada artículo desde el editor."
|
||||
"settings_overleaf_intro": "Las credenciales se guardan en este equipo. Vincula cada artículo desde el editor.",
|
||||
"app_history": "Historial",
|
||||
"detail_drawer_run_provenance": "Procedencia de la ejecución seleccionada",
|
||||
"detail_drawer_provenance_run": "ejecución",
|
||||
"detail_drawer_provenance_commit": "commit",
|
||||
"detail_drawer_provenance_backend": "backend",
|
||||
"detail_drawer_provenance_scheduler": "planificador",
|
||||
"history_origin_from": "a partir de {name}",
|
||||
"history_origin_missing": "a partir de un experimento que falta",
|
||||
"history_origin_start": "punto de partida",
|
||||
"history_not_in_task": "Sin tarea",
|
||||
"history_untitled_task": "Tarea sin título",
|
||||
"history_attempt_initial": "Primer intento",
|
||||
"history_attempt_no_provenance": "Procedencia del código no disponible",
|
||||
"history_attempt_retry": "Mismo commit · reintento",
|
||||
"history_attempt_new_commit": "Nuevo commit · {sha}",
|
||||
"history_status_not_run": "Sin ejecutar",
|
||||
"history_count_done": "{count} completados",
|
||||
"history_count_failed": "{count} con error",
|
||||
"history_count_cancelled": "{count} cancelados",
|
||||
"history_count_running": "{count} en ejecución",
|
||||
"history_count_not_run": "{count} sin ejecutar",
|
||||
"history_attempts_label": "{count} intentos: {summary}",
|
||||
"history_details_for": "Detalles de {name}",
|
||||
"history_all_experiments": "Todos los experimentos",
|
||||
"history_copy_branch_name": "Copiar nombre de la rama",
|
||||
"history_started": "iniciado el {time}",
|
||||
"history_close_details": "Cerrar detalles",
|
||||
"history_how_it_got_here": "Cómo se llegó aquí",
|
||||
"history_earlier_steps": "{count} anteriores: {steps}",
|
||||
"history_led_to": "dio lugar a",
|
||||
"history_what_changed": "Qué cambió",
|
||||
"history_what_changed_from": "Qué cambió respecto a {base}",
|
||||
"history_show_all": "Mostrar todo",
|
||||
"history_no_description": "El agente no indicó qué cambió.",
|
||||
"history_no_description_diff": "El agente no indicó qué cambió. “Diff respecto a {base}” muestra el código.",
|
||||
"history_result": "Resultado",
|
||||
"history_attempts_heading": "Intentos ({count})",
|
||||
"history_not_run_yet": "Aún no se ha ejecutado.",
|
||||
"history_earlier_attempts": "{count} intentos anteriores",
|
||||
"history_attempt_commit": "commit {sha}",
|
||||
"history_attempt_job": "job {id}",
|
||||
"history_commit_unavailable": "no disponible",
|
||||
"history_diff_from": "Diff respecto a {base}",
|
||||
"history_no_parent_to_diff": "Sin padre con el que comparar",
|
||||
"history_no_parent_hint": "El experimento padre ya no está registrado, así que no hay nada con lo que comparar.",
|
||||
"history_empty": "Aún no hay experimentos. Cuando un agente inicie uno, aparecerá aquí con lo que cambió y cómo se ejecutó.",
|
||||
"history_column_attempts": "Intentos",
|
||||
"history_column_latest": "Último",
|
||||
"history_column_from": "Origen",
|
||||
"history_group": "Agrupar",
|
||||
"history_group_task": "Tarea",
|
||||
"history_group_none": "Ninguno",
|
||||
"history_lineage": "Linaje",
|
||||
"history_order": "Orden",
|
||||
"history_order_oldest": "Más antiguos",
|
||||
"history_order_newest": "Más recientes",
|
||||
"history_path_only": "Solo la ruta",
|
||||
"history_path_only_hint": "Mostrar solo los antecesores y descendientes del experimento seleccionado",
|
||||
"history_filtered_count": "{shown} de {total}",
|
||||
"history_list_label": "Historial de experimentos",
|
||||
"history_no_match": "Ningún experimento coincide con “{query}”.",
|
||||
"history_off_path": "Nada en esta vista está en la ruta del experimento seleccionado.",
|
||||
"history_collapsed_more": "{count} más",
|
||||
"history_collapsed_hint": "Todo completado · mostrar rama",
|
||||
"history_show_lineage": "Mostrar linaje",
|
||||
"history_ancestors": "Antecesores",
|
||||
"history_children": "Experimentos hijos",
|
||||
"experiment_overview_diff": "Diferencias",
|
||||
"experiment_overview_diff_from_parent": "Diferencias con el experimento padre"
|
||||
}
|
||||
|
||||
+70
-1
@@ -1305,5 +1305,74 @@
|
||||
"settings_github_enable_under": "فعالسازی همگامسازی در…",
|
||||
"settings_overleaf_file_sync": "همگامسازی فایلهای مقاله",
|
||||
"settings_overleaf_live_sync": "همگامسازی ویرایشهای زنده",
|
||||
"settings_overleaf_intro": "اطلاعات ورود روی این دستگاه میماند. هر مقاله را از ویرایشگر پیوند دهید."
|
||||
"settings_overleaf_intro": "اطلاعات ورود روی این دستگاه میماند. هر مقاله را از ویرایشگر پیوند دهید.",
|
||||
"app_history": "تاریخچه",
|
||||
"detail_drawer_run_provenance": "منشأ اجرای انتخابشده",
|
||||
"detail_drawer_provenance_run": "اجرا",
|
||||
"detail_drawer_provenance_commit": "کامیت",
|
||||
"detail_drawer_provenance_backend": "بکاند",
|
||||
"detail_drawer_provenance_scheduler": "زمانبند",
|
||||
"history_origin_from": "برگرفته از {name}",
|
||||
"history_origin_missing": "برگرفته از آزمایشی که دیگر وجود ندارد",
|
||||
"history_origin_start": "نقطهٔ شروع",
|
||||
"history_not_in_task": "بدون وظیفه",
|
||||
"history_untitled_task": "وظیفهٔ بدون عنوان",
|
||||
"history_attempt_initial": "تلاش نخست",
|
||||
"history_attempt_no_provenance": "منشأ کد در دسترس نیست",
|
||||
"history_attempt_retry": "همان کامیت · تلاش دوباره",
|
||||
"history_attempt_new_commit": "کامیت جدید · {sha}",
|
||||
"history_status_not_run": "اجرا نشده",
|
||||
"history_count_done": "{count} انجامشده",
|
||||
"history_count_failed": "{count} ناموفق",
|
||||
"history_count_cancelled": "{count} لغوشده",
|
||||
"history_count_running": "{count} در حال اجرا",
|
||||
"history_count_not_run": "{count} اجرا نشده",
|
||||
"history_attempts_label": "{count} تلاش: {summary}",
|
||||
"history_details_for": "جزئیات {name}",
|
||||
"history_all_experiments": "همهٔ آزمایشها",
|
||||
"history_copy_branch_name": "کپی نام شاخه",
|
||||
"history_started": "شروع در {time}",
|
||||
"history_close_details": "بستن جزئیات",
|
||||
"history_how_it_got_here": "چگونه به اینجا رسید",
|
||||
"history_earlier_steps": "{count} مورد قبلی: {steps}",
|
||||
"history_led_to": "منجر شد به",
|
||||
"history_what_changed": "چه چیزی تغییر کرد",
|
||||
"history_what_changed_from": "تغییرات نسبت به {base}",
|
||||
"history_show_all": "نمایش همه",
|
||||
"history_no_description": "عامل نگفته است چه چیزی تغییر کرده است.",
|
||||
"history_no_description_diff": "عامل نگفته است چه چیزی تغییر کرده است. «تفاوت با {base}» کد را نشان میدهد.",
|
||||
"history_result": "نتیجه",
|
||||
"history_attempts_heading": "تلاشها ({count})",
|
||||
"history_not_run_yet": "هنوز اجرا نشده است.",
|
||||
"history_earlier_attempts": "{count} تلاش قبلی",
|
||||
"history_attempt_commit": "کامیت {sha}",
|
||||
"history_attempt_job": "کار {id}",
|
||||
"history_commit_unavailable": "در دسترس نیست",
|
||||
"history_diff_from": "تفاوت با {base}",
|
||||
"history_no_parent_to_diff": "والدی برای مقایسه نیست",
|
||||
"history_no_parent_hint": "آزمایش والد دیگر ثبت نشده است، بنابراین چیزی برای مقایسه وجود ندارد.",
|
||||
"history_empty": "هنوز آزمایشی وجود ندارد. وقتی عاملی آزمایشی را شروع کند، همراه با تغییرات و نحوهٔ اجرایش اینجا نمایش داده میشود.",
|
||||
"history_column_attempts": "تلاشها",
|
||||
"history_column_latest": "آخرین",
|
||||
"history_column_from": "منشأ",
|
||||
"history_group": "گروهبندی",
|
||||
"history_group_task": "وظیفه",
|
||||
"history_group_none": "هیچ",
|
||||
"history_lineage": "تبار",
|
||||
"history_order": "ترتیب",
|
||||
"history_order_oldest": "قدیمیترین",
|
||||
"history_order_newest": "جدیدترین",
|
||||
"history_path_only": "فقط مسیر",
|
||||
"history_path_only_hint": "فقط نیاکان و نوادگان آزمایش انتخابشده را نشان بده",
|
||||
"history_filtered_count": "{shown} از {total}",
|
||||
"history_list_label": "تاریخچهٔ آزمایشها",
|
||||
"history_no_match": "هیچ آزمایشی با «{query}» مطابقت ندارد.",
|
||||
"history_off_path": "در این نما چیزی روی مسیر آزمایش انتخابشده نیست.",
|
||||
"history_collapsed_more": "{count} مورد دیگر",
|
||||
"history_collapsed_hint": "همه انجام شد · نمایش شاخه",
|
||||
"history_show_lineage": "نمایش تبار",
|
||||
"history_ancestors": "نیاکان",
|
||||
"history_children": "آزمایشهای فرزند",
|
||||
"experiment_overview_diff": "تفاوتها",
|
||||
"experiment_overview_diff_from_parent": "تفاوت با آزمایش والد"
|
||||
}
|
||||
|
||||
+70
-1
@@ -1305,5 +1305,74 @@
|
||||
"settings_github_enable_under": "इसमें सिंक चालू करें…",
|
||||
"settings_overleaf_file_sync": "पेपर की फ़ाइलें सिंक करें",
|
||||
"settings_overleaf_live_sync": "लाइव बदलाव सिंक करें",
|
||||
"settings_overleaf_intro": "लॉगिन जानकारी इसी मशीन पर रहती है। हर पेपर को एडिटर से लिंक करें।"
|
||||
"settings_overleaf_intro": "लॉगिन जानकारी इसी मशीन पर रहती है। हर पेपर को एडिटर से लिंक करें।",
|
||||
"app_history": "इतिहास",
|
||||
"detail_drawer_run_provenance": "चुने गए रन का स्रोत विवरण",
|
||||
"detail_drawer_provenance_run": "रन",
|
||||
"detail_drawer_provenance_commit": "कमिट",
|
||||
"detail_drawer_provenance_backend": "बैकएंड",
|
||||
"detail_drawer_provenance_scheduler": "शेड्यूलर",
|
||||
"history_origin_from": "{name} से",
|
||||
"history_origin_missing": "एक गायब प्रयोग से",
|
||||
"history_origin_start": "शुरुआती बिंदु",
|
||||
"history_not_in_task": "किसी टास्क में नहीं",
|
||||
"history_untitled_task": "बिना शीर्षक वाला टास्क",
|
||||
"history_attempt_initial": "पहला प्रयास",
|
||||
"history_attempt_no_provenance": "कोड का स्रोत उपलब्ध नहीं",
|
||||
"history_attempt_retry": "वही कमिट · फिर से कोशिश",
|
||||
"history_attempt_new_commit": "नया कमिट · {sha}",
|
||||
"history_status_not_run": "नहीं चलाया गया",
|
||||
"history_count_done": "{count} पूरे",
|
||||
"history_count_failed": "{count} विफल",
|
||||
"history_count_cancelled": "{count} रद्द",
|
||||
"history_count_running": "{count} चल रहे",
|
||||
"history_count_not_run": "{count} नहीं चलाए गए",
|
||||
"history_attempts_label": "{count} प्रयास: {summary}",
|
||||
"history_details_for": "{name} का विवरण",
|
||||
"history_all_experiments": "सभी प्रयोग",
|
||||
"history_copy_branch_name": "ब्रांच का नाम कॉपी करें",
|
||||
"history_started": "{time} को शुरू हुआ",
|
||||
"history_close_details": "विवरण बंद करें",
|
||||
"history_how_it_got_here": "यह यहाँ तक कैसे पहुँचा",
|
||||
"history_earlier_steps": "{count} पहले के: {steps}",
|
||||
"history_led_to": "इससे बने",
|
||||
"history_what_changed": "क्या बदला",
|
||||
"history_what_changed_from": "{base} से क्या बदला",
|
||||
"history_show_all": "सभी दिखाएं",
|
||||
"history_no_description": "एजेंट ने नहीं बताया कि क्या बदला।",
|
||||
"history_no_description_diff": "एजेंट ने नहीं बताया कि क्या बदला। “{base} से डिफ़” कोड दिखाता है।",
|
||||
"history_result": "परिणाम",
|
||||
"history_attempts_heading": "प्रयास ({count})",
|
||||
"history_not_run_yet": "अभी तक नहीं चलाया गया।",
|
||||
"history_earlier_attempts": "{count} पिछले प्रयास",
|
||||
"history_attempt_commit": "कमिट {sha}",
|
||||
"history_attempt_job": "जॉब {id}",
|
||||
"history_commit_unavailable": "उपलब्ध नहीं",
|
||||
"history_diff_from": "{base} से डिफ़",
|
||||
"history_no_parent_to_diff": "तुलना के लिए कोई पैरेंट नहीं",
|
||||
"history_no_parent_hint": "पैरेंट प्रयोग अब रिकॉर्ड में नहीं है, इसलिए तुलना करने के लिए कुछ नहीं है।",
|
||||
"history_empty": "अभी तक कोई प्रयोग नहीं। जब कोई एजेंट प्रयोग शुरू करेगा, तो वह यहाँ दिखेगा, साथ में यह भी कि उसमें क्या बदला और वह कैसे चला।",
|
||||
"history_column_attempts": "प्रयास",
|
||||
"history_column_latest": "नवीनतम",
|
||||
"history_column_from": "स्रोत",
|
||||
"history_group": "समूह",
|
||||
"history_group_task": "टास्क",
|
||||
"history_group_none": "कोई नहीं",
|
||||
"history_lineage": "वंशावली",
|
||||
"history_order": "क्रम",
|
||||
"history_order_oldest": "सबसे पुराने",
|
||||
"history_order_newest": "सबसे नए",
|
||||
"history_path_only": "केवल पथ",
|
||||
"history_path_only_hint": "केवल चुने गए प्रयोग के पूर्वज और वंशज दिखाएं",
|
||||
"history_filtered_count": "{total} में से {shown}",
|
||||
"history_list_label": "प्रयोग इतिहास",
|
||||
"history_no_match": "“{query}” से मेल खाने वाला कोई प्रयोग नहीं।",
|
||||
"history_off_path": "इस व्यू में कुछ भी चुने गए प्रयोग के पथ पर नहीं है।",
|
||||
"history_collapsed_more": "{count} और",
|
||||
"history_collapsed_hint": "सब पूरे · ब्रांच दिखाएं",
|
||||
"history_show_lineage": "वंशक्रम दिखाएँ",
|
||||
"history_ancestors": "पूर्वज",
|
||||
"history_children": "चाइल्ड प्रयोग",
|
||||
"experiment_overview_diff": "अंतर",
|
||||
"experiment_overview_diff_from_parent": "मूल प्रयोग से अंतर"
|
||||
}
|
||||
|
||||
+70
-1
@@ -1305,5 +1305,74 @@
|
||||
"settings_github_enable_under": "同期先を選んで有効化…",
|
||||
"settings_overleaf_file_sync": "論文ファイルを同期",
|
||||
"settings_overleaf_live_sync": "リアルタイムの編集を同期",
|
||||
"settings_overleaf_intro": "認証情報はこの端末に保存されます。各論文はエディターからリンクします。"
|
||||
"settings_overleaf_intro": "認証情報はこの端末に保存されます。各論文はエディターからリンクします。",
|
||||
"app_history": "履歴",
|
||||
"detail_drawer_run_provenance": "選択した実行の来歴",
|
||||
"detail_drawer_provenance_run": "実行",
|
||||
"detail_drawer_provenance_commit": "コミット",
|
||||
"detail_drawer_provenance_backend": "バックエンド",
|
||||
"detail_drawer_provenance_scheduler": "スケジューラー",
|
||||
"history_origin_from": "{name} から派生",
|
||||
"history_origin_missing": "見つからない実験から派生",
|
||||
"history_origin_start": "起点",
|
||||
"history_not_in_task": "タスク外",
|
||||
"history_untitled_task": "無題のタスク",
|
||||
"history_attempt_initial": "初回の試行",
|
||||
"history_attempt_no_provenance": "コードの来歴は不明",
|
||||
"history_attempt_retry": "同じコミット · 再試行",
|
||||
"history_attempt_new_commit": "新しいコミット · {sha}",
|
||||
"history_status_not_run": "未実行",
|
||||
"history_count_done": "{count} 件完了",
|
||||
"history_count_failed": "{count} 件失敗",
|
||||
"history_count_cancelled": "{count} 件キャンセル",
|
||||
"history_count_running": "{count} 件実行中",
|
||||
"history_count_not_run": "{count} 件未実行",
|
||||
"history_attempts_label": "試行 {count} 回: {summary}",
|
||||
"history_details_for": "{name} の詳細",
|
||||
"history_all_experiments": "すべての実験",
|
||||
"history_copy_branch_name": "ブランチ名をコピー",
|
||||
"history_started": "{time} に開始",
|
||||
"history_close_details": "詳細を閉じる",
|
||||
"history_how_it_got_here": "ここまでの経緯",
|
||||
"history_earlier_steps": "それ以前の {count} 件: {steps}",
|
||||
"history_led_to": "派生先",
|
||||
"history_what_changed": "変更内容",
|
||||
"history_what_changed_from": "{base} からの変更内容",
|
||||
"history_show_all": "すべて表示",
|
||||
"history_no_description": "エージェントは変更内容を説明していません。",
|
||||
"history_no_description_diff": "エージェントは変更内容を説明していません。「{base} との差分」でコードを確認できます。",
|
||||
"history_result": "結果",
|
||||
"history_attempts_heading": "試行({count})",
|
||||
"history_not_run_yet": "まだ実行されていません。",
|
||||
"history_earlier_attempts": "それ以前の試行 {count} 回",
|
||||
"history_attempt_commit": "コミット {sha}",
|
||||
"history_attempt_job": "ジョブ {id}",
|
||||
"history_commit_unavailable": "不明",
|
||||
"history_diff_from": "{base} との差分",
|
||||
"history_no_parent_to_diff": "比較する親がありません",
|
||||
"history_no_parent_hint": "親の実験はもう記録されていないため、比較対象がありません。",
|
||||
"history_empty": "実験はまだありません。エージェントが実験を開始すると、変更内容と実行状況とともにここに表示されます。",
|
||||
"history_column_attempts": "試行",
|
||||
"history_column_latest": "最新",
|
||||
"history_column_from": "派生元",
|
||||
"history_group": "グループ",
|
||||
"history_group_task": "タスク",
|
||||
"history_group_none": "なし",
|
||||
"history_lineage": "系譜",
|
||||
"history_order": "並び順",
|
||||
"history_order_oldest": "古い順",
|
||||
"history_order_newest": "新しい順",
|
||||
"history_path_only": "経路のみ",
|
||||
"history_path_only_hint": "選択した実験の祖先と子孫だけを表示",
|
||||
"history_filtered_count": "{total} 件中 {shown} 件",
|
||||
"history_list_label": "実験の履歴",
|
||||
"history_no_match": "「{query}」に一致する実験はありません。",
|
||||
"history_off_path": "この表示には、選択した実験の経路上にあるものはありません。",
|
||||
"history_collapsed_more": "他 {count} 件",
|
||||
"history_collapsed_hint": "すべて完了 · ブランチを表示",
|
||||
"history_show_lineage": "系統を表示",
|
||||
"history_ancestors": "祖先",
|
||||
"history_children": "子実験",
|
||||
"experiment_overview_diff": "差分",
|
||||
"experiment_overview_diff_from_parent": "親との差分"
|
||||
}
|
||||
|
||||
+70
-1
@@ -1305,5 +1305,74 @@
|
||||
"settings_github_enable_under": "다음 계정에서 동기화 활성화…",
|
||||
"settings_overleaf_file_sync": "논문 파일 동기화",
|
||||
"settings_overleaf_live_sync": "실시간 편집 동기화",
|
||||
"settings_overleaf_intro": "인증 정보는 이 기기에 저장됩니다. 각 논문은 편집기에서 연결하세요."
|
||||
"settings_overleaf_intro": "인증 정보는 이 기기에 저장됩니다. 각 논문은 편집기에서 연결하세요.",
|
||||
"app_history": "기록",
|
||||
"detail_drawer_run_provenance": "선택한 실행의 출처 정보",
|
||||
"detail_drawer_provenance_run": "실행",
|
||||
"detail_drawer_provenance_commit": "커밋",
|
||||
"detail_drawer_provenance_backend": "백엔드",
|
||||
"detail_drawer_provenance_scheduler": "스케줄러",
|
||||
"history_origin_from": "{name}에서 파생",
|
||||
"history_origin_missing": "누락된 실험에서 파생",
|
||||
"history_origin_start": "시작점",
|
||||
"history_not_in_task": "작업에 속하지 않음",
|
||||
"history_untitled_task": "제목 없는 작업",
|
||||
"history_attempt_initial": "첫 시도",
|
||||
"history_attempt_no_provenance": "코드 출처를 알 수 없음",
|
||||
"history_attempt_retry": "동일 커밋 · 재시도",
|
||||
"history_attempt_new_commit": "새 커밋 · {sha}",
|
||||
"history_status_not_run": "실행 안 됨",
|
||||
"history_count_done": "완료 {count}",
|
||||
"history_count_failed": "실패 {count}",
|
||||
"history_count_cancelled": "취소됨 {count}",
|
||||
"history_count_running": "실행 중 {count}",
|
||||
"history_count_not_run": "실행 안 됨 {count}",
|
||||
"history_attempts_label": "시도 {count}회: {summary}",
|
||||
"history_details_for": "{name} 상세 정보",
|
||||
"history_all_experiments": "모든 실험",
|
||||
"history_copy_branch_name": "브랜치 이름 복사",
|
||||
"history_started": "{time} 시작",
|
||||
"history_close_details": "상세 정보 닫기",
|
||||
"history_how_it_got_here": "이곳까지의 과정",
|
||||
"history_earlier_steps": "이전 {count}개: {steps}",
|
||||
"history_led_to": "이어진 실험",
|
||||
"history_what_changed": "변경 사항",
|
||||
"history_what_changed_from": "{base} 대비 변경 사항",
|
||||
"history_show_all": "모두 보기",
|
||||
"history_no_description": "에이전트가 변경 사항을 설명하지 않았습니다.",
|
||||
"history_no_description_diff": "에이전트가 변경 사항을 설명하지 않았습니다. “{base} 대비 차이”에서 코드를 확인할 수 있습니다.",
|
||||
"history_result": "결과",
|
||||
"history_attempts_heading": "시도 ({count})",
|
||||
"history_not_run_yet": "아직 실행되지 않았습니다.",
|
||||
"history_earlier_attempts": "이전 시도 {count}회",
|
||||
"history_attempt_commit": "커밋 {sha}",
|
||||
"history_attempt_job": "작업 {id}",
|
||||
"history_commit_unavailable": "알 수 없음",
|
||||
"history_diff_from": "{base} 대비 차이",
|
||||
"history_no_parent_to_diff": "비교할 상위 실험 없음",
|
||||
"history_no_parent_hint": "상위 실험 기록이 더 이상 없어 비교할 대상이 없습니다.",
|
||||
"history_empty": "아직 실험이 없습니다. 에이전트가 실험을 시작하면 변경 내용과 실행 결과와 함께 여기에 표시됩니다.",
|
||||
"history_column_attempts": "시도",
|
||||
"history_column_latest": "최근",
|
||||
"history_column_from": "출처",
|
||||
"history_group": "그룹",
|
||||
"history_group_task": "작업",
|
||||
"history_group_none": "없음",
|
||||
"history_lineage": "계보",
|
||||
"history_order": "정렬",
|
||||
"history_order_oldest": "오래된순",
|
||||
"history_order_newest": "최신순",
|
||||
"history_path_only": "경로만",
|
||||
"history_path_only_hint": "선택한 실험의 상위 및 하위 실험만 표시",
|
||||
"history_filtered_count": "{total}개 중 {shown}개",
|
||||
"history_list_label": "실험 기록",
|
||||
"history_no_match": "“{query}”와 일치하는 실험이 없습니다.",
|
||||
"history_off_path": "이 보기에는 선택한 실험의 경로에 있는 항목이 없습니다.",
|
||||
"history_collapsed_more": "{count}개 더",
|
||||
"history_collapsed_hint": "모두 완료 · 브랜치 보기",
|
||||
"history_show_lineage": "계보 표시",
|
||||
"history_ancestors": "상위 실험",
|
||||
"history_children": "하위 실험",
|
||||
"experiment_overview_diff": "차이",
|
||||
"experiment_overview_diff_from_parent": "상위 실험과의 차이"
|
||||
}
|
||||
|
||||
+70
-1
@@ -1305,5 +1305,74 @@
|
||||
"settings_github_enable_under": "Włącz synchronizację w…",
|
||||
"settings_overleaf_file_sync": "Synchronizuj pliki artykułu",
|
||||
"settings_overleaf_live_sync": "Synchronizuj zmiany na żywo",
|
||||
"settings_overleaf_intro": "Dane logowania pozostają na tym komputerze. Łącz poszczególne artykuły w edytorze."
|
||||
"settings_overleaf_intro": "Dane logowania pozostają na tym komputerze. Łącz poszczególne artykuły w edytorze.",
|
||||
"app_history": "Historia",
|
||||
"detail_drawer_run_provenance": "Pochodzenie wybranego uruchomienia",
|
||||
"detail_drawer_provenance_run": "uruchomienie",
|
||||
"detail_drawer_provenance_commit": "commit",
|
||||
"detail_drawer_provenance_backend": "backend",
|
||||
"detail_drawer_provenance_scheduler": "harmonogram",
|
||||
"history_origin_from": "z {name}",
|
||||
"history_origin_missing": "z brakującego eksperymentu",
|
||||
"history_origin_start": "punkt wyjścia",
|
||||
"history_not_in_task": "Poza zadaniem",
|
||||
"history_untitled_task": "Zadanie bez nazwy",
|
||||
"history_attempt_initial": "Pierwsza próba",
|
||||
"history_attempt_no_provenance": "Pochodzenie kodu niedostępne",
|
||||
"history_attempt_retry": "Ten sam commit · ponowna próba",
|
||||
"history_attempt_new_commit": "Nowy commit · {sha}",
|
||||
"history_status_not_run": "Nie uruchomiono",
|
||||
"history_count_done": "Ukończone: {count}",
|
||||
"history_count_failed": "Nieudane: {count}",
|
||||
"history_count_cancelled": "Anulowane: {count}",
|
||||
"history_count_running": "W toku: {count}",
|
||||
"history_count_not_run": "Nieuruchomione: {count}",
|
||||
"history_attempts_label": "Próby ({count}): {summary}",
|
||||
"history_details_for": "Szczegóły: {name}",
|
||||
"history_all_experiments": "Wszystkie eksperymenty",
|
||||
"history_copy_branch_name": "Kopiuj nazwę gałęzi",
|
||||
"history_started": "rozpoczęto {time}",
|
||||
"history_close_details": "Zamknij szczegóły",
|
||||
"history_how_it_got_here": "Jak do tego doszło",
|
||||
"history_earlier_steps": "Wcześniejsze kroki ({count}): {steps}",
|
||||
"history_led_to": "doprowadziło do",
|
||||
"history_what_changed": "Co się zmieniło",
|
||||
"history_what_changed_from": "Co się zmieniło względem {base}",
|
||||
"history_show_all": "Pokaż wszystko",
|
||||
"history_no_description": "Agent nie opisał, co się zmieniło.",
|
||||
"history_no_description_diff": "Agent nie opisał, co się zmieniło. Różnice względem {base} pokazują kod.",
|
||||
"history_result": "Wynik",
|
||||
"history_attempts_heading": "Próby ({count})",
|
||||
"history_not_run_yet": "Jeszcze nie uruchomiono.",
|
||||
"history_earlier_attempts": "Wcześniejsze próby: {count}",
|
||||
"history_attempt_commit": "commit {sha}",
|
||||
"history_attempt_job": "zadanie obliczeniowe {id}",
|
||||
"history_commit_unavailable": "niedostępny",
|
||||
"history_diff_from": "Różnice względem {base}",
|
||||
"history_no_parent_to_diff": "Brak rodzica do porównania",
|
||||
"history_no_parent_hint": "Eksperyment nadrzędny nie jest już zapisany, więc nie ma z czym porównać.",
|
||||
"history_empty": "Nie ma jeszcze eksperymentów. Gdy agent jakiś uruchomi, pojawi się tutaj razem z tym, co zmienił i jak przebiegł.",
|
||||
"history_column_attempts": "Próby",
|
||||
"history_column_latest": "Ostatnia",
|
||||
"history_column_from": "Źródło",
|
||||
"history_group": "Grupuj",
|
||||
"history_group_task": "Zadanie",
|
||||
"history_group_none": "Brak",
|
||||
"history_lineage": "Rodowód",
|
||||
"history_order": "Kolejność",
|
||||
"history_order_oldest": "Od najstarszych",
|
||||
"history_order_newest": "Od najnowszych",
|
||||
"history_path_only": "Tylko ścieżka",
|
||||
"history_path_only_hint": "Pokaż tylko przodków i potomków wybranego eksperymentu",
|
||||
"history_filtered_count": "{shown} z {total}",
|
||||
"history_list_label": "Historia eksperymentów",
|
||||
"history_no_match": "Żaden eksperyment nie pasuje do „{query}”.",
|
||||
"history_off_path": "Nic w tym widoku nie leży na ścieżce wybranego eksperymentu.",
|
||||
"history_collapsed_more": "Jeszcze {count}",
|
||||
"history_collapsed_hint": "Wszystko ukończone · pokaż gałąź",
|
||||
"history_show_lineage": "Pokaż pochodzenie",
|
||||
"history_ancestors": "Przodkowie",
|
||||
"history_children": "Eksperymenty potomne",
|
||||
"experiment_overview_diff": "Różnice",
|
||||
"experiment_overview_diff_from_parent": "Różnice względem rodzica"
|
||||
}
|
||||
|
||||
+70
-1
@@ -1305,5 +1305,74 @@
|
||||
"settings_github_enable_under": "启用同步到…",
|
||||
"settings_overleaf_file_sync": "同步论文文件",
|
||||
"settings_overleaf_live_sync": "同步实时编辑",
|
||||
"settings_overleaf_intro": "凭据保存在本机。在编辑器中关联各篇论文。"
|
||||
"settings_overleaf_intro": "凭据保存在本机。在编辑器中关联各篇论文。",
|
||||
"app_history": "历史",
|
||||
"detail_drawer_run_provenance": "所选运行的来源信息",
|
||||
"detail_drawer_provenance_run": "运行",
|
||||
"detail_drawer_provenance_commit": "提交",
|
||||
"detail_drawer_provenance_backend": "后端",
|
||||
"detail_drawer_provenance_scheduler": "调度器",
|
||||
"history_origin_from": "源自 {name}",
|
||||
"history_origin_missing": "源自已缺失的实验",
|
||||
"history_origin_start": "起点",
|
||||
"history_not_in_task": "不属于任何任务",
|
||||
"history_untitled_task": "未命名任务",
|
||||
"history_attempt_initial": "首次尝试",
|
||||
"history_attempt_no_provenance": "代码来源不可用",
|
||||
"history_attempt_retry": "同一提交 · 重试",
|
||||
"history_attempt_new_commit": "新提交 · {sha}",
|
||||
"history_status_not_run": "未运行",
|
||||
"history_count_done": "已完成 {count}",
|
||||
"history_count_failed": "失败 {count}",
|
||||
"history_count_cancelled": "已取消 {count}",
|
||||
"history_count_running": "运行中 {count}",
|
||||
"history_count_not_run": "未运行 {count}",
|
||||
"history_attempts_label": "共 {count} 次尝试:{summary}",
|
||||
"history_details_for": "{name} 的详情",
|
||||
"history_all_experiments": "所有实验",
|
||||
"history_copy_branch_name": "复制分支名称",
|
||||
"history_started": "开始于 {time}",
|
||||
"history_close_details": "关闭详情",
|
||||
"history_how_it_got_here": "演进过程",
|
||||
"history_earlier_steps": "更早的 {count} 个:{steps}",
|
||||
"history_led_to": "衍生出",
|
||||
"history_what_changed": "变更内容",
|
||||
"history_what_changed_from": "相对于 {base} 的变更",
|
||||
"history_show_all": "显示全部",
|
||||
"history_no_description": "智能体没有说明改了什么。",
|
||||
"history_no_description_diff": "智能体没有说明改了什么。“与 {base} 对比”可查看代码。",
|
||||
"history_result": "结果",
|
||||
"history_attempts_heading": "尝试({count})",
|
||||
"history_not_run_yet": "尚未运行。",
|
||||
"history_earlier_attempts": "更早的 {count} 次尝试",
|
||||
"history_attempt_commit": "提交 {sha}",
|
||||
"history_attempt_job": "作业 {id}",
|
||||
"history_commit_unavailable": "不可用",
|
||||
"history_diff_from": "与 {base} 对比",
|
||||
"history_no_parent_to_diff": "没有可对比的父实验",
|
||||
"history_no_parent_hint": "父实验已不再有记录,因此没有可对比的对象。",
|
||||
"history_empty": "还没有实验。智能体启动实验后,它会显示在这里,并附上改动内容和运行情况。",
|
||||
"history_column_attempts": "尝试",
|
||||
"history_column_latest": "最近",
|
||||
"history_column_from": "来源",
|
||||
"history_group": "分组",
|
||||
"history_group_task": "任务",
|
||||
"history_group_none": "无",
|
||||
"history_lineage": "谱系",
|
||||
"history_order": "排序",
|
||||
"history_order_oldest": "最早",
|
||||
"history_order_newest": "最新",
|
||||
"history_path_only": "仅显示路径",
|
||||
"history_path_only_hint": "仅显示所选实验的祖先和后代",
|
||||
"history_filtered_count": "{shown} / {total}",
|
||||
"history_list_label": "实验历史",
|
||||
"history_no_match": "没有与“{query}”匹配的实验。",
|
||||
"history_off_path": "此视图中没有位于所选实验路径上的实验。",
|
||||
"history_collapsed_more": "另有 {count} 个",
|
||||
"history_collapsed_hint": "全部完成 · 显示分支",
|
||||
"history_show_lineage": "显示谱系",
|
||||
"history_ancestors": "祖先实验",
|
||||
"history_children": "子实验",
|
||||
"experiment_overview_diff": "差异",
|
||||
"experiment_overview_diff_from_parent": "与父实验的差异"
|
||||
}
|
||||
|
||||
+55
-8
@@ -67,12 +67,14 @@ import { useLocale } from "./locale";
|
||||
import { autoDir, ltr } from "./i18n";
|
||||
import {
|
||||
ChartSpline,
|
||||
ArrowDownWideNarrow,
|
||||
Check,
|
||||
FileCode,
|
||||
Filter,
|
||||
FlaskConical,
|
||||
FolderGit2,
|
||||
FolderOpen,
|
||||
GitBranch,
|
||||
Maximize2,
|
||||
MessagesSquare,
|
||||
Minimize2,
|
||||
@@ -105,7 +107,7 @@ import { ProjectTerminal } from "./components/ProjectTerminal";
|
||||
import { SettingsCommandTerminal } from "./components/SshConnectTerminal";
|
||||
import { isWindowsDrivePath } from "./markdownTarget";
|
||||
import { ChatPanel, findPartById, spawnRowTitle } from "./components/ChatPanel";
|
||||
import { usePopover } from "./components/ModelPicker";
|
||||
import { OptionPicker, usePopover } from "./components/ModelPicker";
|
||||
import { SubagentTab } from "./components/SubagentTab";
|
||||
import { CodeTab, type CodeView } from "./components/CodeTab";
|
||||
import { WorktreeTab, type WorktreeView } from "./components/WorktreeTab";
|
||||
@@ -122,13 +124,14 @@ import { OfflineBanner } from "./components/OfflineBanner";
|
||||
import { NewProjectDialog } from "./components/ProjectsHome";
|
||||
import { ExperimentsTable } from "./components/ExperimentsTable";
|
||||
import { archiveActionsByExperiment } from "./components/ArchiveMenu";
|
||||
import { type HistoryOrder } from "./components/ExperimentLineage";
|
||||
import { Md } from "./components/Md";
|
||||
import { SettingsView, type SettingsTab } from "./components/SettingsPage";
|
||||
import { DemoWelcomeModal } from "./components/Tour";
|
||||
import { TreeView } from "./components/TreeView";
|
||||
import { onChatEvent, useOrxEvents } from "./events";
|
||||
import { closeTab, openTab, type TabOpenIntent } from "./tabPreview";
|
||||
import { Button, IconButton, MenuItem, showAlert, Spinner } from "./components/ui";
|
||||
import { Button, IconButton, MenuItem, showAlert, Spinner, Tooltip } from "./components/ui";
|
||||
import { CodeTabBody, TabBody } from "./components/layout/TabBody";
|
||||
import { RemoteStatus } from "./components/RemoteStatus";
|
||||
|
||||
@@ -374,6 +377,8 @@ export default function App({ runtime, projectId, pane }: { runtime: RuntimeInfo
|
||||
const artifacts = artifactsQuery.data ?? null;
|
||||
|
||||
const [view, setView] = useState<ExperimentsView>("table");
|
||||
const [showTableLineage, setShowTableLineage] = useState(false);
|
||||
const [tableOrder, setTableOrder] = useState<HistoryOrder>("lineage");
|
||||
const [showArchivedExperiments, setShowArchivedExperiments] = useState(false);
|
||||
// Experiments pane scope: "agent" narrows to the open chat session's work.
|
||||
// Falls back to "project" whenever there is no usable experiment attribution.
|
||||
@@ -802,7 +807,7 @@ export default function App({ runtime, projectId, pane }: { runtime: RuntimeInfo
|
||||
if (prefs) {
|
||||
setRailOpen(prefs.railOpen);
|
||||
setPanelWidth(Math.min(prefs.panelWidth, panelMaxWidth()));
|
||||
setView(prefs.experimentsView);
|
||||
setView(prefs.experimentsView === "tree" ? "tree" : "table");
|
||||
}
|
||||
}, [uiState]);
|
||||
useEffect(() => {
|
||||
@@ -1915,8 +1920,43 @@ export default function App({ runtime, projectId, pane }: { runtime: RuntimeInfo
|
||||
) : rightTab === "experiments" ? (
|
||||
<TabBody>
|
||||
<div className="pane-toolbar flex shrink-0 flex-wrap items-center gap-2 bg-background px-3 pt-2.5 pb-2">
|
||||
<span className="flex-1" />
|
||||
<div className="experiments-toolbar-controls inline-flex items-center gap-[5px]">
|
||||
<div className="experiments-toolbar-controls flex w-full items-center gap-[5px]">
|
||||
{view === "table" && (
|
||||
<Tooltip interactive content={m.history_order()}>
|
||||
<OptionPicker
|
||||
triggerIcon={<ArrowDownWideNarrow size={16} />}
|
||||
choices={[
|
||||
{ id: "lineage", label: m.history_lineage() },
|
||||
{ id: "oldest", label: m.history_order_oldest() },
|
||||
{ id: "newest", label: m.history_order_newest() },
|
||||
]}
|
||||
value={tableOrder}
|
||||
header={m.history_order()}
|
||||
title={m.history_order()}
|
||||
variant="bare"
|
||||
dropDown
|
||||
align="left"
|
||||
onSelect={(id) => {
|
||||
if (id === "lineage" || id === "oldest" || id === "newest") setTableOrder(id);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{view === "table" && (
|
||||
<Tooltip interactive content={m.history_show_lineage()}>
|
||||
<IconButton
|
||||
size="small"
|
||||
active={showTableLineage && tableOrder === "lineage"}
|
||||
className="[&.active]:text-text"
|
||||
disabled={tableOrder !== "lineage"}
|
||||
aria-label={m.history_show_lineage()}
|
||||
aria-pressed={showTableLineage && tableOrder === "lineage"}
|
||||
onClick={() => setShowTableLineage((show) => !show)}
|
||||
>
|
||||
<GitBranch size={16} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<div className="option-picker relative inline-flex" ref={scopeMenuRef}>
|
||||
<IconButton size="small"
|
||||
ref={scopeTriggerRef}
|
||||
@@ -1930,7 +1970,7 @@ export default function App({ runtime, projectId, pane }: { runtime: RuntimeInfo
|
||||
<Filter size={16} strokeWidth={2.5} />
|
||||
</IconButton>
|
||||
{scopeMenuOpen && (
|
||||
<div className="option-menu absolute bottom-[calc(100%_+_8px)] start-0 max-h-95 flex flex-col bg-background border border-border rounded-lg shadow-menu z-50 overflow-hidden min-w-47.5 p-1.5 [&.align-right]:start-auto [&.align-right]:end-0 [&.drop-down]:bottom-auto [&.drop-down]:top-[calc(100%_+_4px)] [&.session-menu]:start-auto [&.session-menu]:end-1.5 [&.session-menu]:top-[calc(100%_-_2px)] [&.session-menu]:min-w-35 drop-down align-right experiment-scope-menu [&_.model-item]:whitespace-nowrap [&_.model-item:disabled]:text-muted [&_.model-item:disabled]:cursor-default [&_.model-item:disabled:hover]:bg-transparent">
|
||||
<div className="option-menu absolute bottom-[calc(100%_+_8px)] start-0 max-h-95 flex flex-col bg-background border border-border rounded-lg shadow-menu z-50 overflow-hidden min-w-47.5 p-1.5 [&.align-right]:start-auto [&.align-right]:end-0 [&.drop-down]:bottom-auto [&.drop-down]:top-[calc(100%_+_4px)] [&.session-menu]:start-auto [&.session-menu]:end-1.5 [&.session-menu]:top-[calc(100%_-_2px)] [&.session-menu]:min-w-35 drop-down experiment-scope-menu [&_.model-item]:whitespace-nowrap [&_.model-item:disabled]:text-muted [&_.model-item:disabled]:cursor-default [&_.model-item:disabled:hover]:bg-transparent">
|
||||
<MenuItem
|
||||
aria-pressed={effectiveScope === "agent"}
|
||||
disabled={!activeSessionId || !allExperimentsAttributed}
|
||||
@@ -1973,6 +2013,7 @@ export default function App({ runtime, projectId, pane }: { runtime: RuntimeInfo
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="flex-1" />
|
||||
<div
|
||||
className="seg inline-flex items-center gap-0.5 rounded-md bg-hover-subtle [&_button]:font-medium [&_button]:text-text [&_button]:rounded-sm [&_button:not(:disabled):hover]:text-text [&_button.active]:bg-background [&_button.active]:shadow-segment [&_button:disabled]:text-muted [&_button:disabled]:cursor-default experiments-view-toggle p-0.5 [&_button]:py-0.5 [&_button]:px-2 [&_button]:text-sm"
|
||||
role="group"
|
||||
@@ -2014,8 +2055,10 @@ export default function App({ runtime, projectId, pane }: { runtime: RuntimeInfo
|
||||
onViewportChange={setTreeViewport}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
) : view === "table" ? (
|
||||
<ExperimentsTable
|
||||
showLineage={showTableLineage && tableOrder === "lineage"}
|
||||
order={tableOrder}
|
||||
archiveActions={archiveActions}
|
||||
runs={scopedRuns}
|
||||
emptyHint={
|
||||
@@ -2045,7 +2088,7 @@ export default function App({ runtime, projectId, pane }: { runtime: RuntimeInfo
|
||||
onArchive={archiveExperiment}
|
||||
onCancel={cancelRun}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</TabBody>
|
||||
) : rightTab === "files" ? (
|
||||
@@ -2283,6 +2326,10 @@ export default function App({ runtime, projectId, pane }: { runtime: RuntimeInfo
|
||||
<DetailDrawer
|
||||
key={`${expTab.id}:${expTab.view}`}
|
||||
experiment={tabExperiment}
|
||||
experiments={experiments}
|
||||
onOpenExperiment={(id, intent) =>
|
||||
openFromRightTab(expTab, () => openExperimentTab(id, "overview", intent))
|
||||
}
|
||||
project={activeProject}
|
||||
view={expTab.view}
|
||||
runs={runs}
|
||||
|
||||
@@ -395,3 +395,24 @@
|
||||
.openresearch-diff .diff-code {
|
||||
@apply whitespace-pre-wrap break-normal break-words;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.term-provenance {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px 14px;
|
||||
min-height: 32px;
|
||||
padding: 6px 10px;
|
||||
overflow-x: auto;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--subtext);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.term-provenance code {
|
||||
color: var(--text);
|
||||
font-size: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { LogTerminal } from "./LogTerminal";
|
||||
import { StatusBadge } from "./StatusBadge";
|
||||
import type { TabOpenIntent } from "../tabPreview";
|
||||
import { Button, ButtonLink, MenuItem } from "./ui";
|
||||
import { buildHistoryNodes, historyBackendLabel, historyJobId } from "./experimentLineageModel";
|
||||
import { getTrackioSettingsQuery } from "../queries/settings";
|
||||
|
||||
export type ExperimentView = "overview" | "terminal";
|
||||
@@ -25,6 +26,7 @@ export type ExperimentView = "overview" | "terminal";
|
||||
* keyed by `${experiment.id}:${view}` so per-view state resets on switch. */
|
||||
export function DetailDrawer({
|
||||
experiment,
|
||||
experiments,
|
||||
project,
|
||||
view,
|
||||
runs,
|
||||
@@ -33,8 +35,10 @@ export function DetailDrawer({
|
||||
parentExperiment,
|
||||
onOpenView,
|
||||
onOpenCode,
|
||||
onOpenExperiment,
|
||||
}: {
|
||||
experiment: Experiment;
|
||||
experiments: Experiment[];
|
||||
/** Owning project — supplies owner/repo for the GitHub branch link. */
|
||||
project: Project;
|
||||
view: ExperimentView;
|
||||
@@ -44,20 +48,25 @@ export function DetailDrawer({
|
||||
parentExperiment: Experiment | null;
|
||||
onOpenView: (view: ExperimentView, runId: string | undefined, intent: TabOpenIntent) => void;
|
||||
onOpenCode: (view: CodeView, intent: TabOpenIntent) => void;
|
||||
onOpenExperiment: (id: string, intent: TabOpenIntent) => void;
|
||||
}) {
|
||||
const expRuns = runs
|
||||
.filter((r) => r.experimentId === experiment.id)
|
||||
.sort((a, b) => b.createdAt - a.createdAt);
|
||||
|
||||
if (view === "overview") {
|
||||
const node = buildHistoryNodes(experiments, runs).find((node) => node.id === experiment.id);
|
||||
return (
|
||||
<ExperimentOverview
|
||||
lineageNode={node ?? null}
|
||||
onOpenExperiment={onOpenExperiment}
|
||||
experiment={experiment}
|
||||
parentExperiment={parentExperiment}
|
||||
project={project}
|
||||
runs={expRuns}
|
||||
onOpenLogs={(runId, intent) => onOpenView("terminal", runId, intent)}
|
||||
onOpenCode={(intent) => onOpenCode("files", intent)}
|
||||
onOpenDiff={(intent) => onOpenCode("changes", intent)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -198,6 +207,23 @@ function TerminalView({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedRun && (
|
||||
<div className="term-provenance" aria-label={m.detail_drawer_run_provenance()}>
|
||||
<span>
|
||||
{m.detail_drawer_provenance_run()} <code>{selectedRun.id}</code>
|
||||
</span>
|
||||
<span>
|
||||
{m.detail_drawer_provenance_commit()} <code>{selectedRun.commitSha || m.history_commit_unavailable()}</code>
|
||||
</span>
|
||||
<span>
|
||||
{m.detail_drawer_provenance_backend()} <code>{historyBackendLabel(selectedRun)}</code>
|
||||
</span>
|
||||
<span>
|
||||
{m.detail_drawer_provenance_scheduler()} <code>{historyJobId(selectedRun)}</code>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="term-fill flex-1 min-h-0 bg-terminal pt-1 pe-0 pb-1 ps-1.5">
|
||||
{selectedRun ? (
|
||||
// Key by run id so switching runs in the history dropdown remounts
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { timeAgo, fmtNumber } from "../api";
|
||||
import { getLocale } from "../paraglide/runtime.js";
|
||||
import { m } from "../paraglide/messages.js";
|
||||
import { tabOpenGestureHandlers, type TabOpenIntent } from "../tabPreview";
|
||||
import { ancestorsOf, laneCount, shortName, type HistoryNode, type HistoryRow, type HistoryStatus } from "./experimentLineageModel";
|
||||
|
||||
export type HistoryOrder = "lineage" | "oldest" | "newest";
|
||||
|
||||
const LANE = 14;
|
||||
const RAIL_PAD = 15;
|
||||
const laneX = (lane: number) => RAIL_PAD + LANE / 2 + lane * LANE;
|
||||
|
||||
const STATUS_WORD: Record<HistoryStatus, () => string> = {
|
||||
done: m.status_done,
|
||||
failed: m.status_failed,
|
||||
cancelled: m.status_cancelled,
|
||||
running: m.status_running,
|
||||
none: m.history_status_not_run,
|
||||
};
|
||||
const STATUS_TONE: Record<HistoryStatus, string> = {
|
||||
done: "var(--accent-green)",
|
||||
failed: "var(--accent-red)",
|
||||
cancelled: "var(--muted)",
|
||||
running: "var(--accent-teal)",
|
||||
none: "var(--muted)",
|
||||
};
|
||||
// Only runtime theme variables: an @theme colour that no class uses is dropped from the build.
|
||||
const INK = "var(--lineage)";
|
||||
const LINE_OPACITY = 0.48;
|
||||
const DOT_OPACITY = 1;
|
||||
|
||||
/** Status as a shape, so colour is never the only signal: ● done, ◆ failed, ⊘ cancelled, ◐ running, ○ not run. */
|
||||
function StatusShape({ status, cx, cy, r, color, background = "var(--base)" }: {
|
||||
status: HistoryStatus;
|
||||
cx: number;
|
||||
cy: number;
|
||||
r: number;
|
||||
color: string;
|
||||
background?: string;
|
||||
}) {
|
||||
const stroke = Math.max(1.25, r * 0.38);
|
||||
const inner = r - stroke / 2;
|
||||
if (status === "done") return <circle cx={cx} cy={cy} r={r} fill={color} />;
|
||||
if (status === "failed") {
|
||||
const d = r * 1.18;
|
||||
return <path d={`M${cx} ${cy - d}L${cx + d} ${cy}L${cx} ${cy + d}L${cx - d} ${cy}Z`} fill={color} />;
|
||||
}
|
||||
const ring = <circle cx={cx} cy={cy} r={inner} fill={background} stroke={color} strokeWidth={stroke} />;
|
||||
if (status === "running")
|
||||
return (
|
||||
<g>
|
||||
{ring}
|
||||
<path d={`M${cx} ${cy - inner}A${inner} ${inner} 0 0 1 ${cx} ${cy + inner}Z`} fill={color} />
|
||||
</g>
|
||||
);
|
||||
if (status === "cancelled") {
|
||||
const s = (r - stroke) * 0.7;
|
||||
return (
|
||||
<g>
|
||||
{ring}
|
||||
<line x1={cx - s} y1={cy + s} x2={cx + s} y2={cy - s} stroke={color} strokeWidth={stroke} />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
return ring;
|
||||
}
|
||||
|
||||
function StatusLabel({ status, word = true, size = 12 }: { status: HistoryStatus; word?: boolean; size?: number }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 whitespace-nowrap text-sm text-text">
|
||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className="shrink-0" aria-hidden="true">
|
||||
<StatusShape status={status} cx={size / 2} cy={size / 2} r={size * 0.33} color={STATUS_TONE[status]} />
|
||||
</svg>
|
||||
{word ? STATUS_WORD[status]() : <span className="sr-only">{STATUS_WORD[status]()}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function stamp(ms: number): string {
|
||||
return new Intl.DateTimeFormat(getLocale(), { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" }).format(ms);
|
||||
}
|
||||
function When({ ms }: { ms: number }) {
|
||||
return (
|
||||
<span className="whitespace-nowrap text-xs tabular-nums text-subtext" title={stamp(ms)}>
|
||||
{timeAgo(ms)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export interface Placed {
|
||||
row: HistoryRow;
|
||||
top: number;
|
||||
height: number;
|
||||
y: number;
|
||||
}
|
||||
function edgePath(parent: Placed, child: Placed): string {
|
||||
const from = laneX(parent.row.lane);
|
||||
const to = laneX(child.row.lane);
|
||||
if (from === to) return `M${from} ${parent.y + 6}V${child.y - 6}`;
|
||||
const bend = child.top - 7;
|
||||
return `M${from} ${parent.y + 6}V${bend}A7 7 0 0 0 ${from + 7} ${bend + 7}H${to - 7}A7 7 0 0 1 ${to} ${bend + 14}V${child.y - 6}`;
|
||||
}
|
||||
function rowHead(row: HistoryRow): HistoryNode {
|
||||
return row.kind === "collapsed" ? row.head : row.node;
|
||||
}
|
||||
|
||||
export function Rail({ placed, height, path, selectedId }: {
|
||||
placed: Placed[];
|
||||
height: number;
|
||||
path: ReadonlySet<string>;
|
||||
selectedId: string | null;
|
||||
}) {
|
||||
const byId = new Map<string, Placed>();
|
||||
for (const item of placed) if (item.row.kind !== "lead-in") byId.set(rowHead(item.row).id, item);
|
||||
const edges: { d: string; hot: boolean; dotted: boolean }[] = [];
|
||||
for (const item of placed) {
|
||||
if (item.row.kind === "lead-in") {
|
||||
const target = byId.get(item.row.node.id);
|
||||
if (target)
|
||||
edges.push({
|
||||
d: `M${laneX(0)} ${item.top + 6}V${target.y - 6}`,
|
||||
hot: path.has(item.row.node.id) && path.has(item.row.from.id),
|
||||
dotted: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const node = rowHead(item.row);
|
||||
const parent = node.parent ? byId.get(node.parent.id) : undefined;
|
||||
if (parent) edges.push({ d: edgePath(parent, item), hot: path.has(node.id) && path.has(node.parent!.id), dotted: false });
|
||||
}
|
||||
const width = RAIL_PAD + LANE * laneCount(placed.map((item) => item.row)) + 8;
|
||||
const line = (edge: (typeof edges)[number], key: number) => (
|
||||
<path
|
||||
key={key}
|
||||
d={edge.d}
|
||||
fill="none"
|
||||
stroke={edge.hot ? "var(--primary)" : INK}
|
||||
strokeOpacity={edge.hot ? 1 : LINE_OPACITY}
|
||||
strokeWidth={edge.hot ? 2 : 1.5}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={edge.dotted ? "1.5 3.5" : undefined}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<svg className="pointer-events-none absolute left-0 top-0 z-10" width={width} height={height} aria-hidden="true">
|
||||
{edges.filter((edge) => !edge.hot).map(line)}
|
||||
{edges.filter((edge) => edge.hot).map(line)}
|
||||
{placed.map((item, index) => {
|
||||
if (item.row.kind === "lead-in") return null;
|
||||
const cx = laneX(item.row.lane);
|
||||
if (item.row.kind === "collapsed")
|
||||
return (
|
||||
<g key={index}>
|
||||
<circle cx={cx} cy={item.y + 2.5} r={3.5} fill="var(--base)" stroke={INK} strokeOpacity={DOT_OPACITY} strokeWidth={1.25} />
|
||||
<circle cx={cx} cy={item.y - 1.5} r={3.5} fill="var(--base)" stroke={INK} strokeOpacity={DOT_OPACITY} strokeWidth={1.25} />
|
||||
</g>
|
||||
);
|
||||
const node = item.row.node;
|
||||
const selected = node.id === selectedId;
|
||||
const hot = path.has(node.id);
|
||||
const color = hot ? "var(--primary)" : INK;
|
||||
return (
|
||||
<g key={index} opacity={hot ? 1 : DOT_OPACITY}>
|
||||
{!node.parent && !node.missingParentId && <circle cx={cx} cy={item.y} r={7} fill="none" stroke={color} strokeWidth={1} opacity={0.7} />}
|
||||
{selected && <circle cx={cx} cy={item.y} r={8.5} fill="var(--primary)" fillOpacity={0.18} />}
|
||||
<StatusShape status={node.status} cx={cx} cy={item.y} r={selected ? 4.6 : 4} color={color} background={selected ? "var(--highlight)" : "var(--base)"} />
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function LineageStep({ node, current, onSelect }: { node: HistoryNode; current?: boolean; onSelect: (id: string, intent: TabOpenIntent) => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={current}
|
||||
aria-current={current ? "true" : undefined}
|
||||
{...tabOpenGestureHandlers<HTMLButtonElement>((intent) => onSelect(node.id, intent))}
|
||||
className={`flex min-w-0 items-center gap-2 rounded-sm px-1.5 py-1 text-start ${current ? "bg-highlight" : "hover:bg-hover-faint"}`}
|
||||
>
|
||||
<StatusLabel status={node.status} word={false} size={11} />
|
||||
{node.code && <span className="w-7 shrink-0 font-mono text-xs text-subtext">{node.code}</span>}
|
||||
<span className={`min-w-0 flex-1 truncate text-sm text-text ${current ? "font-medium" : ""}`}>{node.label}</span>
|
||||
<When ms={node.createdAt} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExperimentLineage({ node, onSelect }: { node: HistoryNode; onSelect: (id: string, intent: TabOpenIntent) => void }) {
|
||||
const [showAllAncestors, setShowAllAncestors] = useState(false);
|
||||
useEffect(() => setShowAllAncestors(false), [node.id]);
|
||||
const ancestors = ancestorsOf(node);
|
||||
const folded = ancestors.slice(0, Math.max(0, ancestors.length - 4));
|
||||
const visibleAncestors = showAllAncestors ? ancestors : ancestors.slice(-4);
|
||||
return (
|
||||
<section className="flex flex-col gap-2">
|
||||
<h2 className="m-0 text-sm font-semibold text-text">{m.history_lineage()}</h2>
|
||||
<div className="flex flex-col">
|
||||
{ancestors.length > 0 && <div className="mb-1 text-sm text-subtext">{m.history_ancestors()}</div>}
|
||||
{folded.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={showAllAncestors}
|
||||
onClick={() => setShowAllAncestors((show) => !show)}
|
||||
className="flex min-w-0 items-center gap-1.5 px-1.5 py-1 text-start text-xs text-subtext hover:text-text"
|
||||
title={folded.map((step) => step.label).join(" → ")}
|
||||
>
|
||||
<ChevronRight size={11} className={`shrink-0 ${showAllAncestors ? "rotate-90" : ""}`} />
|
||||
<span className="truncate">
|
||||
{showAllAncestors ? m.common_show_less() : m.history_earlier_steps({ count: fmtNumber(folded.length), steps: folded.map((step) => shortName(step, 18)).join(" → ") })}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{visibleAncestors.map((step) => (
|
||||
<LineageStep key={step.id} node={step} onSelect={onSelect} />
|
||||
))}
|
||||
<LineageStep node={node} current onSelect={onSelect} />
|
||||
{node.children.length > 0 && (
|
||||
<div className="mt-3 flex flex-col gap-1 pt-3">
|
||||
<div className="text-sm text-subtext">{m.history_children()}</div>
|
||||
{node.children.map((child) => <LineageStep key={child.id} node={child} onSelect={onSelect} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Clock3,
|
||||
FolderTree,
|
||||
GitCommitHorizontal,
|
||||
GitCompareArrows,
|
||||
Terminal,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -22,7 +23,9 @@ import { BackendBadge } from "./BackendLogos";
|
||||
import { BranchPill } from "./BranchPill";
|
||||
import { Md } from "./Md";
|
||||
import { StatusBadge } from "./StatusBadge";
|
||||
import { Button } from "./ui";
|
||||
import { Button, Tooltip } from "./ui";
|
||||
import { ExperimentLineage } from "./ExperimentLineage";
|
||||
import type { HistoryNode } from "./experimentLineageModel";
|
||||
|
||||
const EXPERIMENT_OVERVIEW_SECTION_CLASS_NAME = [
|
||||
"experiment-overview-section mt-5.5 pt-4.5 border-t border-t-border-variant",
|
||||
@@ -51,18 +54,24 @@ function runDuration(run: Run, now: number): string {
|
||||
|
||||
export function ExperimentOverview({
|
||||
experiment,
|
||||
lineageNode,
|
||||
onOpenExperiment,
|
||||
parentExperiment,
|
||||
project,
|
||||
runs,
|
||||
onOpenLogs,
|
||||
onOpenCode,
|
||||
onOpenDiff,
|
||||
}: {
|
||||
experiment: Experiment;
|
||||
lineageNode: HistoryNode | null;
|
||||
onOpenExperiment: (id: string, intent: TabOpenIntent) => void;
|
||||
parentExperiment: Experiment | null;
|
||||
project: Project;
|
||||
runs: Run[];
|
||||
onOpenLogs: (runId: string, intent: TabOpenIntent) => void;
|
||||
onOpenCode: (intent: TabOpenIntent) => void;
|
||||
onOpenDiff: (intent: TabOpenIntent) => void;
|
||||
}) {
|
||||
const latestRun = runs[0] ?? null;
|
||||
const monitoringError = experimentMonitoringError(runs);
|
||||
@@ -106,6 +115,15 @@ export function ExperimentOverview({
|
||||
<FolderTree size={15} />
|
||||
{m.experiment_overview_code()}
|
||||
</Button>
|
||||
<Tooltip interactive content={m.experiment_overview_diff_from_parent()}>
|
||||
<Button
|
||||
disabled={!parentExperiment}
|
||||
{...tabOpenGestureHandlers<HTMLButtonElement>(onOpenDiff)}
|
||||
>
|
||||
<GitCompareArrows size={15} />
|
||||
{m.experiment_overview_diff()}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{experiment.description && (
|
||||
@@ -161,6 +179,34 @@ export function ExperimentOverview({
|
||||
)}
|
||||
</section>
|
||||
|
||||
{runs.length > 0 && (
|
||||
<section className={EXPERIMENT_OVERVIEW_SECTION_CLASS_NAME}>
|
||||
<h2>{m.experiment_overview_run_history()}</h2>
|
||||
<div className="experiment-run-history divide-y divide-border-variant [&_button]:w-full [&_button]:grid [&_button]:grid-cols-[minmax(72px,_0.7fr)_minmax(100px,_1fr)_minmax(70px,_0.7fr)_60px_16px] [&_button]:items-center [&_button]:gap-3.5 [&_button]:py-[11px] [&_button]:px-0.5 [&_button]:text-text [&_button]:text-start [&_button]:text-sm [&_button:hover]:bg-surface [@media((max-width:_720px))]:[&_button]:grid-cols-[65px_1fr_60px_16px] [@media((max-width:_720px))]:[&_button_>_:nth-child(3)]:hidden">
|
||||
{runs.map((run, index) => (
|
||||
<button
|
||||
key={run.id}
|
||||
{...tabOpenGestureHandlers<HTMLButtonElement>((intent) =>
|
||||
onOpenLogs(run.id, intent),
|
||||
)}
|
||||
>
|
||||
<span className="experiment-run-number text-xs font-medium">{m.experiment_overview_run()} {runs.length - index}</span>
|
||||
<StatusBadge status={runDisplayStatus(run)} />
|
||||
<span>{timeAgo(run.createdAt)}</span>
|
||||
<span>{runDuration(run, now)}</span>
|
||||
<Terminal size={13} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{lineageNode && (
|
||||
<div className="mt-5.5 border-t border-border-variant pt-4.5">
|
||||
<ExperimentLineage node={lineageNode} onSelect={onOpenExperiment} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className={EXPERIMENT_OVERVIEW_SECTION_CLASS_NAME}>
|
||||
<h2>Git</h2>
|
||||
<div className="experiment-overview-meta flex items-center flex-wrap text-text text-sm [&_svg]:text-muted [&_.backend-badge]:text-text [&_.status-badge]:text-text [&_>_span]:inline-flex [&_>_span]:items-center [&_>_span]:gap-[5px] [&_code]:text-text [&_code]:text-xs experiment-overview-git-meta gap-y-[9px] gap-x-3.5 [&_.files-pill]:py-[5px] [&_.files-pill]:px-2 [&_.files-pill]:rounded-sm [&_.files-pill_code]:text-xs">
|
||||
@@ -183,27 +229,6 @@ export function ExperimentOverview({
|
||||
)}
|
||||
</section>
|
||||
|
||||
{runs.length > 0 && (
|
||||
<section className={EXPERIMENT_OVERVIEW_SECTION_CLASS_NAME}>
|
||||
<h2>{m.experiment_overview_run_history()}</h2>
|
||||
<div className="experiment-run-history border-t border-t-border-variant [&_button]:w-full [&_button]:grid [&_button]:grid-cols-[minmax(72px,_0.7fr)_minmax(100px,_1fr)_minmax(70px,_0.7fr)_60px_16px] [&_button]:items-center [&_button]:gap-3.5 [&_button]:py-[11px] [&_button]:px-0.5 [&_button]:border-b [&_button]:border-b-border-variant [&_button]:text-text [&_button]:text-start [&_button]:text-sm [&_button:hover]:bg-surface [@media((max-width:_720px))]:[&_button]:grid-cols-[65px_1fr_60px_16px] [@media((max-width:_720px))]:[&_button_>_:nth-child(3)]:hidden">
|
||||
{runs.map((run, index) => (
|
||||
<button
|
||||
key={run.id}
|
||||
{...tabOpenGestureHandlers<HTMLButtonElement>((intent) =>
|
||||
onOpenLogs(run.id, intent),
|
||||
)}
|
||||
>
|
||||
<span className="experiment-run-number text-xs font-medium">{m.experiment_overview_run()} {runs.length - index}</span>
|
||||
<StatusBadge status={runDisplayStatus(run)} />
|
||||
<span>{timeAgo(run.createdAt)}</span>
|
||||
<span>{runDuration(run, now)}</span>
|
||||
<Terminal size={13} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import { WorkspaceEmptyState } from "./WorkspaceEmptyState";
|
||||
import { m } from "../paraglide/messages.js";
|
||||
import { ltr } from "../i18n";
|
||||
import { FlaskConical, CircleStop, FolderTree, GitBranch, Terminal } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { FlaskConical, CircleStop, FolderTree, Terminal } from "lucide-react";
|
||||
import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { fmtNumber, runDisplayStatus, timeAgo, type Experiment, type Run } from "../api";
|
||||
import { StatusBadge } from "./StatusBadge";
|
||||
import { tabOpenGestureHandlers, type TabOpenIntent } from "../tabPreview";
|
||||
import { Button } from "./ui";
|
||||
import { IconButton, Tooltip } from "./ui";
|
||||
import { ArchiveMenu, type ArchiveActions } from "./ArchiveMenu";
|
||||
import { Rail, type HistoryOrder, type Placed } from "./ExperimentLineage";
|
||||
import { buildHistoryNodes, laneCount, layoutChronological, layoutLineage } from "./experimentLineageModel";
|
||||
|
||||
const NO_HIGHLIGHT = new Set<string>();
|
||||
|
||||
export function ExperimentsTable({
|
||||
runs,
|
||||
experiments,
|
||||
showLineage,
|
||||
order,
|
||||
archiveActions,
|
||||
emptyHint,
|
||||
onOpen,
|
||||
@@ -22,6 +28,8 @@ export function ExperimentsTable({
|
||||
}: {
|
||||
runs: Run[];
|
||||
experiments: Experiment[];
|
||||
showLineage: boolean;
|
||||
order: HistoryOrder;
|
||||
archiveActions: Map<string, ArchiveActions>;
|
||||
emptyHint?: string;
|
||||
onOpen: (experiment: Experiment, intent: TabOpenIntent) => void;
|
||||
@@ -32,6 +40,33 @@ export function ExperimentsTable({
|
||||
}) {
|
||||
const [pendingCancellation, setPendingCancellation] = useState<ReadonlySet<string>>(new Set());
|
||||
const [cancelError, setCancelError] = useState<string | null>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const [placed, setPlaced] = useState<Placed[]>([]);
|
||||
const rows = useMemo(() => {
|
||||
const group = { key: "table", title: "", nodes: buildHistoryNodes(experiments, runs) };
|
||||
return order === "lineage" ? layoutLineage(group) : layoutChronological(group, order === "newest");
|
||||
}, [experiments, runs, order]);
|
||||
const railWidth = 15 + 14 * laneCount(rows) + 8;
|
||||
const lastPlaced = placed.at(-1);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const list = listRef.current;
|
||||
if (!list) return;
|
||||
const elements = [...list.children].filter((child) => child instanceof HTMLDivElement);
|
||||
const measure = () => {
|
||||
setPlaced(rows.flatMap((row, index) => {
|
||||
const element = elements[index];
|
||||
if (!element) return [];
|
||||
const top = element.offsetTop;
|
||||
const height = element.offsetHeight;
|
||||
return [{ row, top, height, y: top + height / 2 }];
|
||||
}));
|
||||
};
|
||||
const observer = new ResizeObserver(measure);
|
||||
for (const element of elements) observer.observe(element);
|
||||
measure();
|
||||
return () => observer.disconnect();
|
||||
}, [rows]);
|
||||
const runsByExperiment = new Map<string, Run[]>();
|
||||
for (const run of runs) {
|
||||
const experimentRuns = runsByExperiment.get(run.experimentId);
|
||||
@@ -42,13 +77,7 @@ export function ExperimentsTable({
|
||||
experimentRuns.sort((a, b) => b.createdAt - a.createdAt);
|
||||
}
|
||||
|
||||
const sortedExperiments = [...experiments].sort((a, b) => {
|
||||
const aActivity = runsByExperiment.get(a.id)?.[0]?.createdAt ?? a.createdAt;
|
||||
const bActivity = runsByExperiment.get(b.id)?.[0]?.createdAt ?? b.createdAt;
|
||||
return bActivity - aActivity;
|
||||
});
|
||||
|
||||
if (sortedExperiments.length === 0) {
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<WorkspaceEmptyState
|
||||
icon={FlaskConical}
|
||||
@@ -80,8 +109,11 @@ export function ExperimentsTable({
|
||||
{m.experiments_table_stop_failed()} {cancelError}
|
||||
</div>
|
||||
)}
|
||||
<div className="experiments-table w-full text-sm bg-background" role="list" aria-label={m.experiments_table_experiments()}>
|
||||
{sortedExperiments.map((experiment) => {
|
||||
<div ref={listRef} className="experiments-table relative w-full text-sm bg-background" role="list" aria-label={m.experiments_table_experiments()}>
|
||||
{showLineage && <Rail placed={placed} height={lastPlaced ? lastPlaced.top + lastPlaced.height : 0} path={NO_HIGHLIGHT} selectedId={null} />}
|
||||
{rows.map((row) => {
|
||||
if (row.kind !== "experiment") return null;
|
||||
const experiment = row.node.experiment;
|
||||
const experimentRuns = runsByExperiment.get(experiment.id) ?? [];
|
||||
const latestRun = experimentRuns[0] ?? null;
|
||||
const liveRun = experimentRuns.find(
|
||||
@@ -102,7 +134,8 @@ export function ExperimentsTable({
|
||||
return (
|
||||
<div
|
||||
key={experiment.id}
|
||||
className="experiment-table-group grid grid-cols-[minmax(0,_1fr)_auto] [grid-template-areas:'name_meta'_'actions_actions'] gap-x-8 items-center py-4 px-5 gap-y-[7px] border-b border-b-divider-subtle bg-background cursor-pointer [&:hover]:bg-surface [&:last-child]:border-b-0 [@container((max-width:_560px))]:grid-cols-[minmax(0,_1fr)_auto] [@container((max-width:_560px))]:gap-x-3.5 [@container((max-width:_560px))]:gap-y-[9px] [@container((max-width:_400px))]:grid-cols-[minmax(0,_1fr)] [@container((max-width:_400px))]:[grid-template-areas:'name'_'meta'_'actions']"
|
||||
style={{ paddingLeft: showLineage ? railWidth + 12 : 20 }}
|
||||
className="experiment-table-group grid min-h-11 grid-cols-[minmax(0,_1fr)_4.5rem_4rem_4rem_auto] [grid-template-areas:'name_status_attempts_latest_actions'] items-center gap-x-3 gap-y-1 py-2 pe-3 border-b border-b-divider-subtle bg-background cursor-pointer [&:hover]:bg-surface [&:last-child]:border-b-0 [@container(max-width:640px)]:grid-cols-[auto_auto_minmax(0,_1fr)_auto] [@container(max-width:640px)]:[grid-template-areas:'name_name_name_name'_'status_attempts_latest_actions'] [@container(max-width:640px)]:gap-x-3"
|
||||
role="listitem"
|
||||
onClick={() => onOpen(experiment, "preview")}
|
||||
onDoubleClick={() => onOpen(experiment, "keepOpen")}
|
||||
@@ -112,10 +145,10 @@ export function ExperimentsTable({
|
||||
onOpen(experiment, "keepOpen");
|
||||
}}
|
||||
>
|
||||
<div className="experiment-table-name [grid-area:name] self-start min-w-0">
|
||||
<div className="experiment-table-name [grid-area:name] min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
className="experiment-table-title block w-full overflow-hidden text-text font-semibold text-start text-ellipsis whitespace-nowrap"
|
||||
className="experiment-table-title line-clamp-2 w-full overflow-hidden text-text font-medium text-start"
|
||||
{...tabOpenGestureHandlers<HTMLButtonElement>((intent) =>
|
||||
onOpen(experiment, intent),
|
||||
{ stopPropagation: true })}
|
||||
@@ -123,69 +156,69 @@ export function ExperimentsTable({
|
||||
{experiment.title || experiment.slug}
|
||||
</button>
|
||||
{experiment.archived && <span className="text-muted text-xs">{m.tree_archived()}</span>}
|
||||
<span className="experiment-table-subtitle flex items-center min-w-0 gap-1.5 mt-1 overflow-hidden text-subtext text-sm [&_>_svg]:shrink-0 [&_code]:min-w-0 [&_code]:overflow-hidden [&_code]:text-ellipsis [&_code]:whitespace-nowrap" title={experiment.branchName}>
|
||||
<GitBranch size={14} aria-hidden="true" />
|
||||
<code>{experiment.branchName}</code>
|
||||
</span>
|
||||
</div>
|
||||
<div className="experiment-table-meta [grid-area:meta] self-start flex items-center justify-end gap-4.5 whitespace-nowrap [@container((max-width:_560px))]:flex-col [@container((max-width:_560px))]:items-end [@container((max-width:_560px))]:gap-1.5 [@container((max-width:_400px))]:!flex-row [@container((max-width:_400px))]:!items-center [@container((max-width:_400px))]:flex-wrap [@container((max-width:_400px))]:justify-start [@container((max-width:_400px))]:gap-3">
|
||||
<div className="experiment-table-status flex items-center min-w-0">
|
||||
<StatusBadge status={status} />
|
||||
</div>
|
||||
<div className="experiment-run-summary flex items-center min-w-0 gap-2 text-subtext text-sm font-medium">
|
||||
<span>{experimentRuns.length === 1 ? m.experiments_one_run() : m.experiments_run_count({ count: fmtNumber(experimentRuns.length) })}</span>
|
||||
</div>
|
||||
<div className="experiment-table-latest flex items-center gap-1.5 min-w-0 text-subtext text-sm font-medium whitespace-nowrap">
|
||||
<span>{latestRun ? timeAgo(latestRun.createdAt) : m.experiments_not_run_yet()}</span>
|
||||
</div>
|
||||
<div className="experiment-table-status [grid-area:status] min-w-0">
|
||||
<StatusBadge status={status} />
|
||||
</div>
|
||||
<div className="experiment-run-summary [grid-area:attempts] flex items-center gap-2 whitespace-nowrap text-sm text-subtext">
|
||||
<span>{experimentRuns.length === 1 ? m.experiments_one_run() : m.experiments_run_count({ count: fmtNumber(experimentRuns.length) })}</span>
|
||||
</div>
|
||||
<div className="experiment-table-latest [grid-area:latest] whitespace-nowrap text-xs tabular-nums text-subtext">
|
||||
{latestRun ? timeAgo(latestRun.createdAt) : "–"}
|
||||
</div>
|
||||
<div
|
||||
className="experiment-table-actions [grid-area:actions] flex flex-wrap items-center justify-start gap-2 mt-3"
|
||||
className="experiment-table-actions [grid-area:actions] flex items-center justify-end gap-1"
|
||||
role="group"
|
||||
aria-label={m.a11y_actions_for({ name: experiment.title || experiment.slug })}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onDoubleClick={(event) => event.stopPropagation()}
|
||||
onAuxClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={!logsRun}
|
||||
title={logsRun ? m.experiments_open_logs() : m.experiments_no_runs_yet()}
|
||||
{...tabOpenGestureHandlers<HTMLButtonElement>((intent) => {
|
||||
if (logsRun) onOpenLogs(experiment.id, logsRun.id, intent);
|
||||
}, { stopPropagation: true })}
|
||||
>
|
||||
<Terminal size={15} />
|
||||
{m.experiments_table_logs()}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
title={m.a11y_browse_code_on({ branch: ltr(experiment.branchName) })}
|
||||
{...tabOpenGestureHandlers<HTMLButtonElement>((intent) =>
|
||||
onOpenCode(experiment.id, intent),
|
||||
{ stopPropagation: true })}
|
||||
>
|
||||
<FolderTree size={15} />
|
||||
{m.experiments_table_code()}
|
||||
</Button>
|
||||
{liveRun && (
|
||||
<Button
|
||||
<Tooltip interactive content={logsRun ? m.experiments_open_logs() : m.experiments_no_runs_yet()}>
|
||||
<IconButton
|
||||
size="small"
|
||||
variant="danger"
|
||||
disabled={cancelling}
|
||||
title={cancelling ? m.experiments_stop_requested() : m.experiments_stop_run()}
|
||||
onClick={() => void requestCancel(liveRun.id)}
|
||||
disabled={!logsRun}
|
||||
aria-label={m.experiments_table_logs()}
|
||||
{...tabOpenGestureHandlers<HTMLButtonElement>((intent) => {
|
||||
if (logsRun) onOpenLogs(experiment.id, logsRun.id, intent);
|
||||
}, { stopPropagation: true })}
|
||||
>
|
||||
<CircleStop size={15} />
|
||||
{cancelling ? m.common_stopping() : m.common_stop()}
|
||||
</Button>
|
||||
<Terminal size={15} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip interactive content={m.experiments_table_code()}>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={m.a11y_browse_code_on({ branch: ltr(experiment.branchName) })}
|
||||
{...tabOpenGestureHandlers<HTMLButtonElement>((intent) =>
|
||||
onOpenCode(experiment.id, intent),
|
||||
{ stopPropagation: true })}
|
||||
>
|
||||
<FolderTree size={15} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{liveRun && (
|
||||
<Tooltip interactive content={cancelling ? m.experiments_stop_requested() : m.experiments_stop_run()}>
|
||||
<IconButton
|
||||
size="small"
|
||||
variant="stop"
|
||||
disabled={cancelling}
|
||||
aria-label={cancelling ? m.common_stopping() : m.common_stop()}
|
||||
onClick={() => void requestCancel(liveRun.id)}
|
||||
>
|
||||
<CircleStop size={15} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<ArchiveMenu
|
||||
id={experiment.id}
|
||||
name={experiment.title || experiment.slug}
|
||||
actions={archiveActions.get(experiment.id)!}
|
||||
onArchive={onArchive}
|
||||
/>
|
||||
<Tooltip interactive content={m.tree_archive_manage()}>
|
||||
<ArchiveMenu
|
||||
compact
|
||||
id={experiment.id}
|
||||
name={experiment.title || experiment.slug}
|
||||
actions={archiveActions.get(experiment.id)!}
|
||||
onArchive={onArchive}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -531,6 +531,7 @@ export function ModelPicker({
|
||||
* Claude Code composer menu: a header, the current-default row pinned at top
|
||||
* with a "· Default" note, then the full numbered list. */
|
||||
export function OptionPicker({
|
||||
triggerIcon,
|
||||
choices,
|
||||
value,
|
||||
defaultId,
|
||||
@@ -548,6 +549,7 @@ export function OptionPicker({
|
||||
onSelect,
|
||||
className,
|
||||
}: {
|
||||
triggerIcon?: ReactNode;
|
||||
choices: OptionChoice[];
|
||||
value: string | null;
|
||||
/** The harness's default id — pinned at top of the menu and used when
|
||||
@@ -628,6 +630,19 @@ export function OptionPicker({
|
||||
|
||||
return (
|
||||
<div className={`option-picker relative inline-flex${variant === "field" ? " w-full" : ""}`} ref={ref}>
|
||||
{triggerIcon ? (
|
||||
<IconButton
|
||||
ref={buttonRef}
|
||||
size="small"
|
||||
aria-label={title ?? header ?? label}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
onClick={() => { setFilter(""); setOpen((v) => !v); }}
|
||||
>
|
||||
{triggerIcon}
|
||||
</IconButton>
|
||||
) : (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
@@ -649,6 +664,7 @@ export function OptionPicker({
|
||||
</span>
|
||||
<ChevronDown size={12} />
|
||||
</button>
|
||||
)}
|
||||
{open && (
|
||||
<div ref={menuRef} popover={floating ? "manual" : undefined}
|
||||
style={floating ? { position: "fixed", inset: "auto", margin: 0 } : undefined}
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
import {
|
||||
backendDetail,
|
||||
backendKind,
|
||||
runDisplayStatus,
|
||||
type Experiment,
|
||||
type Run,
|
||||
} from "../api";
|
||||
import { ltr } from "../i18n";
|
||||
import { m } from "../paraglide/messages.js";
|
||||
|
||||
/** Outcome of an experiment's latest attempt; "none" when it has never run. */
|
||||
export type HistoryStatus = "done" | "failed" | "cancelled" | "running" | "none";
|
||||
|
||||
export interface HistoryNode {
|
||||
id: string;
|
||||
experiment: Experiment;
|
||||
parent: HistoryNode | null;
|
||||
/** Recorded parent id that is not among the experiments, or null. */
|
||||
missingParentId: string | null;
|
||||
children: HistoryNode[];
|
||||
/** Attempts, oldest first. */
|
||||
runs: Run[];
|
||||
latestRun: Run | null;
|
||||
status: HistoryStatus;
|
||||
/** Short experiment code parsed from the title ("D2", "A"), or null. */
|
||||
code: string | null;
|
||||
/** Title without its code prefix. */
|
||||
label: string;
|
||||
/** Chat session (agent task) that created the experiment, if any. */
|
||||
task: string | null;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export type HistoryGrouping = "task" | "lineage" | "none";
|
||||
|
||||
export interface HistoryGroup {
|
||||
key: string;
|
||||
title: string;
|
||||
/** Members, oldest first. */
|
||||
nodes: HistoryNode[];
|
||||
}
|
||||
|
||||
export type HistoryRow =
|
||||
| { kind: "experiment"; node: HistoryNode; lane: number }
|
||||
| { kind: "collapsed"; head: HistoryNode; lane: number; hidden: HistoryNode[] }
|
||||
| { kind: "lead-in"; node: HistoryNode; from: HistoryNode; lane: number };
|
||||
|
||||
/** Chapter key for experiments that no agent task created. */
|
||||
export const NO_TASK = "__no_task";
|
||||
|
||||
export function historyStatus(run: Run): HistoryStatus {
|
||||
const status = runDisplayStatus(run);
|
||||
if (status === "done" || status === "failed" || status === "cancelled") return status;
|
||||
return "running";
|
||||
}
|
||||
|
||||
const CODE_PREFIX = /^([A-Z][0-9]{0,2}[a-z]?)(?:\s*[:–—-]\s*|\s+)(?=\S)/;
|
||||
|
||||
/** Split "D2: re-score C2 at higher n" into its code and a sentence-case label. */
|
||||
export function splitExperimentTitle(experiment: Experiment): {
|
||||
code: string | null;
|
||||
label: string;
|
||||
} {
|
||||
const raw = (experiment.title || experiment.description || experiment.slug)
|
||||
.split(/\r?\n/)[0]
|
||||
.trim();
|
||||
const match = raw.match(CODE_PREFIX);
|
||||
const rest = match ? raw.slice(match[0].length) : raw;
|
||||
return {
|
||||
code: match ? match[1] : null,
|
||||
label: rest.charAt(0).toUpperCase() + rest.slice(1),
|
||||
};
|
||||
}
|
||||
|
||||
export function byCreated(a: HistoryNode, b: HistoryNode): number {
|
||||
return a.createdAt - b.createdAt || a.id.localeCompare(b.id);
|
||||
}
|
||||
|
||||
export function buildHistoryNodes(experiments: Experiment[], runs: Run[]): HistoryNode[] {
|
||||
const runsByExperiment = new Map<string, Run[]>();
|
||||
for (const run of runs) {
|
||||
const grouped = runsByExperiment.get(run.experimentId);
|
||||
if (grouped) grouped.push(run);
|
||||
else runsByExperiment.set(run.experimentId, [run]);
|
||||
}
|
||||
const nodes = experiments.map((experiment): HistoryNode => {
|
||||
const attempts = (runsByExperiment.get(experiment.id) ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id));
|
||||
const latestRun = attempts.at(-1) ?? null;
|
||||
return {
|
||||
id: experiment.id,
|
||||
experiment,
|
||||
parent: null,
|
||||
missingParentId: null,
|
||||
children: [],
|
||||
runs: attempts,
|
||||
latestRun,
|
||||
status: latestRun ? historyStatus(latestRun) : "none",
|
||||
...splitExperimentTitle(experiment),
|
||||
task: experiment.chatSessionId ?? null,
|
||||
createdAt: experiment.createdAt,
|
||||
};
|
||||
});
|
||||
const byId = new Map(nodes.map((node) => [node.id, node]));
|
||||
for (const node of nodes) {
|
||||
const parentId = node.experiment.parentExperimentId;
|
||||
const parent = parentId ? byId.get(parentId) : undefined;
|
||||
if (parent) {
|
||||
node.parent = parent;
|
||||
parent.children.push(node);
|
||||
} else if (parentId) node.missingParentId = parentId;
|
||||
}
|
||||
for (const node of nodes) node.children.sort(byCreated);
|
||||
return nodes;
|
||||
}
|
||||
|
||||
export function ancestorsOf(node: HistoryNode): HistoryNode[] {
|
||||
const chain: HistoryNode[] = [];
|
||||
for (let current = node.parent; current; current = current.parent) chain.unshift(current);
|
||||
return chain;
|
||||
}
|
||||
|
||||
export function subtreeOf(node: HistoryNode): HistoryNode[] {
|
||||
return [node, ...node.children.flatMap(subtreeOf)];
|
||||
}
|
||||
|
||||
/** "D2", or the label cut to `max` characters when the title has no code. */
|
||||
export function shortName(node: HistoryNode, max = 22): string {
|
||||
if (node.code) return node.code;
|
||||
return node.label.length > max ? node.label.slice(0, max - 1).trimEnd() + "…" : node.label;
|
||||
}
|
||||
|
||||
/** Where an experiment came from, as the row and the detail pane say it. */
|
||||
export function originOf(node: HistoryNode, max = 22): string {
|
||||
if (node.parent) return m.history_origin_from({ name: shortName(node.parent, max) });
|
||||
return node.missingParentId ? m.history_origin_missing() : m.history_origin_start();
|
||||
}
|
||||
|
||||
/**
|
||||
* What "Diff from …" compares against: the parent, or the project's baseline
|
||||
* branch for a starting point. Null when the recorded parent is missing, since
|
||||
* the backend cannot diff against it.
|
||||
*/
|
||||
export function diffBaseOf(node: HistoryNode, baselineBranch: string): string | null {
|
||||
if (node.parent) return shortName(node.parent);
|
||||
return node.missingParentId ? null : baselineBranch;
|
||||
}
|
||||
|
||||
function rootOf(node: HistoryNode): HistoryNode {
|
||||
let root = node;
|
||||
while (root.parent) root = root.parent;
|
||||
return root;
|
||||
}
|
||||
|
||||
export function groupKeyOf(node: HistoryNode, grouping: HistoryGrouping): string {
|
||||
if (grouping === "none") return "all";
|
||||
if (grouping === "task") return node.task ?? NO_TASK;
|
||||
return rootOf(node).id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chapters, oldest first, with experiments outside any task last. Task titles
|
||||
* come from the caller; a task without one (for example a deleted chat) is
|
||||
* "Untitled task".
|
||||
*/
|
||||
export function groupHistory(
|
||||
nodes: HistoryNode[],
|
||||
grouping: HistoryGrouping,
|
||||
taskTitle: (task: string) => string | undefined,
|
||||
): HistoryGroup[] {
|
||||
if (grouping === "none") return nodes.length ? [{ key: "all", title: "", nodes: [...nodes].sort(byCreated) }] : [];
|
||||
const buckets = new Map<string, HistoryNode[]>();
|
||||
for (const node of nodes) {
|
||||
const key = groupKeyOf(node, grouping);
|
||||
const bucket = buckets.get(key);
|
||||
if (bucket) bucket.push(node);
|
||||
else buckets.set(key, [node]);
|
||||
}
|
||||
const groups = [...buckets.entries()].map(([key, members]): HistoryGroup => {
|
||||
members.sort(byCreated);
|
||||
let title: string;
|
||||
if (grouping === "task") title = key === NO_TASK ? m.history_not_in_task() : taskTitle(key) || m.history_untitled_task();
|
||||
else {
|
||||
const root = members.find((member) => member.id === key) ?? members[0];
|
||||
title = (root.code ? root.code + " · " : "") + root.label;
|
||||
}
|
||||
return { key, title, nodes: members };
|
||||
});
|
||||
return groups.sort((a, b) => {
|
||||
if (a.key === NO_TASK) return 1;
|
||||
if (b.key === NO_TASK) return -1;
|
||||
return a.nodes[0].createdAt - b.nodes[0].createdAt;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lay a chapter out in lineage order, Git-graph style.
|
||||
*
|
||||
* At each fork the side branches are listed first and the continuing child
|
||||
* last, so the parent's line runs straight past the side branches to it. The
|
||||
* continuing child is the one needing the most lanes, then the largest, then
|
||||
* the newest. A side branch takes its parent's lane + 1 and frees it when it
|
||||
* ends, so a long chain never staircases and siblings reuse one lane.
|
||||
*
|
||||
* With `collapseFinished`, a side branch of four or more experiments that all
|
||||
* finished successfully, and holds nothing in `keep`, becomes one summary row
|
||||
* unless its head is in `expanded`. An experiment whose parent sits in another
|
||||
* chapter is preceded by a lead-in row naming that parent.
|
||||
*/
|
||||
export function layoutLineage(
|
||||
group: HistoryGroup,
|
||||
options: {
|
||||
collapseFinished?: boolean;
|
||||
expanded?: ReadonlySet<string>;
|
||||
keep?: ReadonlySet<string>;
|
||||
} = {},
|
||||
): HistoryRow[] {
|
||||
const members = new Set(group.nodes.map((node) => node.id));
|
||||
const kids = (node: HistoryNode) => node.children.filter((child) => members.has(child.id));
|
||||
const sizes = new Map<string, number>();
|
||||
const lanes = new Map<string, number>();
|
||||
const size = (node: HistoryNode): number => {
|
||||
const cached = sizes.get(node.id);
|
||||
if (cached !== undefined) return cached;
|
||||
const value = 1 + kids(node).reduce((sum, child) => sum + size(child), 0);
|
||||
sizes.set(node.id, value);
|
||||
return value;
|
||||
};
|
||||
const continuing = (children: HistoryNode[]) =>
|
||||
[...children].sort(
|
||||
(a, b) => lanesNeeded(b) - lanesNeeded(a) || size(b) - size(a) || byCreated(b, a),
|
||||
)[0];
|
||||
const lanesNeeded = (node: HistoryNode): number => {
|
||||
const cached = lanes.get(node.id);
|
||||
if (cached !== undefined) return cached;
|
||||
const children = kids(node);
|
||||
let value = 1;
|
||||
if (children.length) {
|
||||
const next = continuing(children);
|
||||
const sides = children.filter((child) => child !== next);
|
||||
value = Math.max(lanesNeeded(next), 1 + Math.max(0, ...sides.map(lanesNeeded)));
|
||||
}
|
||||
lanes.set(node.id, value);
|
||||
return value;
|
||||
};
|
||||
|
||||
// A descendant reached through a non-member starts its own root, so it is not part of this branch.
|
||||
const branch = (node: HistoryNode): HistoryNode[] => [node, ...kids(node).flatMap(branch)];
|
||||
|
||||
const rows: HistoryRow[] = [];
|
||||
const visit = (node: HistoryNode, lane: number, side: boolean) => {
|
||||
if (side && options.collapseFinished && !options.expanded?.has(node.id)) {
|
||||
const hidden = branch(node);
|
||||
if (
|
||||
hidden.length >= 4 &&
|
||||
hidden.every((member) => member.status === "done") &&
|
||||
!hidden.some((member) => options.keep?.has(member.id))
|
||||
) {
|
||||
rows.push({ kind: "collapsed", head: node, lane, hidden });
|
||||
return;
|
||||
}
|
||||
}
|
||||
rows.push({ kind: "experiment", node, lane });
|
||||
const children = kids(node);
|
||||
if (!children.length) return;
|
||||
const next = continuing(children);
|
||||
for (const child of children) if (child !== next) visit(child, lane + 1, true);
|
||||
visit(next, lane, false);
|
||||
};
|
||||
|
||||
const roots = group.nodes.filter((node) => !node.parent || !members.has(node.parent.id));
|
||||
for (const root of roots.sort(byCreated)) {
|
||||
if (root.parent) rows.push({ kind: "lead-in", node: root, from: root.parent, lane: 0 });
|
||||
visit(root, 0, false);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Chronological rows; the graph is not drawn in this order. */
|
||||
export function layoutChronological(group: HistoryGroup, newestFirst = false): HistoryRow[] {
|
||||
const ordered = [...group.nodes].sort(byCreated);
|
||||
if (newestFirst) ordered.reverse();
|
||||
return ordered.map((node) => ({ kind: "experiment", node, lane: 0 }));
|
||||
}
|
||||
|
||||
export function laneCount(rows: HistoryRow[]): number {
|
||||
return rows.reduce((max, row) => Math.max(max, row.lane + 1), 0);
|
||||
}
|
||||
|
||||
/** Width of the detail pane when it sits beside the list. */
|
||||
export const PANE_W = 380;
|
||||
/** Below this width the detail pane covers the list instead of sitting beside it. */
|
||||
const OVERLAY_BELOW = 980;
|
||||
/** Below this list width the "What changed" column is dropped. */
|
||||
const TITLE_COLUMN_FROM = 760;
|
||||
/** Below this list width (the default side panel) only the experiment and its status fit. */
|
||||
const COMPACT_BELOW = 520;
|
||||
|
||||
/** What fits in a History view `width` pixels wide. */
|
||||
export function historyFit(width: number, detailOpen: boolean, lineageOrder: boolean) {
|
||||
const overlay = width < OVERLAY_BELOW;
|
||||
const listWidth = detailOpen && !overlay ? width - PANE_W : width;
|
||||
const compact = listWidth < COMPACT_BELOW;
|
||||
return {
|
||||
overlay,
|
||||
/** The open detail pane covers the list, which leaves the tab order until it closes. */
|
||||
listCovered: detailOpen && overlay,
|
||||
whatChanged: listWidth >= TITLE_COLUMN_FROM,
|
||||
/** Lineage order draws the parent on the rail instead. */
|
||||
from: !lineageOrder && !compact,
|
||||
/** The Attempts and Latest columns. */
|
||||
runs: !compact,
|
||||
/** Status counts and dates beside a chapter's title. */
|
||||
chapterSummary: !compact,
|
||||
};
|
||||
}
|
||||
|
||||
export function compactHistoryId(id: string, length = 8): string {
|
||||
return id.length > length ? id.slice(0, length) : id;
|
||||
}
|
||||
|
||||
export function historyBackendLabel(run: Run): string {
|
||||
const kind = backendKind(run.backend);
|
||||
if (!kind) return "—";
|
||||
return [kind, backendDetail(run.backend)].filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
export function historyJobId(run: Run): string {
|
||||
const jobId = run.backend?.jobId;
|
||||
return typeof jobId === "string" || typeof jobId === "number"
|
||||
? String(jobId)
|
||||
: "—";
|
||||
}
|
||||
|
||||
export function describeAttemptChange(
|
||||
run: Run,
|
||||
previousRun: Run | null,
|
||||
): string {
|
||||
if (!previousRun) return m.history_attempt_initial();
|
||||
if (!run.commitSha || !previousRun.commitSha) return m.history_attempt_no_provenance();
|
||||
if (run.commitSha === previousRun.commitSha) return m.history_attempt_retry();
|
||||
return m.history_attempt_new_commit({ sha: ltr(compactHistoryId(run.commitSha, 7)) });
|
||||
}
|
||||
@@ -22,6 +22,7 @@
|
||||
--color-accent-red: var(--accent-red);
|
||||
--color-accent-teal: var(--accent-teal);
|
||||
--color-accent-blue: var(--accent-blue);
|
||||
--color-lineage: var(--lineage);
|
||||
--color-accent-amber: var(--accent-amber);
|
||||
--color-accent-green: var(--accent-green);
|
||||
--color-accent-purple: var(--accent-purple);
|
||||
@@ -98,6 +99,7 @@
|
||||
--shadow-plan-menu: 0 6px 20px rgb(0 0 0 / 12%);
|
||||
--shadow-logo: 0 0 0 1px rgb(0 0 0 / 8%);
|
||||
--shadow-tree: 0 1px 2px rgb(0 0 0 / 4%);
|
||||
--shadow-row-selected: inset 2px 0 0 var(--primary);
|
||||
--shadow-tree-hover: 0 2px 8px rgb(0 0 0 / 8%);
|
||||
--shadow-tree-live: 0 2px 12px rgb(32 154 132 / 20%);
|
||||
--shadow-file-line: inset 2px 0 0 var(--accent-blue);
|
||||
@@ -141,6 +143,7 @@
|
||||
--accent-red: #d94654;
|
||||
--accent-teal: #209a84;
|
||||
--accent-blue: #3a8dff;
|
||||
--lineage: #527aa3;
|
||||
--accent-amber: #da9100;
|
||||
--accent-green: #5eb64c;
|
||||
--accent-purple: #9c5cff;
|
||||
@@ -234,6 +237,7 @@
|
||||
--subtext: #a3a3a3;
|
||||
--muted: #737373;
|
||||
--primary: #ffb3ad;
|
||||
--lineage: #8eafd2;
|
||||
--primary-subtle: #33191b;
|
||||
--border: #525252;
|
||||
--border-variant: #404040;
|
||||
|
||||
@@ -24,6 +24,7 @@ const mocks = {
|
||||
Button: ({ children }) => React.createElement("button", null, children),
|
||||
ButtonLink: ({ children, href }) => React.createElement("a", { href }, children),
|
||||
},
|
||||
"./experimentLineageModel": { historyBackendLabel: () => "local", historyJobId: () => "—" },
|
||||
"lucide-react": { ChartLine: () => null, ChevronDown: () => null, CircleStop: () => null },
|
||||
};
|
||||
const exports = {};
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { describe, test } from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const en = JSON.parse(readFileSync(new URL("../messages/en.json", import.meta.url), "utf8"));
|
||||
// English catalogue text with its placeholders filled, so assertions read as the UI does.
|
||||
const messages = {
|
||||
m: new Proxy({}, {
|
||||
get: (_, key) => (inputs = {}) => {
|
||||
assert.ok(Object.hasOwn(en, key), `en.json has no message ${String(key)}`);
|
||||
return en[key].replace(/\{(\w+)\}/g, (_, name) => String(inputs[name]));
|
||||
},
|
||||
}),
|
||||
};
|
||||
const runtime = { getLocale: () => "en" };
|
||||
|
||||
function load(file, mocks) {
|
||||
const source = readFileSync(new URL(`../src/${file}`, import.meta.url), "utf8");
|
||||
const code = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText;
|
||||
const exports = {};
|
||||
new Function("require", "exports", code)((name) => mocks[name] ?? require(name), exports);
|
||||
return exports;
|
||||
}
|
||||
|
||||
const i18n = load("i18n.ts", { "./paraglide/runtime.js": runtime });
|
||||
const api = load("api.ts", {
|
||||
"./queries/client": {},
|
||||
"./queries/invalidation": {},
|
||||
"./paraglide/messages.js": messages,
|
||||
"./paraglide/runtime.js": runtime,
|
||||
"./i18n": i18n,
|
||||
});
|
||||
const {
|
||||
buildHistoryNodes,
|
||||
describeAttemptChange,
|
||||
diffBaseOf,
|
||||
groupHistory,
|
||||
historyBackendLabel,
|
||||
historyFit,
|
||||
historyJobId,
|
||||
laneCount,
|
||||
layoutChronological,
|
||||
layoutLineage,
|
||||
originOf,
|
||||
splitExperimentTitle,
|
||||
} = load("components/experimentLineageModel.ts", {
|
||||
"../api": api,
|
||||
"../i18n": i18n,
|
||||
"../paraglide/messages.js": messages,
|
||||
});
|
||||
|
||||
function experiment(id, createdAt, parentExperimentId = null, chatSessionId = "session-a", title = "Change " + id) {
|
||||
return {
|
||||
id,
|
||||
projectId: "project",
|
||||
parentExperimentId,
|
||||
slug: id,
|
||||
branchName: "orx/" + id,
|
||||
title,
|
||||
description: "Detail " + id,
|
||||
runCommand: "true",
|
||||
agentStatus: "idle",
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
chatSessionId,
|
||||
};
|
||||
}
|
||||
|
||||
function run(id, experimentId, createdAt, commitSha, status = "done") {
|
||||
return {
|
||||
id,
|
||||
experimentId,
|
||||
projectId: "project",
|
||||
status,
|
||||
commitSha,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** One chapter holding every node, as the "none" grouping produces. */
|
||||
function chapter(experiments, runs = []) {
|
||||
return groupHistory(buildHistoryNodes(experiments, runs), "none", () => undefined)[0];
|
||||
}
|
||||
|
||||
/** Rows as [kind, experiment id, lane] so a whole layout reads in one assertion. */
|
||||
function shape(rows) {
|
||||
return rows.map((row) => [row.kind, row.kind === "collapsed" ? row.head.id : row.node.id, row.lane]);
|
||||
}
|
||||
|
||||
/** A value interpolated as an isolated left-to-right run, as `ltr` renders it. */
|
||||
const isolated = (value) => `\u2066${value}\u2069`;
|
||||
|
||||
const allDone = (ids) => ids.map((id, index) => run("run-" + id, id, 100 + index, "abc1234"));
|
||||
|
||||
describe("splitExperimentTitle", () => {
|
||||
test("separates a leading experiment code from a sentence-case label", () => {
|
||||
const cases = [
|
||||
["D2: re-score C2 at higher n", { code: "D2", label: "Re-score C2 at higher n" }],
|
||||
["A: 200 iterations", { code: "A", label: "200 iterations" }],
|
||||
["G3-probe: does a pool help?", { code: "G3", label: "Probe: does a pool help?" }],
|
||||
["I6 probe: a vocabulary", { code: "I6", label: "Probe: a vocabulary" }],
|
||||
["Ablation E: random routing", { code: null, label: "Ablation E: random routing" }],
|
||||
["PPO baseline (small model)", { code: null, label: "PPO baseline (small model)" }],
|
||||
];
|
||||
|
||||
const parsed = cases.map(([title]) => splitExperimentTitle(experiment("x", 1, null, null, title)));
|
||||
|
||||
assert.deepEqual(parsed, cases.map(([, expected]) => expected));
|
||||
});
|
||||
|
||||
test("falls back to the slug when there is no title or description", () => {
|
||||
const bare = { ...experiment("bare-slug", 1, null, null, null), description: null };
|
||||
|
||||
assert.deepEqual(splitExperimentTitle(bare), { code: null, label: "Bare-slug" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildHistoryNodes", () => {
|
||||
test("orders attempts oldest first and takes the status from the latest", () => {
|
||||
const nodes = buildHistoryNodes(
|
||||
[experiment("root", 1), experiment("child", 2, "root")],
|
||||
[run("late", "child", 30, "bbb", "failed"), run("early", "child", 10, "aaa", "done")],
|
||||
);
|
||||
const child = nodes.find((node) => node.id === "child");
|
||||
|
||||
assert.deepEqual(
|
||||
{
|
||||
runs: child.runs.map((attempt) => attempt.id),
|
||||
status: child.status,
|
||||
parent: child.parent?.id,
|
||||
rootStatus: nodes[0].status,
|
||||
},
|
||||
{ runs: ["early", "late"], status: "failed", parent: "root", rootStatus: "none" },
|
||||
);
|
||||
});
|
||||
|
||||
test("lays out an experiment whose parent is not in the list as a root but keeps the missing id", () => {
|
||||
const [orphan] = buildHistoryNodes([experiment("orphan", 1, "gone")], []);
|
||||
|
||||
assert.deepEqual({ parent: orphan.parent, missingParentId: orphan.missingParentId }, { parent: null, missingParentId: "gone" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("origin and diff base", () => {
|
||||
test("names the parent for a child experiment", () => {
|
||||
const [, child] = buildHistoryNodes([experiment("root", 1, null, "session-a", "A: baseline"), experiment("child", 2, "root")], []);
|
||||
|
||||
assert.deepEqual({ origin: originOf(child), base: diffBaseOf(child, "trunk") }, { origin: "from A", base: "A" });
|
||||
});
|
||||
|
||||
test("compares a starting point with the project's baseline branch", () => {
|
||||
const [root] = buildHistoryNodes([experiment("root", 1)], []);
|
||||
|
||||
assert.deepEqual({ origin: originOf(root), base: diffBaseOf(root, "trunk") }, { origin: "starting point", base: "trunk" });
|
||||
});
|
||||
|
||||
test("offers no diff base when the recorded parent is missing", () => {
|
||||
const [orphan] = buildHistoryNodes([experiment("orphan", 1, "gone")], []);
|
||||
|
||||
assert.deepEqual({ origin: originOf(orphan), base: diffBaseOf(orphan, "trunk") }, { origin: "from a missing experiment", base: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupHistory", () => {
|
||||
const nodes = buildHistoryNodes(
|
||||
[
|
||||
experiment("late-task", 5, null, "session-b"),
|
||||
experiment("loose", 1, null, null),
|
||||
experiment("early-task", 2, null, "session-a"),
|
||||
experiment("deleted-chat", 3, null, "session-gone"),
|
||||
],
|
||||
[],
|
||||
);
|
||||
const titles = new Map([
|
||||
["session-a", "Reproduce the paper"],
|
||||
["session-b", "Resume handoff work"],
|
||||
]);
|
||||
|
||||
test("makes one chapter per agent task, oldest first, with loose experiments last", () => {
|
||||
const chapters = groupHistory(nodes, "task", (task) => titles.get(task));
|
||||
|
||||
assert.deepEqual(chapters.map((group) => [group.title, group.nodes.map((node) => node.id)]), [
|
||||
["Reproduce the paper", ["early-task"]],
|
||||
["Untitled task", ["deleted-chat"]],
|
||||
["Resume handoff work", ["late-task"]],
|
||||
["Not in a task", ["loose"]],
|
||||
]);
|
||||
});
|
||||
|
||||
test("groups by lineage root and names the chapter after the root", () => {
|
||||
const tree = buildHistoryNodes(
|
||||
[experiment("r", 1, null, null, "C1: reproduce"), experiment("k", 2, "r", null), experiment("solo", 3)],
|
||||
[],
|
||||
);
|
||||
|
||||
const chapters = groupHistory(tree, "lineage", () => undefined);
|
||||
|
||||
assert.deepEqual(chapters.map((group) => [group.title, group.nodes.map((node) => node.id)]), [
|
||||
["C1 · Reproduce", ["r", "k"]],
|
||||
["Change solo", ["solo"]],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("layoutLineage", () => {
|
||||
test("lists side branches before the continuing child, which keeps its parent's lane", () => {
|
||||
const group = chapter([
|
||||
experiment("p", 1),
|
||||
experiment("side", 2, "p"),
|
||||
experiment("main", 3, "p"),
|
||||
experiment("main-2", 4, "main"),
|
||||
]);
|
||||
|
||||
assert.deepEqual(shape(layoutLineage(group)), [
|
||||
["experiment", "p", 0],
|
||||
["experiment", "side", 1],
|
||||
["experiment", "main", 0],
|
||||
["experiment", "main-2", 0],
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps a six-way fork of leaves to two lanes", () => {
|
||||
const leaves = ["a", "b", "c", "d", "e", "f"].map((id, index) => experiment(id, 2 + index, "p"));
|
||||
|
||||
const rows = layoutLineage(chapter([experiment("p", 1), ...leaves]));
|
||||
|
||||
assert.equal(laneCount(rows), 2);
|
||||
});
|
||||
|
||||
test("continues through the child that forks, so a plain chain moves aside instead of a third lane", () => {
|
||||
const group = chapter([
|
||||
experiment("p", 1),
|
||||
experiment("forks", 2, "p"),
|
||||
experiment("forks-a", 3, "forks"),
|
||||
experiment("forks-b", 4, "forks"),
|
||||
experiment("chain", 5, "p"),
|
||||
experiment("chain-2", 6, "chain"),
|
||||
experiment("chain-3", 7, "chain-2"),
|
||||
]);
|
||||
|
||||
assert.deepEqual(shape(layoutLineage(group)), [
|
||||
["experiment", "p", 0],
|
||||
["experiment", "chain", 1],
|
||||
["experiment", "chain-2", 1],
|
||||
["experiment", "chain-3", 1],
|
||||
["experiment", "forks", 0],
|
||||
["experiment", "forks-a", 1],
|
||||
["experiment", "forks-b", 0],
|
||||
]);
|
||||
});
|
||||
|
||||
test("opens a third lane only when both branches of a fork fork again", () => {
|
||||
const group = chapter([
|
||||
experiment("p", 1),
|
||||
experiment("x", 2, "p"),
|
||||
experiment("xa", 3, "x"),
|
||||
experiment("xb", 4, "x"),
|
||||
experiment("y", 5, "p"),
|
||||
experiment("ya", 6, "y"),
|
||||
experiment("yb", 7, "y"),
|
||||
]);
|
||||
|
||||
assert.deepEqual(shape(layoutLineage(group)), [
|
||||
["experiment", "p", 0],
|
||||
["experiment", "x", 1],
|
||||
["experiment", "xa", 2],
|
||||
["experiment", "xb", 1],
|
||||
["experiment", "y", 0],
|
||||
["experiment", "ya", 1],
|
||||
["experiment", "yb", 0],
|
||||
]);
|
||||
});
|
||||
|
||||
test("precedes an experiment whose parent is in another chapter with a lead-in", () => {
|
||||
const nodes = buildHistoryNodes([experiment("base", 1, null, "a"), experiment("next", 2, "base", "b")], []);
|
||||
const [, second] = groupHistory(nodes, "task", () => undefined);
|
||||
|
||||
const rows = layoutLineage(second);
|
||||
|
||||
assert.deepEqual(rows.map((row) => (row.kind === "lead-in" ? ["lead-in", row.from.id] : [row.kind, row.lane])), [
|
||||
["lead-in", "base"],
|
||||
["experiment", 0],
|
||||
]);
|
||||
});
|
||||
|
||||
describe("collapsing finished side branches", () => {
|
||||
const ids = ["side", "side-2", "side-3", "side-4"];
|
||||
const tree = [
|
||||
experiment("p", 1),
|
||||
experiment("side", 2, "p"),
|
||||
experiment("side-2", 3, "side"),
|
||||
experiment("side-3", 4, "side-2"),
|
||||
experiment("side-4", 5, "side-3"),
|
||||
experiment("main", 6, "p"),
|
||||
experiment("main-2", 7, "main"),
|
||||
experiment("main-3", 8, "main-2"),
|
||||
experiment("main-4", 9, "main-3"),
|
||||
experiment("main-5", 10, "main-4"),
|
||||
];
|
||||
|
||||
test("folds a branch of four finished experiments into one row", () => {
|
||||
const rows = layoutLineage(chapter(tree, allDone(ids)), { collapseFinished: true });
|
||||
|
||||
assert.deepEqual(shape(rows).slice(0, 3), [
|
||||
["experiment", "p", 0],
|
||||
["collapsed", "side", 1],
|
||||
["experiment", "main", 0],
|
||||
]);
|
||||
});
|
||||
|
||||
test("leaves a branch open while any of it has not finished successfully", () => {
|
||||
const runs = [...allDone(ids.slice(0, 3)), run("fail", "side-4", 200, "abc", "failed")];
|
||||
|
||||
const rows = layoutLineage(chapter(tree, runs), { collapseFinished: true });
|
||||
|
||||
assert.equal(rows.some((row) => row.kind === "collapsed"), false);
|
||||
});
|
||||
|
||||
test("leaves a branch open when it holds the selection or was expanded", () => {
|
||||
const group = chapter(tree, allDone(ids));
|
||||
|
||||
const kept = layoutLineage(group, { collapseFinished: true, keep: new Set(["side-3"]) });
|
||||
const expanded = layoutLineage(group, { collapseFinished: true, expanded: new Set(["side"]) });
|
||||
|
||||
assert.deepEqual([kept, expanded].map((rows) => rows.some((row) => row.kind === "collapsed")), [false, false]);
|
||||
});
|
||||
|
||||
test("never folds a branch shorter than four experiments", () => {
|
||||
const short = tree.filter((node) => node.id !== "side-4");
|
||||
|
||||
const rows = layoutLineage(chapter(short, allDone(ids.slice(0, 3))), { collapseFinished: true });
|
||||
|
||||
assert.equal(rows.some((row) => row.kind === "collapsed"), false);
|
||||
});
|
||||
|
||||
test("leaves out of the fold a later experiment reached through another chapter, which starts its own root", () => {
|
||||
const handedOff = [...tree, experiment("handoff", 11, "side-4", "session-b"), experiment("resumed", 12, "handoff")];
|
||||
const nodes = buildHistoryNodes(handedOff, allDone([...ids, "handoff", "resumed"]));
|
||||
const [own] = groupHistory(nodes, "task", () => undefined);
|
||||
|
||||
const rows = layoutLineage(own, { collapseFinished: true });
|
||||
|
||||
assert.deepEqual(rows.map((row) => (row.kind === "collapsed" ? ["collapsed", row.hidden.map((node) => node.id)] : [row.kind, row.node.id])), [
|
||||
["experiment", "p"],
|
||||
["collapsed", ["side", "side-2", "side-3", "side-4"]],
|
||||
["experiment", "main"],
|
||||
["experiment", "main-2"],
|
||||
["experiment", "main-3"],
|
||||
["experiment", "main-4"],
|
||||
["experiment", "main-5"],
|
||||
["lead-in", "resumed"],
|
||||
["experiment", "resumed"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("historyFit", () => {
|
||||
test("sets the open detail pane beside a wide list, which keeps its focus", () => {
|
||||
assert.deepEqual(historyFit(1200, true, false), {
|
||||
overlay: false,
|
||||
listCovered: false,
|
||||
whatChanged: true,
|
||||
from: true,
|
||||
runs: true,
|
||||
chapterSummary: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("covers a narrow list with the open detail pane and takes the list out of the focus order", () => {
|
||||
assert.deepEqual([historyFit(900, true, false), historyFit(900, false, false)].map(({ overlay, listCovered }) => ({ overlay, listCovered })), [
|
||||
{ overlay: true, listCovered: true },
|
||||
{ overlay: true, listCovered: false },
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps only the experiment and its status in a compact list, in any order", () => {
|
||||
const compact = { overlay: true, listCovered: false, whatChanged: false, from: false, runs: false, chapterSummary: false };
|
||||
|
||||
assert.deepEqual([historyFit(400, false, false), historyFit(400, false, true)], [compact, compact]);
|
||||
});
|
||||
|
||||
test("names the parent in a From column only outside lineage order", () => {
|
||||
assert.deepEqual([historyFit(600, false, false).from, historyFit(600, false, true).from], [true, false]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("layoutChronological", () => {
|
||||
test("lists experiments by creation time, newest first on request", () => {
|
||||
const group = chapter([experiment("b", 2), experiment("a", 1, "b"), experiment("c", 3)]);
|
||||
|
||||
assert.deepEqual([layoutChronological(group), layoutChronological(group, true)].map(shape), [
|
||||
[["experiment", "a", 0], ["experiment", "b", 0], ["experiment", "c", 0]],
|
||||
[["experiment", "c", 0], ["experiment", "b", 0], ["experiment", "a", 0]],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("run attempt metadata", () => {
|
||||
test("distinguishes initial attempts, retries and new code", () => {
|
||||
const first = run("first", "baseline", 1, "aaaaaaa");
|
||||
const retry = run("retry", "baseline", 2, "aaaaaaa");
|
||||
const changed = run("changed", "baseline", 3, "bbbbbbb");
|
||||
|
||||
assert.equal(describeAttemptChange(first, null), "Initial attempt");
|
||||
assert.equal(describeAttemptChange(retry, first), "Same commit · retry");
|
||||
assert.equal(describeAttemptChange(changed, retry), `New commit · ${isolated("bbbbbbb")}`);
|
||||
});
|
||||
|
||||
test("does not invent a comparison when either commit is missing", () => {
|
||||
const unknown = run("unknown", "baseline", 1, null);
|
||||
const known = run("known", "baseline", 2, "bbbbbbb");
|
||||
|
||||
assert.equal(describeAttemptChange(known, unknown), "Code provenance unavailable");
|
||||
assert.equal(describeAttemptChange(unknown, known), "Code provenance unavailable");
|
||||
});
|
||||
|
||||
test("renders backend detail and scheduler job id without inventing fields", () => {
|
||||
const attempt = {
|
||||
...run("slurm", "baseline", 1, "aaaaaaa"),
|
||||
backend: {
|
||||
kind: "slurm_job",
|
||||
namespace: "research-cluster",
|
||||
flavor: "h100:1",
|
||||
jobId: 4336055,
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(historyBackendLabel(attempt), "slurm_job · h100:1");
|
||||
assert.equal(historyJobId(attempt), "4336055");
|
||||
assert.equal(historyJobId(run("local", "baseline", 2, null)), "—");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import ts from "typescript";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const en = JSON.parse(readFileSync(new URL("../messages/en.json", import.meta.url), "utf8"));
|
||||
const messages = { m: new Proxy({}, { get: (_, key) => (values = {}) => en[key].replace(/\{(\w+)\}/g, (_, name) => String(values[name])) }) };
|
||||
const api = { fmtNumber: String, timeAgo: () => "1d ago", runDisplayStatus: (run) => run.status };
|
||||
const runtime = { getLocale: () => "en" };
|
||||
|
||||
function load(file, mocks) {
|
||||
const source = readFileSync(new URL(`../src/components/${file}`, import.meta.url), "utf8");
|
||||
const code = ts.transpileModule(source, {
|
||||
fileName: file,
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX },
|
||||
}).outputText;
|
||||
const exports = {};
|
||||
new Function("require", "exports", code)((name) => mocks[name] ?? require(name), exports);
|
||||
return exports;
|
||||
}
|
||||
|
||||
const model = load("experimentLineageModel.ts", {
|
||||
"../api": api,
|
||||
"../i18n": { ltr: (text) => text },
|
||||
"../paraglide/messages.js": messages,
|
||||
});
|
||||
const tabPreview = load("../tabPreview.ts", {});
|
||||
|
||||
function lineage(expanded) {
|
||||
return load("ExperimentLineage.tsx", {
|
||||
react: { ...React, useState: () => [expanded, () => {}], useEffect: () => {} },
|
||||
"lucide-react": { ChevronRight: () => null },
|
||||
"../api": api,
|
||||
"../paraglide/messages.js": messages,
|
||||
"../paraglide/runtime.js": runtime,
|
||||
"./experimentLineageModel": model,
|
||||
"../tabPreview": tabPreview,
|
||||
});
|
||||
}
|
||||
|
||||
const experiments = Array.from({ length: 8 }, (_, index) => ({
|
||||
id: String(index),
|
||||
title: `Experiment ${index}`,
|
||||
slug: `experiment-${index}`,
|
||||
createdAt: index,
|
||||
parentExperimentId: index ? String(index - 1) : null,
|
||||
}));
|
||||
const nodes = model.buildHistoryNodes(experiments, []);
|
||||
|
||||
test("lineage shows four ancestors and the current experiment before folding earlier ancestors", () => {
|
||||
const { ExperimentLineage } = lineage(false);
|
||||
const html = renderToStaticMarkup(React.createElement(ExperimentLineage, { node: nodes[6], onSelect: () => {} }));
|
||||
assert.match(html, /aria-expanded="false"/);
|
||||
assert.match(html, /2 earlier:/);
|
||||
for (let index = 2; index <= 6; index++) assert.match(html, new RegExp(`>Experiment ${index}<`));
|
||||
assert.doesNotMatch(html, />Experiment 0</);
|
||||
assert.match(html, /Children/);
|
||||
assert.match(html, />Experiment 7</);
|
||||
assert.match(html, /aria-current="true"/);
|
||||
});
|
||||
|
||||
test("expanded lineage retains a collapse control and shows every ancestor", () => {
|
||||
const { ExperimentLineage } = lineage(true);
|
||||
const html = renderToStaticMarkup(React.createElement(ExperimentLineage, { node: nodes[6], onSelect: () => {} }));
|
||||
assert.match(html, /aria-expanded="true"/);
|
||||
assert.match(html, /Show less/);
|
||||
for (let index = 0; index <= 6; index++) assert.match(html, new RegExp(`>Experiment ${index}<`));
|
||||
});
|
||||
|
||||
test("short lineage has no unnecessary expand control", () => {
|
||||
const { ExperimentLineage } = lineage(false);
|
||||
const html = renderToStaticMarkup(React.createElement(ExperimentLineage, { node: nodes[4], onSelect: () => {} }));
|
||||
assert.doesNotMatch(html, /aria-expanded/);
|
||||
for (let index = 0; index <= 4; index++) assert.match(html, new RegExp(`>Experiment ${index}<`));
|
||||
});
|
||||
|
||||
test("ancestor and child rows preserve preview and keep-open gestures", () => {
|
||||
const { ExperimentLineage } = lineage(false);
|
||||
const opened = [];
|
||||
const tree = ExperimentLineage({ node: nodes[6], onSelect: (...args) => opened.push(args) });
|
||||
const buttons = [];
|
||||
function visit(element) {
|
||||
if (Array.isArray(element)) return element.forEach(visit);
|
||||
if (!React.isValidElement(element)) return;
|
||||
if (typeof element.type === "function") return visit(element.type(element.props));
|
||||
if (element.type === "button") buttons.push(element.props);
|
||||
visit(element.props.children);
|
||||
}
|
||||
visit(tree);
|
||||
for (const id of ["5", "7"]) {
|
||||
const row = buttons.find((props) => props.children?.[2]?.props?.children === `Experiment ${id}`);
|
||||
assert.ok(row, `missing lineage row ${id}`);
|
||||
row.onClick({});
|
||||
row.onDoubleClick({});
|
||||
row.onAuxClick({ button: 1, preventDefault() {} });
|
||||
row.onKeyDown({ key: "Enter", preventDefault() {} });
|
||||
row.onKeyDown({ key: " ", preventDefault() {} });
|
||||
assert.deepEqual(opened.splice(0), [
|
||||
[id, "preview"], [id, "keepOpen"], [id, "keepOpen"], [id, "keepOpen"], [id, "preview"],
|
||||
]);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user