From e880977e6716a004d435169d7f4584754877ded3 Mon Sep 17 00:00:00 2001 From: Abubakar Abid Date: Thu, 6 Aug 2026 12:55:59 -0700 Subject: [PATCH 01/19] Add browser-local run history and loading --- client/js/src/index.ts | 8 + client/js/src/test/run_history.test.ts | 159 ++++++++++ client/js/src/utils/run_history.ts | 221 +++++++++++++ client/js/src/utils/submit.ts | 41 ++- gradio/routes.py | 16 +- js/spa/index.html | 1 + js/spa/src/Index.svelte | 67 +++- js/spa/src/RunHistory.svelte | 423 +++++++++++++++++++++++++ js/spa/src/RunValue.svelte | 69 ++++ test/test_routes.py | 7 + 10 files changed, 1000 insertions(+), 12 deletions(-) create mode 100644 client/js/src/test/run_history.test.ts create mode 100644 client/js/src/utils/run_history.ts create mode 100644 js/spa/src/RunHistory.svelte create mode 100644 js/spa/src/RunValue.svelte diff --git a/client/js/src/index.ts b/client/js/src/index.ts index 6ea916ad113..2aa4e7fc02b 100644 --- a/client/js/src/index.ts +++ b/client/js/src/index.ts @@ -5,6 +5,14 @@ export { submit } from "./utils/submit"; export { upload_files } from "./utils/upload_files"; export { FileData, upload, prepare_files } from "./upload"; export { handle_file } from "./helpers/data"; +export { + clear_run_history, + consume_run_history_replay, + read_run_history, + stage_run_history_replay, + type StoredRunComponent, + type StoredRun +} from "./utils/run_history"; export type { SpaceStatus, diff --git a/client/js/src/test/run_history.test.ts b/client/js/src/test/run_history.test.ts new file mode 100644 index 00000000000..1b61196dd22 --- /dev/null +++ b/client/js/src/test/run_history.test.ts @@ -0,0 +1,159 @@ +import { afterEach, describe, expect, test } from "vitest"; + +import { + clear_run_history, + consume_run_history_replay, + read_run_history, + stage_run_history_replay, + start_run_history, + update_run_history, + update_run_inputs +} from "../utils/run_history"; + +const root = "http://localhost:7860/my-app/"; + +afterEach(() => clear_run_history(root)); + +describe("run history", () => { + test("stores runs per app root with the page and inputs", () => { + const id = start_run_history({ + root, + endpoint: "/predict", + api_name: "/predict", + fn_index: 0, + inputs: ["hello"], + input_components: [ + { + type: "textbox", + component_class_id: "textbox-id", + props: { label: "Prompt" } + } + ] + }); + + const runs = read_run_history(root); + expect(runs).toHaveLength(1); + expect(runs[0]).toMatchObject({ + id, + endpoint: "/predict", + api_name: "/predict", + inputs: ["hello"], + status: "running", + input_components: [ + { + type: "textbox", + component_class_id: "textbox-id", + props: { label: "Prompt" } + } + ], + page: `${window.location.pathname}${window.location.search}` + }); + expect(read_run_history("http://localhost:7860/other-app/")).toEqual([]); + }); + + test("replaces inputs with their upload-processed values", () => { + const id = start_run_history({ + root, + endpoint: "/predict", + api_name: "/predict", + fn_index: 0, + inputs: [new File(["hello"], "hello.txt")] + }); + + update_run_inputs(root, id, [ + { path: "/tmp/hello.txt", url: "/gradio_api/file=/tmp/hello.txt" } + ]); + + expect(read_run_history(root)[0].inputs).toEqual([ + { path: "/tmp/hello.txt", url: "/gradio_api/file=/tmp/hello.txt" } + ]); + }); + + test("updates outputs and completion state", () => { + const id = start_run_history({ + root, + endpoint: "/predict", + api_name: "/predict", + fn_index: 0, + inputs: ["hello"] + }); + + update_run_history(root, id, { + type: "data", + endpoint: "/predict", + fn_index: 0, + data: ["hello hello"] + }); + update_run_history(root, id, { + type: "status", + endpoint: "/predict", + fn_index: 0, + queue: false, + stage: "complete", + time: new Date("2025-05-16T21:45:00Z") + }); + + expect(read_run_history(root)[0]).toMatchObject({ + outputs: ["hello hello"], + status: "completed", + completed_at: "2025-05-16T21:45:00.000Z" + }); + }); + + test("records failed runs and their error message", () => { + const id = start_run_history({ + root, + endpoint: 3, + api_name: "Function 3", + fn_index: 3, + inputs: [{ circular: null }] + }); + + update_run_history(root, id, { + type: "status", + endpoint: "/predict", + fn_index: 3, + queue: true, + stage: "error", + message: "generation failed" + }); + + expect(read_run_history(root)[0]).toMatchObject({ + status: "failed", + error: "generation failed" + }); + }); + + test("keeps only the newest 100 runs", () => { + for (let index = 0; index < 105; index++) { + start_run_history({ + root, + endpoint: "/predict", + api_name: "/predict", + fn_index: 0, + inputs: [index] + }); + } + + const runs = read_run_history(root); + expect(runs).toHaveLength(100); + expect(runs[0].inputs).toEqual([104]); + expect(runs.at(-1)?.inputs).toEqual([5]); + }); + + test("stages a run once so it can be loaded on its saved page", () => { + const id = start_run_history({ + root, + endpoint: "/predict", + api_name: "/predict", + fn_index: 0, + inputs: ["hello"] + }); + const run = read_run_history(root).find((item) => item.id === id)!; + + stage_run_history_replay(root, run); + + expect(consume_run_history_replay(root)).toEqual(run); + expect(consume_run_history_replay(root)).toBeNull(); + }); +}); diff --git a/client/js/src/utils/run_history.ts b/client/js/src/utils/run_history.ts new file mode 100644 index 00000000000..82b35cbc14f --- /dev/null +++ b/client/js/src/utils/run_history.ts @@ -0,0 +1,221 @@ +import type { GradioEvent } from "../types"; + +const STORAGE_PREFIX = "gradio:run-history:v1:"; +const REPLAY_PREFIX = "gradio:run-history:replay:v1:"; +const MAX_RUNS = 100; + +export type RunStatus = "running" | "completed" | "failed"; + +export interface StoredRunComponent { + type: string; + component_class_id: string; + props: Record; +} + +export interface StoredRun { + id: string; + endpoint: string | number; + api_name: string; + fn_index: number; + page: string; + inputs: unknown; + outputs: unknown | null; + input_components?: StoredRunComponent[]; + output_components?: StoredRunComponent[]; + status: RunStatus; + error?: string; + started_at: string; + completed_at?: string; +} + +interface StartRunOptions { + root: string; + endpoint: string | number; + api_name: string; + fn_index: number; + inputs: unknown; + input_components?: StoredRunComponent[]; + output_components?: StoredRunComponent[]; +} + +function storage_key(root: string): string | null { + if (typeof window === "undefined") return null; + + try { + if (!window.localStorage) return null; + const root_url = new URL(root || "/", window.location.href); + const path = root_url.pathname.replace(/\/$/, "") || "/"; + return `${STORAGE_PREFIX}${path}`; + } catch { + return null; + } +} + +function replay_key(root: string): string | null { + const key = storage_key(root); + return key ? key.replace(STORAGE_PREFIX, REPLAY_PREFIX) : null; +} + +function make_id(): string { + if (typeof crypto !== "undefined" && crypto.randomUUID) { + return crypto.randomUUID(); + } + return `${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +function clone_for_storage(value: unknown): unknown { + const seen = new WeakSet(); + try { + return JSON.parse( + JSON.stringify(value, (_key, item) => { + if (typeof item === "bigint") return item.toString(); + if (typeof item === "object" && item !== null) { + if (seen.has(item)) return "[Circular]"; + seen.add(item); + } + return item; + }) + ); + } catch { + return "[Unserializable value]"; + } +} + +export function read_run_history(root: string): StoredRun[] { + const key = storage_key(root); + if (!key) return []; + + try { + const value = JSON.parse(window.localStorage.getItem(key) || "[]"); + return Array.isArray(value) ? value : []; + } catch { + return []; + } +} + +function write_run_history(root: string, runs: StoredRun[]): void { + const key = storage_key(root); + if (!key) return; + + let next = runs.slice(0, MAX_RUNS); + while (next.length > 0) { + try { + window.localStorage.setItem(key, JSON.stringify(next)); + return; + } catch { + next = next.slice(0, -1); + } + } + try { + window.localStorage.removeItem(key); + } catch { + // Run history must never interfere with an app submission. + } +} + +export function clear_run_history(root: string): void { + const key = storage_key(root); + if (!key) return; + try { + window.localStorage.removeItem(key); + } catch { + // Storage may be disabled by the browser. + } +} + +export function stage_run_history_replay(root: string, run: StoredRun): void { + const key = replay_key(root); + if (!key) return; + try { + window.sessionStorage.setItem(key, JSON.stringify(run)); + } catch { + // Session storage may be disabled by the browser. + } +} + +export function consume_run_history_replay(root: string): StoredRun | null { + const key = replay_key(root); + if (!key) return null; + try { + const value = window.sessionStorage.getItem(key); + window.sessionStorage.removeItem(key); + return value ? (JSON.parse(value) as StoredRun) : null; + } catch { + return null; + } +} + +export function start_run_history(options: StartRunOptions): string | null { + const key = storage_key(options.root); + if (!key) return null; + + const run: StoredRun = { + id: make_id(), + endpoint: options.endpoint, + api_name: options.api_name, + fn_index: options.fn_index, + page: `${window.location.pathname}${window.location.search}`, + inputs: clone_for_storage(options.inputs), + outputs: null, + ...(options.input_components + ? { + input_components: clone_for_storage( + options.input_components + ) as StoredRunComponent[] + } + : {}), + ...(options.output_components + ? { + output_components: clone_for_storage( + options.output_components + ) as StoredRunComponent[] + } + : {}), + status: "running", + started_at: new Date().toISOString() + }; + write_run_history(options.root, [run, ...read_run_history(options.root)]); + return run.id; +} + +export function update_run_inputs( + root: string, + id: string | null, + inputs: unknown +): void { + if (!id) return; + + const runs = read_run_history(root); + const run = runs.find((item) => item.id === id); + if (!run) return; + run.inputs = clone_for_storage(inputs); + write_run_history(root, runs); +} + +export function update_run_history( + root: string, + id: string | null, + event: GradioEvent +): void { + if (!id) return; + + const runs = read_run_history(root); + const run = runs.find((item) => item.id === id); + if (!run) return; + + if (event.type === "data") { + run.outputs = clone_for_storage(event.data); + } else if (event.type === "status" && event.stage === "complete") { + run.status = "completed"; + run.completed_at = (event.time || new Date()).toISOString(); + } else if (event.type === "status" && event.stage === "error") { + run.status = "failed"; + run.error = + typeof event.message === "string" + ? event.message + : JSON.stringify(event.message || "Unknown error"); + run.completed_at = (event.time || new Date()).toISOString(); + } + + write_run_history(root, runs); +} diff --git a/client/js/src/utils/submit.ts b/client/js/src/utils/submit.ts index db642d823a3..89b3c383818 100644 --- a/client/js/src/utils/submit.ts +++ b/client/js/src/utils/submit.ts @@ -29,6 +29,11 @@ import { } from "../constants"; import { apply_diff_stream, close_stream } from "./stream"; import { Client } from "../client"; +import { + start_run_history, + update_run_history, + update_run_inputs +} from "./run_history"; export function submit( this: Client, @@ -73,12 +78,42 @@ export function submit( ); let resolved_data = map_data_to_params(data, endpoint_info); - - let stream: EventSource | null; let protocol = config.protocol ?? "ws"; if (protocol === "ws") { throw new Error(WS_PROTOCOL_MSG); } + const history_endpoint = + typeof dependency.api_name === "string" + ? `/${dependency.api_name}` + : endpoint; + const history_api_name = + typeof dependency.api_name === "string" + ? `/${dependency.api_name}` + : `Function ${fn_index}`; + const component_metadata = (id: number) => { + const component = config.components.find((item) => item.id === id); + if (!component) return undefined; + return { + type: component.type, + component_class_id: component.component_class_id, + props: component.props + }; + }; + const history_run_id = start_run_history({ + root: config.root, + endpoint: history_endpoint, + api_name: history_api_name, + fn_index, + inputs: resolved_data, + input_components: dependency.inputs + .map(component_metadata) + .filter((item) => item !== undefined), + output_components: dependency.outputs + .map(component_metadata) + .filter((item) => item !== undefined) + }); + + let stream: EventSource | null; let event_id_final = ""; let event_id_cb: () => string = () => event_id_final; @@ -103,6 +138,7 @@ export function submit( // event subscription methods function fire_event(event: GradioEvent): void { + update_run_history(config!.root, history_run_id, event); if (all_events || events_to_publish[event.type]) { push_event(event); } @@ -180,6 +216,7 @@ export function submit( "input", true ); + update_run_inputs(config.root, history_run_id, input_data || []); payload = { data: input_data || [], event_data, diff --git a/gradio/routes.py b/gradio/routes.py index 764326eb524..51e0baf5de2 100644 --- a/gradio/routes.py +++ b/gradio/routes.py @@ -614,9 +614,10 @@ def main( ): mimetypes.add_type("application/javascript", ".js") blocks = app.get_blocks() + is_run_history = request.url.path.rstrip("/").endswith(f"{API_PREFIX}/runs") root = route_utils.get_root_url( request=request, - route_path=f"/{page}", + route_path=f"{API_PREFIX}/runs" if is_run_history else f"/{page}", root_path=app.root_path or request.scope.get("root_path") or blocks.custom_mount_path, @@ -678,6 +679,11 @@ def main( request=request, name=template, context={ + "base_url": ( + "../../" if request.url.path.endswith("/") else "../" + ) + if is_run_history + else "./", "config": config, "gradio_api_info": gradio_api_info, }, @@ -695,6 +701,14 @@ def main( "the frontend by running /scripts/build_frontend.sh" ) from err + @router.get("/runs", response_class=HTMLResponse) + @router.get("/runs/", response_class=HTMLResponse) + def run_history( + request: fastapi.Request, + user: str = Depends(get_current_user), + ): + return main(request, user) + @app.get("/gradio_api/deep_link") def deep_link(session_hash: str): if session_hash in app.state_holder: diff --git a/js/spa/index.html b/js/spa/index.html index 706a3f680e9..77f367a8ca5 100644 --- a/js/spa/index.html +++ b/js/spa/index.html @@ -11,6 +11,7 @@ > + import { onMount, onDestroy } from "svelte"; - import type { SpaceStatus } from "@gradio/client"; + import { + consume_run_history_replay, + type SpaceStatus, + type StoredRun + } from "@gradio/client"; import { Embed } from "@gradio/core"; import type { ThemeMode } from "@gradio/core"; import { StatusTracker } from "@gradio/statustracker"; import { _ } from "svelte-i18n"; import { setupi18n } from "@gradio/core"; import { init } from "@huggingface/space-header"; + import RunHistory from "./RunHistory.svelte"; let i18n_ready = $state(false); setupi18n().then(() => { @@ -145,7 +150,40 @@ let loading_text = $state("Loading..."); let active_theme_mode: ThemeMode = $state("system"); - let api_url: string; + let api_url = $state(""); + let run_history = $state(false); + + function restore_run(config: Config, run: StoredRun | null): void { + if (!run) return; + const dependency = config.dependencies.find( + (item) => + item.id === run.fn_index || + (typeof item.api_name === "string" && + `/${item.api_name.replace(/^\//, "")}` === run.api_name) + ); + if (!dependency) return; + + const inputs = Array.isArray(run.inputs) + ? run.inputs + : Object.values(run.inputs as Record); + const outputs = Array.isArray(run.outputs) + ? run.outputs + : run.outputs === null + ? [] + : [run.outputs]; + for (const [index, id] of dependency.inputs.entries()) { + const component = config.components.find((item) => item.id === id); + if (component && index < inputs.length) { + component.props.value = inputs[index]; + } + } + for (const [index, id] of dependency.outputs.entries()) { + const component = config.components.find((item) => item.id === id); + if (component && index < outputs.length) { + component.props.value = outputs[index]; + } + } + } $effect(() => { if (config?.app_id) { @@ -341,10 +379,16 @@ onMount(async () => { if (!wrapper) return; active_theme_mode = handle_theme_mode(wrapper); + run_history = window.location.pathname + .replace(/\/$/, "") + .endsWith("/gradio_api/runs"); //@ts-ignore const server_port = window.__GRADIO__SERVER_PORT__; + const app_path = run_history + ? window.location.pathname.replace(/gradio_api\/runs\/?$/, "") + : window.location.pathname; api_url = BUILD_MODE === "dev" || gradio_dev_mode === "dev" ? `http://localhost:${ @@ -352,7 +396,7 @@ }` : space || src || - new URL(location.pathname, location.origin).href.replace(/\/$/, ""); + new URL(app_path, location.origin).href.replace(/\/$/, ""); const deep_link = new URLSearchParams(window.location.search).get( "deep_link" @@ -376,6 +420,7 @@ } config = app.get_url_config() as unknown as Config; + restore_run(config, consume_run_history_replay(config.root)); window.__gradio_space__ = config.space_id; if (app.config?.i18n_translations) { @@ -466,11 +511,13 @@ }); let loader_status: "pending" | "error" | "complete" | "generating" = $derived( - !ready && status.load_status !== "error" - ? "pending" - : !ready && status.load_status === "error" - ? "error" - : status.load_status + run_history + ? status.load_status + : !ready && status.load_status !== "error" + ? "pending" + : !ready && status.load_status === "error" + ? "error" + : status.load_status ); $effect(() => { @@ -586,7 +633,7 @@ bind:wrapper > {#if i18n_ready} - {#if (loader_status === "pending" || loader_status === "error") && !(config && config?.auth_required)} + {#if !run_history && (loader_status === "pending" || loader_status === "error") && !(config && config?.auth_required)} s} {app_mode} /> + {:else if config && css_ready && run_history} + {:else if config && Blocks && css_ready} + import { onMount } from "svelte"; + import { + clear_run_history, + read_run_history, + stage_run_history_replay, + type StoredRun, + type StoredRunComponent + } from "@gradio/client"; + import RunValue from "./RunValue.svelte"; + + interface Props { + root: string; + } + + let { root }: Props = $props(); + let runs: StoredRun[] = $state([]); + + let groups = $derived.by(() => { + const grouped = new Map(); + for (const run of runs) { + const current = grouped.get(run.api_name) || []; + current.push(run); + grouped.set(run.api_name, current); + } + return Array.from(grouped.entries()); + }); + + function refresh(): void { + runs = read_run_history(root); + } + + onMount(() => { + refresh(); + window.addEventListener("storage", refresh); + return () => window.removeEventListener("storage", refresh); + }); + + function values(value: unknown): unknown[] { + if (Array.isArray(value)) return value; + if (value && typeof value === "object") { + return Object.values(value as Record); + } + return value === null || value === undefined ? [] : [value]; + } + + function summarize(value: unknown): string { + if (value === null || value === undefined) return "No value"; + if (typeof value === "string") return value || "Empty value"; + try { + return JSON.stringify(value); + } catch { + return String(value); + } + } + + function label(meta: StoredRunComponent | undefined, index: number): string { + const component_label = meta?.props?.label; + if (typeof component_label === "string" && component_label) { + return component_label; + } + return meta?.type || `Value ${index + 1}`; + } + + function format_time(value: string): string { + return new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short" + }).format(new Date(value)); + } + + function load(run: StoredRun): void { + stage_run_history_replay(root, run); + const app_url = new URL(root, window.location.href); + let target = new URL(run.page || app_url.pathname, app_url); + if (target.origin !== app_url.origin) target = app_url; + window.location.assign(target); + } + + function clear_all(): void { + if (!window.confirm("Clear all saved runs for this app?")) return; + clear_run_history(root); + refresh(); + } + + +
+ + + {#if groups.length === 0} +
+

No runs yet

+

Use the app, then return here to load previous runs.

+
+ {:else} + {#each groups as [api_name, endpoint_runs]} +
+
+ + {api_name} + {endpoint_runs.length} + {endpoint_runs.length === 1 ? "run" : "runs"} +
+ + {#each endpoint_runs as run (run.id)} + {@const input_values = values(run.inputs)} + {@const output_values = values(run.outputs)} +
+
+

Inputs

+ {#each input_values as value, index} + {@const meta = run.input_components?.[index]} +
+ {label(meta, index)} + {#if meta} + + {:else} + {summarize(value)} + {/if} +
+ {/each} +
+
+

Outputs

+ {#if output_values.length} + {#each output_values as value, index} + {@const meta = run.output_components?.[index]} +
+ {label(meta, index)} + {#if meta} + + {:else} + {summarize(value)} + {/if} +
+ {/each} + {:else} +
No saved output
+ {/if} +
+
+ +
+
+ + + {run.status === "completed" + ? "Completed" + : run.status === "failed" + ? "Failed" + : "Running"} + + {#if run.error}{run.error}{/if} +
+
+ {/each} +
+ {/each} + {/if} +
+ + diff --git a/js/spa/src/RunValue.svelte b/js/spa/src/RunValue.svelte new file mode 100644 index 00000000000..8f71829d50d --- /dev/null +++ b/js/spa/src/RunValue.svelte @@ -0,0 +1,69 @@ + + +
+ + diff --git a/test/test_routes.py b/test/test_routes.py index 3a499325e9a..fc31c0aaac2 100644 --- a/test/test_routes.py +++ b/test/test_routes.py @@ -59,6 +59,13 @@ def test_get_main_route(self, test_client): response = test_client.get("/") assert response.status_code == 200 + def test_get_run_history_route(self, test_client): + response = test_client.get(f"{API_PREFIX}/runs") + assert response.status_code == 200 + assert " Date: Thu, 6 Aug 2026 13:15:19 -0700 Subject: [PATCH 02/19] Refine run history controls and storage UI --- client/js/src/index.ts | 1 + client/js/src/test/run_history.test.ts | 23 +++++ client/js/src/utils/run_history.ts | 7 ++ js/spa/src/RunHistory.svelte | 138 +++++++++++++++++++++---- js/spa/src/RunValue.svelte | 9 ++ 5 files changed, 159 insertions(+), 19 deletions(-) diff --git a/client/js/src/index.ts b/client/js/src/index.ts index 2aa4e7fc02b..c88277df104 100644 --- a/client/js/src/index.ts +++ b/client/js/src/index.ts @@ -8,6 +8,7 @@ export { handle_file } from "./helpers/data"; export { clear_run_history, consume_run_history_replay, + delete_run_history, read_run_history, stage_run_history_replay, type StoredRunComponent, diff --git a/client/js/src/test/run_history.test.ts b/client/js/src/test/run_history.test.ts index 1b61196dd22..cf124ccfe11 100644 --- a/client/js/src/test/run_history.test.ts +++ b/client/js/src/test/run_history.test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, test } from "vitest"; import { clear_run_history, consume_run_history_replay, + delete_run_history, read_run_history, stage_run_history_replay, start_run_history, @@ -141,6 +142,28 @@ describe("run history", () => { expect(runs.at(-1)?.inputs).toEqual([5]); }); + test("deletes an individual run", () => { + const first = start_run_history({ + root, + endpoint: "/predict", + api_name: "/predict", + fn_index: 0, + inputs: ["first"] + }); + start_run_history({ + root, + endpoint: "/predict", + api_name: "/predict", + fn_index: 0, + inputs: ["second"] + }); + + delete_run_history(root, first!); + + expect(read_run_history(root)).toHaveLength(1); + expect(read_run_history(root)[0].inputs).toEqual(["second"]); + }); + test("stages a run once so it can be loaded on its saved page", () => { const id = start_run_history({ root, diff --git a/client/js/src/utils/run_history.ts b/client/js/src/utils/run_history.ts index 82b35cbc14f..4c4ded5767a 100644 --- a/client/js/src/utils/run_history.ts +++ b/client/js/src/utils/run_history.ts @@ -123,6 +123,13 @@ export function clear_run_history(root: string): void { } } +export function delete_run_history(root: string, id: string): void { + write_run_history( + root, + read_run_history(root).filter((run) => run.id !== id) + ); +} + export function stage_run_history_replay(root: string, run: StoredRun): void { const key = replay_key(root); if (!key) return; diff --git a/js/spa/src/RunHistory.svelte b/js/spa/src/RunHistory.svelte index f60a6ca95a3..5b724d85274 100644 --- a/js/spa/src/RunHistory.svelte +++ b/js/spa/src/RunHistory.svelte @@ -2,6 +2,7 @@ import { onMount } from "svelte"; import { clear_run_history, + delete_run_history, read_run_history, stage_run_history_replay, type StoredRun, @@ -82,13 +83,42 @@ clear_run_history(root); refresh(); } + + function delete_run(run: StoredRun): void { + if (!window.confirm("Delete this saved run?")) return; + delete_run_history(root, run.id); + refresh(); + }
{#each endpoint_runs as run (run.id)} {@const input_values = values(run.inputs)} @@ -154,9 +184,6 @@
No saved output
{/if} -
- -
+ {#if run.error}{run.error}{/if} +
{/each} @@ -207,13 +238,81 @@ font-weight: var(--weight-semibold, 600); line-height: 1.2; } - .page-header p, + .storage-copy, .empty p { margin: 6px 0 0; color: var(--body-text-color-subdued, #71717a); } + .storage-copy { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 5px; + } + .storage-picker { + display: inline-block; + position: relative; + } + .storage-picker summary { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 8px; + border: 1px solid var(--border-color-primary, #d4d4d8); + border-radius: 999px; + background: var(--background-fill-secondary, #f4f4f5); + color: var(--body-text-color, #27272a); + font-size: 13px; + font-weight: 600; + cursor: pointer; + list-style: none; + } + .storage-picker summary::-webkit-details-marker { + display: none; + } + .storage-picker[open] summary { + border-color: var(--border-color-accent, #f97316); + } + .storage-menu { + position: absolute; + top: calc(100% + 8px); + left: 0; + z-index: 10; + width: 250px; + padding: 6px; + border: 1px solid var(--border-color-primary, #e4e4e7); + border-radius: var(--radius-lg, 8px); + background: var(--block-background-fill, #fff); + box-shadow: var(--shadow-drop-lg, 0 12px 28px rgb(0 0 0 / 15%)); + } + .storage-option { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 9px 10px; + border-radius: 6px; + } + .storage-option.active { + background: var(--background-fill-secondary, #f4f4f5); + color: var(--body-text-color, #27272a); + } + .storage-option.disabled { + color: var(--body-text-color-subdued, #71717a); + opacity: 0.7; + } + .storage-option strong, + .storage-option small { + display: block; + } + .storage-option small { + margin-top: 2px; + font-size: 11px; + font-weight: 400; + } .clear, - .load { + .load, + .delete { border: var(--button-border-width, 1px) solid var(--button-secondary-border-color, #d4d4d8); border-radius: var(--button-medium-radius, 8px); @@ -232,11 +331,20 @@ padding: 8px 12px; } .load { - min-width: 112px; - padding: 10px 16px; + padding: 5px 10px; + font-size: 13px; + } + .delete { + margin-left: auto; + padding: 5px 10px; + border-color: transparent; + background: transparent; + box-shadow: none; + color: var(--body-text-color-subdued, #71717a); } .clear:hover, - .load:hover { + .load:hover, + .delete:hover { border-color: var(--button-secondary-border-color-hover, #a1a1aa); background: var(--button-secondary-background-fill-hover, #f4f4f5); color: var(--button-secondary-text-color-hover, #18181b); @@ -280,7 +388,7 @@ .table-header, .run { display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 132px; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } .table-header { gap: 16px; @@ -339,11 +447,6 @@ color: var(--body-text-color-subdued, #71717a); font-style: italic; } - .run-action { - display: flex; - align-items: center; - justify-content: flex-end; - } .metadata { grid-column: 1 / -1; gap: 10px; @@ -411,9 +514,6 @@ .run-values h3 { display: block; } - .run-action { - justify-content: flex-start; - } .metadata { grid-column: 1; align-items: flex-start; diff --git a/js/spa/src/RunValue.svelte b/js/spa/src/RunValue.svelte index 8f71829d50d..83ef507aebb 100644 --- a/js/spa/src/RunValue.svelte +++ b/js/spa/src/RunValue.svelte @@ -28,8 +28,17 @@ Promise.all([loaded.component, loaded.runtime]).then( ([component, runtime]) => { if (disposed) return; + const choices = Array.isArray(meta.props.choices) + ? meta.props.choices + : (meta.type === "dropdown" || meta.type === "radio") && value != null + ? (Array.isArray(value) ? value : [value]).map((item) => [ + String(item), + item + ]) + : undefined; const props = { ...meta.props, + ...(choices ? { choices } : {}), value, type: "table", selected: false, From 77d5c5714e158405e0ddb1f8b25b834b0c91aada Mon Sep 17 00:00:00 2001 From: Abubakar Abid Date: Thu, 6 Aug 2026 13:41:35 -0700 Subject: [PATCH 03/19] Simplify storage label and emphasize loading --- js/spa/src/RunHistory.svelte | 117 ++++++++++++----------------------- 1 file changed, 39 insertions(+), 78 deletions(-) diff --git a/js/spa/src/RunHistory.svelte b/js/spa/src/RunHistory.svelte index 5b724d85274..2a99bb24b02 100644 --- a/js/spa/src/RunHistory.svelte +++ b/js/spa/src/RunHistory.svelte @@ -97,28 +97,12 @@

Run history

Runs ({runs.length}) logged in -
- Local Storage -
-
- Local StorageThis browser - -
-
- Local FileComing soon -
-
- Hugging Face BucketComing soon -
-
-
+ Local Storage , privately in this browser.
+
+ Local File and Hugging Face Bucket storage are not implemented yet. +
{#if runs.length} @@ -199,7 +183,9 @@ ? "Failed" : "Running"} - + {#if run.error}{run.error}{/if} Date: Thu, 6 Aug 2026 14:47:34 -0700 Subject: [PATCH 04/19] Condense run history header --- js/spa/src/RunHistory.svelte | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/js/spa/src/RunHistory.svelte b/js/spa/src/RunHistory.svelte index 2a99bb24b02..28c5640b23a 100644 --- a/js/spa/src/RunHistory.svelte +++ b/js/spa/src/RunHistory.svelte @@ -93,16 +93,13 @@
diff --git a/test/test_routes.py b/test/test_routes.py index 156a89ffad1..6f2ccffea23 100644 --- a/test/test_routes.py +++ b/test/test_routes.py @@ -1347,7 +1347,7 @@ def test_run_history_is_a_default_footer_link(): app, _, _ = demo.launch(prevent_thread_lock=True) client = TestClient(app) config = client.get("/config").json() - assert config["footer_links"] == ["api", "gradio", "settings", "history"] + assert config["footer_links"] == ["api", "gradio", "settings", "runs"] finally: demo.close() @@ -1361,7 +1361,7 @@ def test_footer_links_can_exclude_run_history(): ) client = TestClient(app) config = client.get("/config").json() - assert "history" not in config["footer_links"] + assert "runs" not in config["footer_links"] finally: demo.close() From cbce97540fcb0ce5b10954dd0342032c848e2a80 Mon Sep 17 00:00:00 2001 From: Abubakar Abid Date: Mon, 10 Aug 2026 12:31:02 -0700 Subject: [PATCH 10/19] Reveal a failed run's message on hover instead of via `title` The dotted underline promised a message that never arrived: a native `title` tooltip is slow enough to miss and cannot be clicked. Replace it with a real bubble that appears immediately on hover, toggles on click for touch, shows on keyboard focus, and dismisses on Escape. Escape also drops focus, since pressing a key would otherwise promote the trigger to `:focus-visible` and keep the message on screen after dismissing it. The info icon goes away too, as the underline already advertises the message, so a failed run gets the same status dot as every other run. The groups no longer clip their overflow, so a bubble cannot be cut off; the header rounds its own corners instead. Also fold the run count into the heading: "Run history (3)". Co-Authored-By: Claude Opus 5 (1M context) --- js/spa/src/RunHistory.svelte | 112 +++++++++++++++++++++++------------ 1 file changed, 73 insertions(+), 39 deletions(-) diff --git a/js/spa/src/RunHistory.svelte b/js/spa/src/RunHistory.svelte index 271d61d00d7..b32326f1c2b 100644 --- a/js/spa/src/RunHistory.svelte +++ b/js/spa/src/RunHistory.svelte @@ -119,6 +119,25 @@ return run.status === "failed" ? "Failed" : "Running"; } + // Hover reveals the error, but tapping has to work too, and a touch device + // has no hover to offer. + let open_error: string | null = $state(null); + + function toggle_error(id: string): void { + open_error = open_error === id ? null : id; + } + + function close_error(event: KeyboardEvent): void { + if (event.key !== "Escape") return; + open_error = null; + // Pressing a key promotes the trigger to `:focus-visible`, which would + // keep the message on screen even though it was just dismissed. + const active = document.activeElement; + if (active instanceof HTMLElement && active.matches(".error-trigger")) { + active.blur(); + } + } + function load(run: StoredRun): void { stage_run_history_replay(root, run); const app_url = new URL(root, window.location.href); @@ -140,12 +159,14 @@ } + +