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
112 changes: 112 additions & 0 deletions frontend/src/components/dataset/EpisodesDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// @vitest-environment jsdom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { expect, test } from "vitest";

import { parseEpisodeList } from "@/lib/datasetApi";

import EpisodesDialog from "./EpisodesDialog";

test("parses episode lists and ranges", () => {
expect(parseEpisodeList("3, 1, 5-7, 3", 10)).toEqual([1, 3, 5, 6, 7]);
expect(parseEpisodeList(" 2 - 4 ,", 10)).toEqual([2, 3, 4]);
expect(parseEpisodeList("", 10)).toBeNull();
expect(parseEpisodeList("10", 10)).toBeNull();
expect(parseEpisodeList("4-2", 10)).toBeNull();
expect(parseEpisodeList("1, x", 10)).toBeNull();
});

test("opens on the browsed episode and refuses to move every episode", async () => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const moved: [number[], string][] = [];

try {
await act(async () => {
root.render(
<EpisodesDialog
mode="move"
repoId="local/demo"
totalEpisodes={2}
currentEpisode={1}
open
onOpenChange={() => {}}
onSubmit={async (indices, name) => {
moved.push([indices, name!]);
}}
/>,
);
});

const episodes = document.getElementById("episode-selection") as HTMLInputElement;
const name = document.getElementById("moved-dataset-name") as HTMLInputElement;
const moveButton = () =>
[...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.startsWith("Move "),
) as HTMLButtonElement;
const type = async (input: HTMLInputElement, value: string) => {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
await act(async () => {
setter.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
});
};

expect(episodes.value).toBe("1");
expect(moveButton().disabled).toBe(true);

await type(name, "rejected takes");
expect(name.value).toBe("rejected_takes");
expect(moveButton().disabled).toBe(false);

await type(episodes, "0-1");
expect(document.body.textContent).toContain("At least one episode must stay in the dataset.");
expect(moveButton().disabled).toBe(true);

await type(episodes, "1");
await act(async () => moveButton().click());
expect(moved).toEqual([[[1], "rejected_takes"]]);
} finally {
await act(async () => root.unmount());
host.remove();
}
});

test("delete mode has no name field and deletes the selected episodes", async () => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const deleted: [number[], string | null][] = [];

try {
await act(async () => {
root.render(
<EpisodesDialog
mode="delete"
repoId="local/demo"
totalEpisodes={3}
currentEpisode={2}
open
onOpenChange={() => {}}
onSubmit={async (indices, name) => {
deleted.push([indices, name]);
}}
/>,
);
});

expect(document.getElementById("moved-dataset-name")).toBeNull();
const deleteButton = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.startsWith("Delete "),
) as HTMLButtonElement;
expect(deleteButton.textContent).toBe("Delete 1 episode");
await act(async () => deleteButton.click());
expect(deleted).toEqual([[[2], null]]);
} finally {
await act(async () => root.unmount());
host.remove();
}
});
173 changes: 173 additions & 0 deletions frontend/src/components/dataset/EpisodesDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { useEffect, useState } from "react";
import { Loader2 } from "lucide-react";

import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { parseEpisodeList } from "@/lib/datasetApi";

/** "move" sends the episodes to a new dataset; "delete" drops them. */
export type EpisodesDialogMode = "move" | "delete";

interface EpisodesDialogProps {
mode: EpisodesDialogMode;
repoId: string;
totalEpisodes: number;
currentEpisode: number | null;
open: boolean;
onOpenChange: (open: boolean) => void;
/** `outputName` is the new dataset's name in "move" mode, and null in "delete" mode. */
onSubmit: (episodeIndices: number[], outputName: string | null) => Promise<void>;
}

export default function EpisodesDialog({
mode,
repoId,
totalEpisodes,
currentEpisode,
open,
onOpenChange,
onSubmit,
}: EpisodesDialogProps) {
const [episodesText, setEpisodesText] = useState("");
const [outputName, setOutputName] = useState("");
const [working, setWorking] = useState(false);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
if (open) {
setEpisodesText(currentEpisode === null ? "" : String(currentEpisode));
setOutputName("");
setWorking(false);
setError(null);
}
}, [open, currentEpisode]);

const isMove = mode === "move";
const verb = isMove ? "Move" : "Delete";
const episodes = parseEpisodeList(episodesText, totalEpisodes);
const takesEverything = episodes !== null && episodes.length >= totalEpisodes;
const nameValid = !isMove || /^[A-Za-z0-9._-]+$/.test(outputName);
const canSubmit = episodes !== null && !takesEverything && nameValid && !working;

const handleOpenChange = (nextOpen: boolean) => {
if (working) return;
onOpenChange(nextOpen);
};

const handleSubmit = async () => {
if (!canSubmit || episodes === null) return;
setWorking(true);
try {
await onSubmit(episodes, isMove ? outputName : null);
onOpenChange(false);
} catch (caughtError) {
setError(
caughtError instanceof Error
? caughtError.message
: `Could not ${verb.toLowerCase()} the episodes.`,
);
} finally {
setWorking(false);
}
};

return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="border-gray-700 bg-gray-900 text-gray-300 sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-white">
{isMove ? "Move episodes to a new dataset" : "Delete episodes"}
</DialogTitle>
<DialogDescription>
{isMove ? (
<>
The episodes leave <code>{repoId}</code> and are saved locally as a new dataset.
</>
) : (
<>
The episodes are permanently removed from <code>{repoId}</code>. To keep a copy,
move them to a new dataset instead.
</>
)}{" "}
The episodes left in <code>{repoId}</code> are renumbered.
</DialogDescription>
</DialogHeader>

