Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion ui/messages/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -1456,5 +1456,10 @@
"skills_detection_accepted": "تم قبول {count}",
"desktop_zoom_controls": "عناصر التحكم في التكبير",
"desktop_zoom_reset": "إعادة تعيين",
"reproduce_paper_prompt": "أعد إنتاج نتائج الورقة البحثية «{title}» ({source})."
"reproduce_paper_prompt": "أعد إنتاج نتائج الورقة البحثية «{title}» ({source}).",
"app_routes": "المسارات",
"routes_archived_ancestor": "تجربة سابقة مؤرشفة",
"routes_other_task": "من مهمة أخرى",
"routes_missing_parent": "التجربة الأصلية غير متاحة",
"routes_invalid_lineage": "علاقة تجارب غير صالحة"
}
7 changes: 6 additions & 1 deletion ui/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1456,5 +1456,10 @@
"skills_detection_accepted": "{count} accepted",
"desktop_zoom_controls": "Zoom controls",
"desktop_zoom_reset": "Reset",
"reproduce_paper_prompt": "Reproduce the paper “{title}” ({source})."
"reproduce_paper_prompt": "Reproduce the paper “{title}” ({source}).",
"app_routes": "Routes",
"routes_archived_ancestor": "Archived ancestor",
"routes_other_task": "From another task",
"routes_missing_parent": "Parent unavailable",
"routes_invalid_lineage": "Invalid lineage"
}
7 changes: 6 additions & 1 deletion ui/messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -1456,5 +1456,10 @@
"skills_detection_accepted": "{count} aceptadas",
"desktop_zoom_controls": "Controles de zoom",
"desktop_zoom_reset": "Restablecer",
"reproduce_paper_prompt": "Reproduce el artículo «{title}» ({source})."
"reproduce_paper_prompt": "Reproduce el artículo «{title}» ({source}).",
"app_routes": "Rutas",
"routes_archived_ancestor": "Antecesor archivado",
"routes_other_task": "De otra tarea",
"routes_missing_parent": "Experimento padre no disponible",
"routes_invalid_lineage": "Linaje no válido"
}
7 changes: 6 additions & 1 deletion ui/messages/fa.json
Original file line number Diff line number Diff line change
Expand Up @@ -1456,5 +1456,10 @@
"skills_detection_accepted": "{count} پذیرفته‌شده",
"desktop_zoom_controls": "کنترل‌های بزرگ‌نمایی",
"desktop_zoom_reset": "بازنشانی",
"reproduce_paper_prompt": "مقاله «{title}» ({source}) را بازتولید کن."
"reproduce_paper_prompt": "مقاله «{title}» ({source}) را بازتولید کن.",
"app_routes": "مسیرها",
"routes_archived_ancestor": "آزمایش پیشین بایگانی‌شده",
"routes_other_task": "از وظیفه‌ای دیگر",
"routes_missing_parent": "آزمایش والد در دسترس نیست",
"routes_invalid_lineage": "رابطهٔ آزمایش نامعتبر"
}
7 changes: 6 additions & 1 deletion ui/messages/hi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1456,5 +1456,10 @@
"skills_detection_accepted": "{count} स्वीकृत",
"desktop_zoom_controls": "ज़ूम नियंत्रण",
"desktop_zoom_reset": "रीसेट",
"reproduce_paper_prompt": "शोध-पत्र “{title}” ({source}) को पुनरुत्पादित करें।"
"reproduce_paper_prompt": "शोध-पत्र “{title}” ({source}) को पुनरुत्पादित करें।",
"app_routes": "मार्ग",
"routes_archived_ancestor": "संग्रहीत पूर्वज",
"routes_other_task": "दूसरे कार्य से",
"routes_missing_parent": "मूल प्रयोग उपलब्ध नहीं",
"routes_invalid_lineage": "अमान्य प्रयोग संबंध"
}
7 changes: 6 additions & 1 deletion ui/messages/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -1456,5 +1456,10 @@
"skills_detection_accepted": "{count} 件を採用",
"desktop_zoom_controls": "ズーム操作",
"desktop_zoom_reset": "リセット",
"reproduce_paper_prompt": "論文「{title}」({source}) を再現してください。"
"reproduce_paper_prompt": "論文「{title}」({source}) を再現してください。",
"app_routes": "ルート",
"routes_archived_ancestor": "アーカイブ済みの祖先",
"routes_other_task": "別のタスクから",
"routes_missing_parent": "親実験が見つかりません",
"routes_invalid_lineage": "無効な実験関係"
}
7 changes: 6 additions & 1 deletion ui/messages/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -1456,5 +1456,10 @@
"skills_detection_accepted": "{count}개 수락됨",
"desktop_zoom_controls": "확대/축소 컨트롤",
"desktop_zoom_reset": "초기화",
"reproduce_paper_prompt": "논문 “{title}”({source})의 결과를 재현해 주세요."
"reproduce_paper_prompt": "논문 “{title}”({source})의 결과를 재현해 주세요.",
"app_routes": "경로",
"routes_archived_ancestor": "보관된 상위 실험",
"routes_other_task": "다른 작업에서 생성됨",
"routes_missing_parent": "상위 실험을 찾을 수 없음",
"routes_invalid_lineage": "잘못된 실험 관계"
}
7 changes: 6 additions & 1 deletion ui/messages/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -1456,5 +1456,10 @@
"skills_detection_accepted": "Zaakceptowano: {count}",
"desktop_zoom_controls": "Sterowanie powiększeniem",
"desktop_zoom_reset": "Resetuj",
"reproduce_paper_prompt": "Odtwórz wyniki artykułu „{title}” ({source})."
"reproduce_paper_prompt": "Odtwórz wyniki artykułu „{title}” ({source}).",
"app_routes": "Ścieżki",
"routes_archived_ancestor": "Zarchiwizowany eksperyment nadrzędny",
"routes_other_task": "Z innego zadania",
"routes_missing_parent": "Eksperyment nadrzędny niedostępny",
"routes_invalid_lineage": "Nieprawidłowe powiązania"
}
7 changes: 6 additions & 1 deletion ui/messages/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -1456,5 +1456,10 @@
"skills_detection_accepted": "已接受 {count} 项",
"desktop_zoom_controls": "缩放控件",
"desktop_zoom_reset": "重置",
"reproduce_paper_prompt": "复现论文《{title}》({source})。"
"reproduce_paper_prompt": "复现论文《{title}》({source})。",
"app_routes": "路线",
"routes_archived_ancestor": "已归档的祖先",
"routes_other_task": "来自其他任务",
"routes_missing_parent": "父实验不可用",
"routes_invalid_lineage": "实验关系异常"
}
42 changes: 31 additions & 11 deletions ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ import { Md } from "./components/Md";
import { SettingsView, type SettingsTab } from "./components/SettingsPage";
import { DemoWelcomeModal } from "./components/Tour";
import { TreeView } from "./components/TreeView";
import { ExperimentRoutes } from "./components/ExperimentRoutes";
import { onChatEvent, useOrxEvents } from "./events";
import { closeTab, openTab, type TabOpenIntent } from "./tabPreview";
import { Button, IconButton, MenuItem, SEGMENTED_CLASS_NAME, showAlert, Spinner, Tooltip } from "./components/ui";
Expand Down Expand Up @@ -382,6 +383,9 @@ export default function App({ runtime, projectId, pane }: { runtime: RuntimeInfo
const artifacts = artifactsQuery.data ?? null;

const [view, setView] = useState<ExperimentsView>("table");
// Routes uses the same home pane; its browser-only mode leaves the existing
// server workspace schema and the saved table/canvas preference intact.
const [routesView, setRoutesView] = useState(false);
const [showTableLineage, setShowTableLineage] = useState(false);
const [tableOrder, setTableOrder] = useState<HistoryOrder>("lineage");
const [showArchivedExperiments, setShowArchivedExperiments] = useState(false);
Expand Down Expand Up @@ -1891,7 +1895,7 @@ export default function App({ runtime, projectId, pane }: { runtime: RuntimeInfo
runs={runs}
onOpenExperiment={(id, runId) => openExperimentTab(id, "overview", "preview", runId)}
rightOffset={panelOpen && !stackPanel ? visiblePanelWidth + 14 : undefined}
activeView={panelOpen && (rightTab === "files" || rightTab === "artifacts" || rightTab === "experiments" || rightTab === "terminal") ? rightTab : null}
activeView={panelOpen && (rightTab === "files" || rightTab === "artifacts" || rightTab === "experiments" || rightTab === "terminal") ? rightTab === "experiments" && routesView ? "routes" : rightTab : null}
projectId={activeProject.id}
onCompute={() => selectMainView("compute")}
sessionId={activeSessionId}
Expand All @@ -1900,7 +1904,8 @@ export default function App({ runtime, projectId, pane }: { runtime: RuntimeInfo
onFiles={() => { setFilesView("files"); openWorktreeTab(); }}
onTerminal={openTerminalTab}
onArtifacts={openArtifactsTab}
onExperiments={() => openExperimentsTab()}
onExperiments={() => { setRoutesView(false); openExperimentsTab(); }}
onRoutes={() => { setRoutesView(true); openExperimentsTab(); }}
onSideChat={activeSessionId ? () => void startSideChat(activeSessionId, "") : undefined}
sessions={sessionsQuery.data ?? []}
onSelectSideChat={focusSideTab}
Expand Down Expand Up @@ -2005,7 +2010,7 @@ export default function App({ runtime, projectId, pane }: { runtime: RuntimeInfo
<TabBody>
<div className="pane-toolbar flex shrink-0 flex-wrap items-center gap-2 bg-background px-3 pt-2.5 pb-2">
<div className="experiments-toolbar-controls flex w-full items-center gap-1">
{view === "table" && (
{!routesView && view === "table" && (
<Tooltip interactive content={m.history_order()}>
<OptionPicker
triggerIcon={<ArrowDownWideNarrow size={16} />}
Expand All @@ -2026,7 +2031,7 @@ export default function App({ runtime, projectId, pane }: { runtime: RuntimeInfo
/>
</Tooltip>
)}
{view === "table" && (
{!routesView && view === "table" && (
<Tooltip interactive content={m.history_show_lineage()}>
<IconButton
size="small"
Expand Down Expand Up @@ -2104,24 +2109,39 @@ export default function App({ runtime, projectId, pane }: { runtime: RuntimeInfo
aria-label={m.app_experiment_view()}
>
<button
className={view === "table" ? "active" : ""}
aria-pressed={view === "table"}
onClick={() => setView("table")}
className={!routesView && view === "table" ? "active" : ""}
aria-pressed={!routesView && view === "table"}
onClick={() => { setRoutesView(false); setView("table"); }}
>
{m.app_table()}
</button>
<button
className={view === "tree" ? "active" : ""}
aria-pressed={view === "tree"}
onClick={() => setView("tree")}
className={!routesView && view === "tree" ? "active" : ""}
aria-pressed={!routesView && view === "tree"}
onClick={() => { setRoutesView(false); setView("tree"); }}
>
{m.app_tree()}
</button>
<button className={routesView ? "active" : ""} aria-pressed={routesView} onClick={() => setRoutesView(true)}>
{m.app_routes()}
</button>
</div>
</div>
</div>
<div className="pane-content flex-1 min-h-0 relative bg-background">
{view === "tree" ? (
{routesView ? (
<ExperimentRoutes
key={`${projectId}:${effectiveScope === "agent" ? activeSessionId : "project"}`}
storageKey={`orx:routes:${projectId}:${effectiveScope === "agent" ? activeSessionId : "project"}`}
experiments={experiments}
runs={runs}
sessionId={effectiveScope === "agent" ? activeSessionId : null}
showArchived={showArchivedExperiments}
emptyHint={!showArchivedExperiments && scopedExperiments.length > 0 && visibleScopedExperiments.length === 0
? m.tree_all_experiments_archived() : effectiveScope === "agent" && experiments.length > 0 ? m.app_no_task_experiments() : undefined}
onOpen={(id, intent, runId) => openExperimentTab(id, "overview", intent, runId)}
/>
) : view === "tree" ? (
activeProject && (
<TreeView
experiments={experiments}
Expand Down
125 changes: 125 additions & 0 deletions ui/src/components/ExperimentRoutes.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { ChevronDown, ChevronRight, GitFork } from "lucide-react";
import { runDisplayStatus, type Experiment, type Run } from "../api";
import { buildRoutes, parseRouteFolds, routeRows, routeRun, visibleRouteId } from "../experimentRoutes";
import { m } from "../paraglide/messages.js";
import { tabOpenGestureHandlers, type TabOpenIntent } from "../tabPreview";
import { StatusBadge } from "./StatusBadge";
import { WorkspaceEmptyState } from "./WorkspaceEmptyState";

export function ExperimentRoutes({ experiments, runs, sessionId, showArchived, storageKey, emptyHint, onOpen }: {
experiments: Experiment[];
runs: Run[];
sessionId: string | null;
showArchived: boolean;
storageKey: string;
emptyHint?: string;
onOpen: (id: string, intent: TabOpenIntent, runId?: string) => void;
}) {
const [collapsed, setCollapsed] = useState(() => {
try { return parseRouteFolds(JSON.parse(localStorage.getItem(storageKey) ?? "null")); }
catch { return new Set<string>(); }
});
const [selectedId, setSelectedId] = useState<string | null>(() => {
try { return localStorage.getItem(`${storageKey}:selection`); } catch { return null; }
});
const [focusedId, setFocusedId] = useState<string | null>(selectedId);
const treeRef = useRef<HTMLDivElement>(null);
const rowRefs = useRef(new Map<string, HTMLDivElement>());
const routes = useMemo(() => buildRoutes(experiments, sessionId, showArchived), [experiments, sessionId, showArchived]);
const rows = useMemo(() => routeRows(routes, collapsed), [routes, collapsed]);
const focusId = visibleRouteId(focusedId, routes, rows);
const selection = selectedId && routes.nodes.has(selectedId) ? visibleRouteId(selectedId, routes, rows) : null;
const runsByExperiment = useMemo(() => {
const grouped = new Map<string, Run[]>();
for (const run of runs) {
const group = grouped.get(run.experimentId) ?? [];
group.push(run);
grouped.set(run.experimentId, group);
}
return grouped;
}, [runs]);
useEffect(() => {
try { localStorage.setItem(storageKey, JSON.stringify([...collapsed])); } catch { /* Browser storage may be unavailable. */ }
}, [storageKey, collapsed]);
useEffect(() => {
if (focusedId !== focusId && (treeRef.current?.contains(document.activeElement) || document.activeElement === document.body)) rowRefs.current.get(focusId ?? "")?.focus();
}, [focusedId, focusId]);
const focus = (id: string) => {
setFocusedId(id);
rowRefs.current.get(id)?.focus();
};
const toggle = (id: string) => {
setCollapsed((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
focus(id);
};
if (!rows.length) return <WorkspaceEmptyState icon={GitFork} title={emptyHint ?? m.experiments_none_yet()} description={emptyHint ? undefined : m.experiments_empty_description()} />;
return (
<div className="absolute inset-0 overflow-auto bg-background p-2">
<div ref={treeRef} role="tree" aria-label={m.app_routes()} className="text-sm text-text" style={{ minWidth: 260 + rows.reduce((max, row) => Math.max(max, row.depth), 0) * 16 }}>
{rows.map(({ node, depth, position, size, continuation }, index) => {
const experiment = node.experiment;
const id = experiment.id;
const title = experiment.title || experiment.slug;
const run = routeRun(runsByExperiment.get(id) ?? []);
const status = run ? runDisplayStatus(run) : experiment.agentStatus === "editing" ? "editing" : "idle";
const folded = collapsed.has(id);
const gestures = tabOpenGestureHandlers<HTMLDivElement>((intent) => {
setSelectedId(id);
try { localStorage.setItem(`${storageKey}:selection`, id); } catch { /* Browser storage may be unavailable. */ }
onOpen(id, intent, run?.id);
});
const notes = [
node.context && (experiment.archived && !showArchived ? m.routes_archived_ancestor() : m.routes_other_task()),
node.boundary === "missing" && m.routes_missing_parent(),
node.boundary === "cycle" && m.routes_invalid_lineage(),
].filter(Boolean).join(" · ");
return (
<div key={id} ref={(element) => { if (element) rowRefs.current.set(id, element); else rowRefs.current.delete(id); }}
role="treeitem" aria-level={depth + 1} aria-posinset={position} aria-setsize={size}
aria-expanded={node.children.length ? !folded : undefined} aria-selected={selection === id}
tabIndex={focusId === id ? 0 : -1} title={[title, notes].filter(Boolean).join(" · ")}
className={`route-row flex h-10 items-center rounded-md pe-2 hover:bg-surface focus-visible:outline-2 focus-visible:outline-primary focus-visible:-outline-offset-2 ${selection === id ? "bg-panel" : ""}`}
{...gestures} onFocus={() => setFocusedId(id)}
onKeyDown={(event) => {
let next: string | undefined;
if (event.key === "ArrowDown") next = rows[Math.min(index + 1, rows.length - 1)].node.experiment.id;
else if (event.key === "ArrowUp") next = rows[Math.max(index - 1, 0)].node.experiment.id;
else if (event.key === "Home") next = rows[0].node.experiment.id;
else if (event.key === "End") next = rows[rows.length - 1].node.experiment.id;
else if (event.key === "ArrowRight") {
if (node.children.length && folded) toggle(id); else next = node.children[0];
} else if (event.key === "ArrowLeft") {
if (node.children.length && !folded) toggle(id); else next = node.parentId ?? undefined;
} else { gestures.onKeyDown(event); return; }
event.preventDefault();
if (next) focus(next);
}}>
<span aria-hidden="true" className="flex h-full shrink-0">
{continuation.map((continues, level) => <span key={level} className="relative w-4">
{level === depth - 1 ? <><span className={`absolute start-2 top-0 border-s border-border ${position < size ? "h-full" : "h-1/2"}`} /><span className="absolute start-2 top-1/2 w-2 border-t border-border" /></>
: continues && <span className="absolute start-2 top-0 h-full border-s border-border" />}
</span>)}
</span>
<button type="button" tabIndex={-1} disabled={!node.children.length}
className="flex size-6 shrink-0 items-center justify-center rounded text-subtext hover:bg-highlight disabled:invisible"
aria-label={folded ? m.a11y_expand_item({ name: title }) : m.a11y_collapse_item({ name: title })}
onClick={(event) => { event.stopPropagation(); toggle(id); }} onDoubleClick={(event) => event.stopPropagation()} onAuxClick={(event) => event.stopPropagation()}>
{folded ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
</button>
<span className="flex min-w-0 flex-1 flex-col pe-3">
<span className={`truncate ${node.context ? "text-subtext" : "text-text"}`}>{title}</span>
{notes && <span className="truncate text-xs text-muted">{notes}</span>}
</span>
<StatusBadge status={status} className="shrink-0" />
</div>
);
})}
</div>
</div>
);
}
Loading
Loading