diff --git a/ui/messages/ar.json b/ui/messages/ar.json index 99f3edacc..f7d2a7e21 100644 --- a/ui/messages/ar.json +++ b/ui/messages/ar.json @@ -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": "علاقة تجارب غير صالحة" } diff --git a/ui/messages/en.json b/ui/messages/en.json index 13ad8573a..72fa5850b 100644 --- a/ui/messages/en.json +++ b/ui/messages/en.json @@ -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" } diff --git a/ui/messages/es.json b/ui/messages/es.json index 0fc653fa2..753d17d1b 100644 --- a/ui/messages/es.json +++ b/ui/messages/es.json @@ -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" } diff --git a/ui/messages/fa.json b/ui/messages/fa.json index d291e692b..6a19ff32f 100644 --- a/ui/messages/fa.json +++ b/ui/messages/fa.json @@ -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": "رابطهٔ آزمایش نامعتبر" } diff --git a/ui/messages/hi.json b/ui/messages/hi.json index 695a52acf..2e3519fc0 100644 --- a/ui/messages/hi.json +++ b/ui/messages/hi.json @@ -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": "अमान्य प्रयोग संबंध" } diff --git a/ui/messages/ja.json b/ui/messages/ja.json index 9571b9fd4..08be985fc 100644 --- a/ui/messages/ja.json +++ b/ui/messages/ja.json @@ -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": "無効な実験関係" } diff --git a/ui/messages/ko.json b/ui/messages/ko.json index 908eb81c8..57e97684b 100644 --- a/ui/messages/ko.json +++ b/ui/messages/ko.json @@ -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": "잘못된 실험 관계" } diff --git a/ui/messages/pl.json b/ui/messages/pl.json index 40f9ec573..562e237a7 100644 --- a/ui/messages/pl.json +++ b/ui/messages/pl.json @@ -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" } diff --git a/ui/messages/zh-CN.json b/ui/messages/zh-CN.json index abf660f25..4e80a48b9 100644 --- a/ui/messages/zh-CN.json +++ b/ui/messages/zh-CN.json @@ -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": "实验关系异常" } diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 5fa1c8491..75b0319bc 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -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"; @@ -382,6 +383,9 @@ export default function App({ runtime, projectId, pane }: { runtime: RuntimeInfo const artifacts = artifactsQuery.data ?? null; const [view, setView] = useState("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("lineage"); const [showArchivedExperiments, setShowArchivedExperiments] = useState(false); @@ -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} @@ -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} @@ -2005,7 +2010,7 @@ export default function App({ runtime, projectId, pane }: { runtime: RuntimeInfo
- {view === "table" && ( + {!routesView && view === "table" && ( } @@ -2026,7 +2031,7 @@ export default function App({ runtime, projectId, pane }: { runtime: RuntimeInfo /> )} - {view === "table" && ( + {!routesView && view === "table" && ( +
- {view === "tree" ? ( + {routesView ? ( + 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 && ( void; +}) { + const [collapsed, setCollapsed] = useState(() => { + try { return parseRouteFolds(JSON.parse(localStorage.getItem(storageKey) ?? "null")); } + catch { return new Set(); } + }); + const [selectedId, setSelectedId] = useState(() => { + try { return localStorage.getItem(`${storageKey}:selection`); } catch { return null; } + }); + const [focusedId, setFocusedId] = useState(selectedId); + const treeRef = useRef(null); + const rowRefs = useRef(new Map()); + 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(); + 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 ; + return ( +
+
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((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 ( +
{ 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); + }}> +
+ ); + })} +
+
+ ); +} diff --git a/ui/src/components/WorkspaceTools.tsx b/ui/src/components/WorkspaceTools.tsx index df0afd423..c619b2b11 100644 --- a/ui/src/components/WorkspaceTools.tsx +++ b/ui/src/components/WorkspaceTools.tsx @@ -4,7 +4,7 @@ import { statusLabel } from "./StatusBadge"; import { getComputeSettingsQuery } from "../queries/settings"; import { TARGET_LABELS } from "../computeTargets"; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { FlaskConical, FolderOpen, Package, GitBranch, Cpu, MessagesSquare, Plus, Terminal } from "lucide-react"; +import { FlaskConical, FolderOpen, Package, GitBranch, GitFork, Cpu, MessagesSquare, Plus, Terminal } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; import { getSessionWorktreeQuery } from "../queries/files"; import { countChanges, parseDiffFiles } from "./GitDiff"; @@ -13,13 +13,13 @@ import { BackendLogo } from "./BackendLogos"; import { IconButton, MenuItem, StatusIndicator, Spinner, Tooltip } from "./ui"; import { usePopover } from "./ModelPicker"; -export function WorkspaceTools({ expanded, experiments, runs, onOpenExperiment, rightOffset, activeView, projectId, onCompute, sessionId, busy, onChanges, onFiles, onTerminal, onArtifacts, onExperiments, onSideChat, sessions, onSelectSideChat }: { +export function WorkspaceTools({ expanded, experiments, runs, onOpenExperiment, rightOffset, activeView, projectId, onCompute, sessionId, busy, onChanges, onFiles, onTerminal, onArtifacts, onExperiments, onRoutes, onSideChat, sessions, onSelectSideChat }: { expanded: boolean; experiments: Experiment[]; runs: Run[]; onOpenExperiment: (id: string, runId: string) => void; rightOffset?: number; - activeView: "files" | "artifacts" | "experiments" | "terminal" | null; + activeView: "files" | "artifacts" | "experiments" | "routes" | "terminal" | null; projectId: string; onCompute: () => void; sessionId: string | null; @@ -29,6 +29,7 @@ export function WorkspaceTools({ expanded, experiments, runs, onOpenExperiment, onTerminal: () => void; onArtifacts: () => void; onExperiments: () => void; + onRoutes: () => void; /** Absent until there is a chat to branch from. */ onSideChat?: () => void; sessions: ChatSession[]; @@ -42,6 +43,7 @@ export function WorkspaceTools({ expanded, experiments, runs, onOpenExperiment, { id: "files", label: m.app_files(), Icon: FolderOpen, onClick: onFiles }, { id: "artifacts", label: m.app_artifacts(), Icon: Package, onClick: onArtifacts }, { id: "experiments", label: m.app_experiments(), Icon: FlaskConical, onClick: onExperiments }, + { id: "routes", label: m.app_routes(), Icon: GitFork, onClick: onRoutes }, { id: "terminal", label: m.workspace_terminal(), Icon: Terminal, onClick: onTerminal }, ]; return ( diff --git a/ui/src/experimentRoutes.ts b/ui/src/experimentRoutes.ts new file mode 100644 index 000000000..54ae509ca --- /dev/null +++ b/ui/src/experimentRoutes.ts @@ -0,0 +1,109 @@ +import type { Experiment, Run } from "./api"; + +export interface RouteNode { + experiment: Experiment; + parentId: string | null; + children: string[]; + context: boolean; + boundary: "missing" | "cycle" | null; +} + +const compare = (a: string, b: string) => a < b ? -1 : a > b ? 1 : 0; + +/** Retain real ancestors across scope/archive filters; never invent an edge. */ +export function buildRoutes(experiments: Experiment[], sessionId: string | null, showArchived: boolean) { + const ordered = [...experiments].sort((a, b) => a.createdAt - b.createdAt || compare(a.id, b.id)); + const nodes = new Map(ordered.map((experiment) => [experiment.id, { + experiment, parentId: experiment.parentExperimentId ?? null, children: [], context: false, boundary: null, + }])); + for (const node of nodes.values()) { + if (node.parentId && !nodes.has(node.parentId)) { + node.parentId = null; + node.boundary = "missing"; + } + } + // Corrupt legacy/imported relationships must not hang the tree. Break one + // edge per cycle deterministically and label it instead of calling it a root. + const visited = new Set(); + for (const id of nodes.keys()) { + const path: string[] = []; + const positions = new Map(); + let current: string | null = id; + while (current && !visited.has(current)) { + const position = positions.get(current); + if (position !== undefined) { + const breakId = path.slice(position).sort(compare)[0]; + const node = nodes.get(breakId)!; + node.parentId = null; + node.boundary = "cycle"; + break; + } + positions.set(current, path.length); + path.push(current); + current = nodes.get(current)!.parentId; + } + for (const item of path) visited.add(item); + } + const targets = new Set(ordered.filter((experiment) => + (!sessionId || experiment.chatSessionId === sessionId) && (showArchived || !experiment.archived), + ).map((experiment) => experiment.id)); + const included = new Set(); + for (const id of targets) { + let current: string | null = id; + while (current && !included.has(current)) { + included.add(current); + current = nodes.get(current)!.parentId; + } + } + const roots: string[] = []; + for (const [id, node] of nodes) { + if (!included.has(id)) { nodes.delete(id); continue; } + node.context = !targets.has(id); + if (node.parentId) nodes.get(node.parentId)!.children.push(id); + else roots.push(id); + } + return { nodes, roots }; +} + +export interface RouteRow { + node: RouteNode; + depth: number; + position: number; + size: number; + /** Which ancestor levels have another sibling below this row. */ + continuation: boolean[]; +} + +export function routeRows(routes: ReturnType, collapsed: ReadonlySet): RouteRow[] { + const rows: RouteRow[] = []; + const stack = routes.roots.map((id, index) => ({ id, depth: 0, position: index + 1, size: routes.roots.length, continuation: [] as boolean[] })).reverse(); + while (stack.length) { + const item = stack.pop()!; + const node = routes.nodes.get(item.id)!; + rows.push({ node, ...item }); + if (collapsed.has(item.id)) continue; + for (let index = node.children.length - 1; index >= 0; index--) { + stack.push({ id: node.children[index], depth: item.depth + 1, position: index + 1, size: node.children.length, + continuation: item.depth === 0 ? [false] : [...item.continuation.slice(0, -1), item.position < item.size, false] }); + } + } + return rows; +} + +/** Return the nearest visible ancestor after folding or filtering. */ +export function visibleRouteId(id: string | null, routes: ReturnType, rows: RouteRow[]) { + const visible = new Set(rows.map((row) => row.node.experiment.id)); + let current = id; + while (current && !visible.has(current)) current = routes.nodes.get(current)?.parentId ?? null; + return current ?? rows[0]?.node.experiment.id ?? null; +} + +/** Match the table's live-first policy, with deterministic ties on API refresh. */ +export function routeRun(runs: Run[]): Run | null { + const sorted = [...runs].sort((a, b) => b.createdAt - a.createdAt || compare(a.id, b.id)); + return sorted.find((run) => run.status === "starting" || run.status === "running") ?? sorted[0] ?? null; +} + +export function parseRouteFolds(value: unknown): Set { + return new Set(Array.isArray(value) ? value.filter((id): id is string => typeof id === "string") : []); +} diff --git a/ui/tests/experimentRoutes.test.mjs b/ui/tests/experimentRoutes.test.mjs new file mode 100644 index 000000000..685f05ea8 --- /dev/null +++ b/ui/tests/experimentRoutes.test.mjs @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { buildRoutes, parseRouteFolds, routeRows, routeRun, visibleRouteId } from "../src/experimentRoutes.ts"; + +const exp = (id, parentExperimentId = null, extra = {}) => ({ id, parentExperimentId, createdAt: 1, chatSessionId: "A", archived: false, ...extra }); +const ids = (routes, folds = new Set()) => routeRows(routes, folds).map((row) => row.node.experiment.id); + +test("stable chronological sibling ordering, multiple roots and exact edges", () => { + const input = [exp("sibling", "root", { createdAt: 3 }), exp("root"), exp("other"), exp("child", "root")]; + const routes = buildRoutes(input, null, false); + assert.deepEqual(ids(routes), ["other", "root", "child", "sibling"]); + assert.deepEqual(ids(buildRoutes([...input].reverse(), null, false)), ids(routes)); + assert.equal(routes.nodes.get("child").parentId, "root"); +}); + +test("A → B → A retains the other task and archived ancestor without shortcuts", () => { + const routes = buildRoutes([exp("A1", null, { archived: true }), exp("B", "A1", { chatSessionId: "B" }), exp("A2", "B"), exp("unrelated", null, { chatSessionId: "B" })], "A", false); + assert.deepEqual(ids(routes), ["A1", "B", "A2"]); + assert.equal(routes.nodes.get("A1").context, true); + assert.equal(routes.nodes.get("B").context, true); + assert.equal(routes.nodes.get("A2").context, false); + assert.equal(routes.nodes.get("A2").parentId, "B"); +}); + +test("missing parent and cycles are boundaries, never claimed as baselines", () => { + const input = [exp("missing", "gone"), exp("self", "self"), exp("z", "a"), exp("a", "z")]; + const routes = buildRoutes(input, null, false); + assert.equal(routes.nodes.get("missing").boundary, "missing"); + assert.equal(routes.nodes.get("self").boundary, "cycle"); + assert.equal(routes.nodes.get("a").boundary, "cycle"); + assert.equal(routes.nodes.get("z").parentId, "a"); + assert.equal(new Set(ids(routes)).size, input.length); + assert.deepEqual(ids(buildRoutes([...input].reverse(), null, false)), ids(routes)); +}); + +test("archive and task filters hide unrelated leaves and handle empty scopes", () => { + const input = [exp("root"), exp("hidden", "root", { archived: true }), exp("other", null, { chatSessionId: "B" })]; + assert.deepEqual(ids(buildRoutes(input, "A", false)), ["root"]); + assert.deepEqual(ids(buildRoutes(input, "A", true)), ["root", "hidden"]); + assert.deepEqual(ids(buildRoutes(input, "absent", false)), []); + assert.deepEqual(ids(buildRoutes([], null, false)), []); +}); + +test("folds survive new API objects; hidden focus/selection resolves to an ancestor", () => { + const input = [exp("root"), exp("child", "root"), exp("grandchild", "child")]; + const folds = parseRouteFolds(["child", 7, null]); + const routes = buildRoutes(input, null, false); + const rows = routeRows(routes, folds); + assert.deepEqual(ids(routes, folds), ["root", "child"]); + assert.equal(visibleRouteId("grandchild", routes, rows), "child"); + assert.equal(visibleRouteId("deleted", routes, rows), "root"); + assert.deepEqual(ids(buildRoutes(input.map((node) => ({ ...node })), null, false), folds), ["root", "child"]); + assert.deepEqual([...parseRouteFolds({ malformed: true })], []); +}); + +test("live/cancelling run wins over a newer terminal attempt; ties are stable", () => { + const runs = [{ id: "done", status: "done", createdAt: 3 }, { id: "live", status: "running", cancelRequested: true, createdAt: 1 }]; + assert.equal(routeRun(runs).id, "live"); + assert.equal(routeRun([{ id: "z", status: "failed", createdAt: 1 }, { id: "a", status: "done", createdAt: 1 }]).id, "a"); + assert.equal(routeRun([]), null); + assert.equal(runs[0].id, "done"); +}); + +test("deep routes use iterative traversal and wide sibling ARIA positions are correct", () => { + const deep = Array.from({ length: 1000 }, (_, i) => exp(String(i), i ? String(i - 1) : null, { createdAt: i })); + const rows = routeRows(buildRoutes(deep, null, false), new Set()); + assert.equal(rows.length, 1000); + assert.equal(rows.at(-1).depth, 999); + const wide = [exp("root"), ...Array.from({ length: 170 }, (_, i) => exp(`child-${i}`, "root", { createdAt: i + 2 }))]; + const children = routeRows(buildRoutes(wide, null, false), new Set()).slice(1); + assert.equal(children.at(-1).position, 170); + assert.equal(children[0].size, 170); +}); + +test("connector continuation reflects siblings at each ancestor level", () => { + const routes = buildRoutes([exp("root"), exp("a", "root"), exp("b", "root"), exp("a1", "a"), exp("a2", "a"), exp("deep", "a1")], null, false); + const rows = routeRows(routes, new Set()); + assert.deepEqual(rows.find((row) => row.node.experiment.id === "a1").continuation, [true, false]); + assert.deepEqual(rows.find((row) => row.node.experiment.id === "deep").continuation, [true, true, false]); +}); diff --git a/ui/tests/experimentRoutesView.test.mjs b/ui/tests/experimentRoutesView.test.mjs new file mode 100644 index 000000000..4d4f198cd --- /dev/null +++ b/ui/tests/experimentRoutesView.test.mjs @@ -0,0 +1,143 @@ +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"; +import * as model from "../src/experimentRoutes.ts"; +import { tabOpenGestureHandlers } from "../src/tabPreview.ts"; + +const require = createRequire(import.meta.url); +const labels = { app_routes: "Routes", routes_archived_ancestor: "Archived ancestor", routes_other_task: "From another task", routes_missing_parent: "Parent unavailable", routes_invalid_lineage: "Invalid lineage" }; +const messages = { m: new Proxy({}, { get: (_, name) => () => labels[name] ?? String(name) }) }; +const compile = (path, mocks) => { + const source = readFileSync(new URL(path, import.meta.url), "utf8"); + const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX } }).outputText; + const exports = {}; + new Function("require", "exports", compiled)((name) => mocks[name] ?? require(name), exports); + return exports; +}; +const { StatusBadge } = compile("../src/components/StatusBadge.tsx", { + "../paraglide/messages.js": messages, + "./ui": { StatusIndicator: ({ tone, live, children, className }) => React.createElement("span", { "data-tone": tone, "data-live": live, className }, children) }, +}); +const { ExperimentRoutes } = compile("../src/components/ExperimentRoutes.tsx", { + "../paraglide/messages.js": messages, + "../api": { runDisplayStatus: (run) => run.cancelRequested && ["starting", "running"].includes(run.status) ? "cancelling" : run.status }, + "../experimentRoutes": model, + "../tabPreview": { tabOpenGestureHandlers }, + "./StatusBadge": { StatusBadge }, + "./WorkspaceEmptyState": { WorkspaceEmptyState: ({ title }) => React.createElement("p", null, title) }, +}); +const experiments = [ + { id: "root", title: "Baseline", createdAt: 1, chatSessionId: "A", archived: false }, + { id: "bridge", title: "Other task", createdAt: 2, parentExperimentId: "root", chatSessionId: "B", archived: true }, + { id: "child", title: "长名称 — a very long bilingual experiment title", createdAt: 3, parentExperimentId: "bridge", chatSessionId: "A", archived: false }, +]; +const render = (overrides = {}) => renderToStaticMarkup(React.createElement(ExperimentRoutes, { + experiments, runs: [{ id: "new", experimentId: "child", status: "done", createdAt: 5 }, { id: "live", experimentId: "child", status: "running", cancelRequested: true, createdAt: 4 }], + sessionId: "A", showArchived: false, storageKey: "test", onOpen: () => assert.fail("render cannot navigate"), ...overrides, +})); + +test("renders real lineage, explicit archive context, accessible levels and status words", () => { + const html = render(); + assert.match(html, /role="tree" aria-label="Routes"/); + assert.equal((html.match(/role="treeitem"/g) ?? []).length, 3); + assert.match(html, /aria-level="3"/); + assert.equal((html.match(/tabindex="0"/g) ?? []).length, 1); + assert.match(html, /Archived ancestor/); + assert.match(html, /长名称 — a very long bilingual experiment title/); + assert.match(html, /data-tone="caution" data-live="true"[^>]*>]*>status_cancelling<\/span>/); + assert.doesNotMatch(html, /status_done/); +}); + +test("persisted folds omit descendants and expose collapsed state on the parent", () => { + globalThis.localStorage = { getItem: (key) => key === "test" ? '["bridge"]' : "child" }; + try { + const html = render(); + assert.equal((html.match(/role="treeitem"/g) ?? []).length, 2); + assert.match(html, /aria-expanded="false" aria-selected="true"/); + assert.doesNotMatch(html, /a very long bilingual/); + } finally { delete globalThis.localStorage; } +}); + +test("missing/cyclic relationships render explicit labels and empty scopes stay empty", () => { + const html = render({ experiments: [ + { id: "missing", title: "Imported", parentExperimentId: "gone", createdAt: 1 }, + { id: "self", title: "Legacy", parentExperimentId: "self", createdAt: 2 }, + ], sessionId: null }); + assert.match(html, /Parent unavailable/); + assert.match(html, /Invalid lineage/); + assert.doesNotMatch(html, /Baseline/); + const empty = render({ experiments: [], emptyHint: "No task experiments" }); + assert.match(empty, /No task experiments/); + assert.doesNotMatch(empty, /role="treeitem"/); +}); + +test("a deleted persisted selection does not select an unrelated route", () => { + globalThis.localStorage = { getItem: (key) => key === "test" ? "[]" : "deleted" }; + try { assert.doesNotMatch(render(), /aria-selected="true"/); } + finally { delete globalThis.localStorage; } +}); + +const { WorkspaceTools } = compile("../src/components/WorkspaceTools.tsx", { + "../paraglide/messages.js": messages, + "../api": {}, "../workspaceRuns": { activeWorkspaceRuns: () => [] }, + "../queries/settings": { getComputeSettingsQuery: () => ({}) }, + "../queries/files": {}, "../computeTargets": {}, "./GitDiff": {}, + "./StatusBadge": {}, "./BackendLogos": {}, + "@tanstack/react-query": { useQuery: () => ({}) }, + "./ModelPicker": { OptionPicker: () => null }, + "./ui": { IconButton: ({ active, children, ...props }) => React.createElement("button", { ...props, "data-active": active }, children) }, + }); + +test("right toolbar exposes Routes with the shared active and accessible button treatment", () => { + const html = renderToStaticMarkup(React.createElement(WorkspaceTools, { expanded: false, experiments: [], runs: [], sessions: [], activeView: "routes" })); + assert.match(html, /aria-label="Routes" aria-pressed="true" data-active="true"/); +}); + +// Evaluate the conditions around the real App controls, not a copied predicate. +test("table-only controls stay hidden while Routes is selected", () => { + const source = readFileSync(new URL("../src/App.tsx", import.meta.url), "utf8"); + const ast = ts.createSourceFile("App.tsx", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + const conditions = []; + const visit = (node) => { + if (ts.isJsxExpression(node) && node.expression && ts.isBinaryExpression(node.expression) + && node.expression.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken) { + const body = node.expression.right.getText(ast); + if (/^\(\s* { + for (const expanded of [false, true]) { + const calls = []; + const tree = WorkspaceTools({ expanded, experiments: [], runs: [], sessions: [], + onRoutes: () => calls.push("routes"), onExperiments: () => calls.push("experiments"), + onTerminal: () => calls.push("terminal"), activeView: "routes" }); + let button; + const visit = (node) => { + if (!React.isValidElement(node)) return; + if (node.key === "routes") button = node; + React.Children.forEach(node.props.children, visit); + }; + visit(tree); + assert.ok(button, `Routes exists in expanded=${expanded}`); + button.props.onClick(); + assert.deepEqual(calls, ["routes"]); + } +});