<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="episode-selection" className="text-gray-300">
Episodes
</Label>
<Input
id="episode-selection"
value={episodesText}
onChange={(event) => {
setError(null);
setEpisodesText(event.target.value);
}}
placeholder="3, 7, 10-12"
disabled={working}
className="border-gray-700 bg-gray-800 text-white"
/>
{episodesText.trim() && episodes === null && (
<p className="text-xs text-red-400">
Use episode numbers from 0 to {totalEpisodes - 1}, e.g. 3, 7, 10-12.
</p>
)}
{takesEverything && (
<p className="text-xs text-red-400">At least one episode must stay in the dataset.</p>
)}
</div>

{isMove && (
<div className="space-y-2">
<Label htmlFor="moved-dataset-name" className="text-gray-300">
New dataset name
</Label>
<Input
id="moved-dataset-name"
value={outputName}
onChange={(event) => {
setError(null);
setOutputName(event.target.value.replace(/[^A-Za-z0-9._-]/g, "_"));
}}
placeholder="rejected_takes"
disabled={working}
className="border-gray-700 bg-gray-800 text-white"
/>
<p className="text-xs text-gray-500">
Saved locally as <code>local/{outputName || "name"}</code>.
</p>
</div>
)}
{error && <p className="text-sm text-red-400">{error}</p>}
</div>

<DialogFooter>
<Button variant="outline" onClick={() => handleOpenChange(false)} disabled={working}>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={!canSubmit}
className={isMove ? undefined : "bg-red-500 text-white hover:bg-red-600"}
>
{working && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{working
? `${isMove ? "Moving" : "Deleting"}…`
: `${verb} ${episodes?.length ?? 0} episode${episodes?.length === 1 ? "" : "s"}`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
9 changes: 6 additions & 3 deletions frontend/src/hooks/useEpisodes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useApi } from "@/contexts/ApiContext";
import {
EpisodeDetail,
Expand All @@ -13,6 +13,7 @@ export const useEpisodes = (repoId: string | null) => {
const [data, setData] = useState<EpisodeListResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [version, setVersion] = useState(0);

useEffect(() => {
setData(null);
Expand Down Expand Up @@ -44,11 +45,13 @@ export const useEpisodes = (repoId: string | null) => {
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort();
}, [baseUrl, fetchWithHeaders, repoId]);
}, [baseUrl, fetchWithHeaders, repoId, version]);

const refresh = useCallback(() => setVersion((v) => v + 1), []);

// Effects run after render: do not let the new dataset auto-select an
// episode from the old list before this effect clears it.
return { data: data?.repo_id === repoId ? data : null, loading, error };
return { data: data?.repo_id === repoId ? data : null, loading, error, refresh };
};

/** One episode's detail. Either arg null → idle. */
Expand Down
19 changes: 19 additions & 0 deletions frontend/src/lib/datasetApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,3 +189,22 @@ export function formatDuration(seconds: number | null | undefined): string {
const s = Math.round(seconds % 60);
return `${m}m ${String(s).padStart(2, "0")}s`;
}

/**
* Parse "3, 7, 10-12" into sorted episode indices. Returns null when the text
* is not a list of indices and ranges below `total`.
*/
export function parseEpisodeList(text: string, total: number): number[] | null {
const indices = new Set<number>();
for (const part of text.split(",")) {
const item = part.trim();
if (!item) continue;
const match = /^(\d+)(?:\s*-\s*(\d+))?$/.exec(item);
if (!match) return null;
const start = Number(match[1]);
const end = match[2] === undefined ? start : Number(match[2]);
if (end < start || end >= total) return null;
for (let i = start; i <= end; i++) indices.add(i);
}
return indices.size > 0 ? [...indices].sort((a, b) => a - b) : null;
}
41 changes: 41 additions & 0 deletions frontend/src/lib/replayApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,20 @@ export interface MergeDatasetsResult {
total_frames: number;
}

export interface MoveEpisodesResult {
success: true;
repo_id: string;
moved_episodes: number;
remaining_episodes: number;
}

export interface DeleteEpisodesResult {
success: true;
repo_id: string;
deleted_episodes: number;
remaining_episodes: number;
}

export async function listDatasets(
baseUrl: string,
fetcher: Fetcher,
Expand Down Expand Up @@ -55,3 +69,30 @@ export async function mergeLocalDatasets(
action: "Merge datasets",
});
}

export async function moveEpisodes(
baseUrl: string,
fetcher: Fetcher,
repoId: string,
episodeIndices: number[],
outputName: string,
): Promise<MoveEpisodesResult> {
return apiRequest<MoveEpisodesResult>(baseUrl, fetcher, "/datasets/move-episodes", {
method: "POST",
body: { repo_id: repoId, episode_indices: episodeIndices, output_name: outputName },
action: "Move episodes",
});
}

export async function deleteEpisodes(
baseUrl: string,
fetcher: Fetcher,
repoId: string,
episodeIndices: number[],
): Promise<DeleteEpisodesResult> {
return apiRequest<DeleteEpisodesResult>(baseUrl, fetcher, "/datasets/delete-episodes", {
method: "POST",
body: { repo_id: repoId, episode_indices: episodeIndices },
action: "Delete episodes",
});
}
Loading