From 8236c428856bf9cfe07282c5c4a537df2fbc9c2c Mon Sep 17 00:00:00 2001 From: Rob von Behren Date: Fri, 6 Mar 2026 07:56:59 -0800 Subject: [PATCH] add support in the UI for public benchmarks rename strings to avoid shortcut key conflict fix: change benchmark job shortcut to [r] "Run Benchmark Job" The previous shortcut "c" conflicted with "copy id" on the detail page, and "n" conflicted with "next page" on the list page. Use "r" for "Run Benchmark Job" on both screens for consistency. Co-Authored-By: Claude Opus 4.6 fix pagenation and loading of benchmark scenarios fmt --- src/commands/benchmark-job/run.ts | 25 +++- src/screens/BenchmarkDetailScreen.tsx | 4 +- src/screens/BenchmarkJobListScreen.tsx | 11 +- src/screens/BenchmarkListScreen.tsx | 114 ++++++++++++---- src/screens/BenchmarkRunDetailScreen.tsx | 38 +++--- src/screens/BenchmarkRunListScreen.tsx | 11 +- src/screens/ScenarioRunListScreen.tsx | 129 ++++++++++++++---- src/services/benchmarkService.ts | 23 ++++ src/store/navigationStateMachine.ts | 12 ++ src/store/navigationStore.tsx | 8 ++ .../store/navigationStateMachine.test.ts | 26 ++++ 11 files changed, 322 insertions(+), 79 deletions(-) diff --git a/src/commands/benchmark-job/run.ts b/src/commands/benchmark-job/run.ts index 9e5b0e93..a22ae537 100644 --- a/src/commands/benchmark-job/run.ts +++ b/src/commands/benchmark-job/run.ts @@ -204,16 +204,31 @@ async function ensureAgentSecrets( return secrets; } +const BENCHMARK_ID_PREFIXES = ["bm_", "bmk_", "bmd_"]; + +function looksLikeBenchmarkId(s: string): boolean { + return BENCHMARK_ID_PREFIXES.some((p) => s.startsWith(p)); +} + +// Extract a benchmark ID from strings like "Name (bmd_xxx)" copied from the TUI +function extractEmbeddedId(s: string): string | null { + const match = s.match(/\((bm[dk]?_\S+)\)\s*$/); + return match ? match[1] : null; +} + // Resolve benchmark name to ID if needed async function resolveBenchmarkId(benchmarkIdOrName: string): Promise { - // If it looks like an ID (starts with bm_ or similar), return as-is - if ( - benchmarkIdOrName.startsWith("bm_") || - benchmarkIdOrName.startsWith("bmk_") - ) { + // If it looks like a bare ID, return as-is + if (looksLikeBenchmarkId(benchmarkIdOrName)) { return benchmarkIdOrName; } + // If the input has an embedded ID like "Name (bmd_xxx)", extract and use it + const embeddedId = extractEmbeddedId(benchmarkIdOrName); + if (embeddedId) { + return embeddedId; + } + // Search both user benchmarks and public benchmarks const [userResult, publicResult] = await Promise.all([ listBenchmarks({ diff --git a/src/screens/BenchmarkDetailScreen.tsx b/src/screens/BenchmarkDetailScreen.tsx index 4bc7b334..9d518b4f 100644 --- a/src/screens/BenchmarkDetailScreen.tsx +++ b/src/screens/BenchmarkDetailScreen.tsx @@ -176,10 +176,10 @@ export function BenchmarkDetailScreen({ const operations: ResourceOperation[] = [ { key: "create-job", - label: "Create Benchmark Job", + label: "Run Benchmark Job", color: colors.success, icon: figures.play, - shortcut: "c", + shortcut: "r", }, ]; diff --git a/src/screens/BenchmarkJobListScreen.tsx b/src/screens/BenchmarkJobListScreen.tsx index 83475e0b..108476c1 100644 --- a/src/screens/BenchmarkJobListScreen.tsx +++ b/src/screens/BenchmarkJobListScreen.tsx @@ -598,7 +598,7 @@ export function BenchmarkJobListScreen() { data={benchmarkJobs} keyExtractor={(job: BenchmarkJob) => job.id} selectedIndex={selectedIndex} - title={`benchmark_jobs[${totalCount}]`} + title={`benchmark_jobs[${totalCount}${hasMore ? "+" : ""}]`} columns={columns} emptyState={ @@ -613,12 +613,13 @@ export function BenchmarkJobListScreen() { {figures.hamburger} {totalCount} + {hasMore ? "+" : ""} {" "} total - {totalPages > 1 && ( + {(hasMore || hasPrev) && ( <> {" "} @@ -630,7 +631,8 @@ export function BenchmarkJobListScreen() { ) : ( - Page {currentPage + 1} of {totalPages} + Page {currentPage + 1} + {!hasMore ? ` of ${totalPages}` : ""} )} @@ -640,7 +642,8 @@ export function BenchmarkJobListScreen() { •{" "} - Showing {startIndex + 1}-{endIndex} of {totalCount} + Showing {startIndex + 1}-{endIndex} + {!hasMore ? ` of ${totalCount}` : ""} )} diff --git a/src/screens/BenchmarkListScreen.tsx b/src/screens/BenchmarkListScreen.tsx index fe953b30..7e753e68 100644 --- a/src/screens/BenchmarkListScreen.tsx +++ b/src/screens/BenchmarkListScreen.tsx @@ -24,15 +24,32 @@ import { useViewportHeight } from "../hooks/useViewportHeight.js"; import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js"; import { useCursorPagination } from "../hooks/useCursorPagination.js"; import { useListSearch } from "../hooks/useListSearch.js"; -import { listBenchmarks } from "../services/benchmarkService.js"; +import { + listBenchmarks, + listPublicBenchmarks, +} from "../services/benchmarkService.js"; import type { Benchmark } from "../store/benchmarkStore.js"; export function BenchmarkListScreen() { const { exit: inkExit } = useApp(); - const { navigate, goBack } = useNavigation(); - const [selectedIndex, setSelectedIndex] = React.useState(0); + const { navigate, goBack, params, updateCurrentParams } = useNavigation(); + const [selectedIndex, setSelectedIndex] = React.useState( + params.selectedIndex ? Number(params.selectedIndex) : 0, + ); const [showPopup, setShowPopup] = React.useState(false); const [selectedOperation, setSelectedOperation] = React.useState(0); + const [tab, setTab] = React.useState<"private" | "public">( + params.tab === "public" ? "public" : "private", + ); + const isPublic = tab === "public"; + + // Save current tab and cursor into route params so back-navigation restores them + const saveListState = React.useCallback(() => { + updateCurrentParams({ + tab, + selectedIndex: String(selectedIndex), + }); + }, [updateCurrentParams, tab, selectedIndex]); // Search state const search = useListSearch({ @@ -41,7 +58,7 @@ export function BenchmarkListScreen() { }); // Calculate overhead for viewport height - const overhead = 13 + search.getSearchOverhead(); + const overhead = 14 + search.getSearchOverhead(); const { viewportHeight, terminalWidth } = useViewportHeight({ overhead, minHeight: 5, @@ -61,19 +78,27 @@ export function BenchmarkListScreen() { // Fetch function for pagination hook const fetchPage = React.useCallback( async (params: { limit: number; startingAt?: string }) => { - const result = await listBenchmarks({ + const listFn = isPublic ? listPublicBenchmarks : listBenchmarks; + const result = await listFn({ limit: params.limit, startingAfter: params.startingAt, search: search.submittedSearchQuery || undefined, }); + // Public tab: only show benchmarks with bmj_support metadata + const items = isPublic + ? result.benchmarks.filter((b) => b.metadata?.bmj_support === "true") + : result.benchmarks; + return { - items: result.benchmarks, + items, hasMore: result.hasMore, - totalCount: result.totalCount, + totalCount: isPublic + ? items.length + (result.hasMore ? 1 : 0) + : result.totalCount, }; }, - [search.submittedSearchQuery], + [search.submittedSearchQuery, isPublic], ); // Use the shared pagination hook @@ -94,7 +119,7 @@ export function BenchmarkListScreen() { getItemId: (benchmark: Benchmark) => benchmark.id, pollInterval: 5000, pollingEnabled: !showPopup && !search.searchMode, - deps: [PAGE_SIZE, search.submittedSearchQuery], + deps: [PAGE_SIZE, search.submittedSearchQuery, isPublic], }); // Operations for benchmarks @@ -108,7 +133,7 @@ export function BenchmarkListScreen() { }, { key: "create_job", - label: "Create Benchmark Job", + label: "Run Benchmark Job", color: colors.success, icon: figures.play, }, @@ -209,21 +234,25 @@ export function BenchmarkListScreen() { const operationKey = operations[selectedOperation].key; if (operationKey === "view_details") { + saveListState(); navigate("benchmark-detail", { benchmarkId: selectedBenchmark.id, }); } else if (operationKey === "create_job") { + saveListState(); navigate("benchmark-job-create", { initialBenchmarkIds: selectedBenchmark.id, }); } } else if (input === "v" && selectedBenchmark) { setShowPopup(false); + saveListState(); navigate("benchmark-detail", { benchmarkId: selectedBenchmark.id, }); - } else if (input === "c" && selectedBenchmark) { + } else if (input === "r" && selectedBenchmark) { setShowPopup(false); + saveListState(); navigate("benchmark-job-create", { initialBenchmarkIds: selectedBenchmark.id, }); @@ -258,19 +287,26 @@ export function BenchmarkListScreen() { prevPage(); setSelectedIndex(0); } else if (key.return && selectedBenchmark) { + saveListState(); navigate("benchmark-detail", { benchmarkId: selectedBenchmark.id, }); } else if (input === "a" && selectedBenchmark) { setShowPopup(true); setSelectedOperation(0); - } else if (input === "c" && selectedBenchmark) { - // Quick shortcut to create a job + } else if (input === "r" && selectedBenchmark) { + saveListState(); navigate("benchmark-job-create", { initialBenchmarkIds: selectedBenchmark.id, }); } else if (input === "/") { search.enterSearchMode(); + } else if (input === "1" && tab !== "private") { + setTab("private"); + setSelectedIndex(0); + } else if (input === "2" && tab !== "public") { + setTab("public"); + setSelectedIndex(0); } else if (key.escape) { if (search.handleEscape()) { return; @@ -287,7 +323,10 @@ export function BenchmarkListScreen() { items={[ { label: "Home" }, { label: "Benchmarks" }, - { label: "Benchmark Definitions", active: true }, + { + label: isPublic ? "Public Benchmarks" : "Benchmark Defs", + active: true, + }, ]} /> @@ -303,7 +342,10 @@ export function BenchmarkListScreen() { items={[ { label: "Home" }, { label: "Benchmarks" }, - { label: "Benchmark Definitions", active: true }, + { + label: isPublic ? "Public Benchmarks" : "Benchmark Defs", + active: true, + }, ]} /> @@ -318,10 +360,30 @@ export function BenchmarkListScreen() { items={[ { label: "Home" }, { label: "Benchmarks" }, - { label: "Benchmark Definitions", active: true }, + { + label: isPublic ? "Public Benchmarks" : "Benchmark Defs", + active: true, + }, ]} /> + {/* Tab indicator */} + + + [1] Private + + + + [2] Public + + + {/* Search bar */} {/* Table */} @@ -339,7 +403,7 @@ export function BenchmarkListScreen() { data={benchmarks} keyExtractor={(benchmark: Benchmark) => benchmark.id} selectedIndex={selectedIndex} - title={`benchmarks[${totalCount}]`} + title={`${isPublic ? "public " : ""}benchmarks[${totalCount}${hasMore ? "+" : ""}]`} columns={columns} emptyState={ @@ -354,12 +418,13 @@ export function BenchmarkListScreen() { {figures.hamburger} {totalCount} + {hasMore ? "+" : ""} {" "} total - {totalPages > 1 && ( + {(hasMore || hasPrev) && ( <> {" "} @@ -371,7 +436,8 @@ export function BenchmarkListScreen() { ) : ( - Page {currentPage + 1} of {totalPages} + Page {currentPage + 1} + {!hasMore ? ` of ${totalPages}` : ""} )} @@ -381,7 +447,8 @@ export function BenchmarkListScreen() { •{" "} - Showing {startIndex + 1}-{endIndex} of {totalCount} + Showing {startIndex + 1}-{endIndex} + {!hasMore ? ` of ${totalCount}` : ""} )} @@ -400,7 +467,7 @@ export function BenchmarkListScreen() { op.key === "view_details" ? "v" : op.key === "create_job" - ? "s" + ? "r" : "", }))} selectedOperation={selectedOperation} @@ -418,8 +485,9 @@ export function BenchmarkListScreen() { label: "Page", condition: hasMore || hasPrev, }, + { key: "1/2", label: "Private/Public" }, { key: "Enter", label: "Details" }, - { key: "c", label: "Create Job" }, + { key: "r", label: "Run Benchmark Job" }, { key: "a", label: "Actions" }, { key: "/", label: "Search" }, { key: "Esc", label: "Back" }, diff --git a/src/screens/BenchmarkRunDetailScreen.tsx b/src/screens/BenchmarkRunDetailScreen.tsx index d32ab94a..61796d04 100644 --- a/src/screens/BenchmarkRunDetailScreen.tsx +++ b/src/screens/BenchmarkRunDetailScreen.tsx @@ -19,7 +19,7 @@ import { } from "../components/ResourceDetailPage.js"; import { getBenchmarkRun, - listScenarioRuns, + fetchAllScenarioRuns, } from "../services/benchmarkService.js"; import { useResourceDetail } from "../hooks/useResourceDetail.js"; import { SpinnerComponent } from "../components/Spinner.js"; @@ -60,17 +60,14 @@ export function BenchmarkRunDetailScreen({ const [scenarioRuns, setScenarioRuns] = React.useState([]); const [scenarioRunsLoading, setScenarioRunsLoading] = React.useState(false); - // Fetch scenario runs for this benchmark run + // Fetch all scenario runs for this benchmark run React.useEffect(() => { if (benchmarkRunId && !scenarioRunsLoading && scenarioRuns.length === 0) { setScenarioRunsLoading(true); - listScenarioRuns({ - limit: 10, // Show up to 10 scenarios - benchmarkRunId, - }) - .then((result) => { - setScenarioRuns(result.scenarioRuns); + fetchAllScenarioRuns(benchmarkRunId) + .then((runs) => { + setScenarioRuns(runs); setScenarioRunsLoading(false); }) .catch(() => { @@ -88,12 +85,9 @@ export function BenchmarkRunDetailScreen({ if (run.state !== "running") return; const interval = setInterval(() => { - listScenarioRuns({ - limit: 10, - benchmarkRunId, - }) - .then((result) => { - setScenarioRuns(result.scenarioRuns); + fetchAllScenarioRuns(benchmarkRunId) + .then((runs) => { + setScenarioRuns(runs); }) .catch(() => { // Silently fail @@ -304,8 +298,12 @@ export function BenchmarkRunDetailScreen({ ], }); - // Scenario Runs Section + // Scenario Runs Section (capped to avoid overflowing the terminal) if (scenarioRuns.length > 0) { + const MAX_EMBEDDED_SCENARIOS = 10; + const displayedScenarioRuns = scenarioRuns.slice(0, MAX_EMBEDDED_SCENARIOS); + const hasHiddenScenarios = scenarioRuns.length > MAX_EMBEDDED_SCENARIOS; + // Define columns for scenario table const scenarioColumns = [ createTextColumn("id", "ID", (s: ScenarioRun) => s.id, { @@ -362,13 +360,19 @@ export function BenchmarkRunDetailScreen({ { label: "", value: ( - + s.id} /> + {hasHiddenScenarios && ( + + Showing {MAX_EMBEDDED_SCENARIOS} of {scenarioRuns.length}{" "} + {"— press 's' to view all"} + + )} ), }, diff --git a/src/screens/BenchmarkRunListScreen.tsx b/src/screens/BenchmarkRunListScreen.tsx index dadd0d9a..2f5f1dd3 100644 --- a/src/screens/BenchmarkRunListScreen.tsx +++ b/src/screens/BenchmarkRunListScreen.tsx @@ -340,7 +340,7 @@ export function BenchmarkRunListScreen() { data={benchmarkRuns} keyExtractor={(run: BenchmarkRun) => run.id} selectedIndex={selectedIndex} - title={`benchmark_runs[${totalCount}]`} + title={`benchmark_runs[${totalCount}${hasMore ? "+" : ""}]`} columns={columns} emptyState={ @@ -355,12 +355,13 @@ export function BenchmarkRunListScreen() { {figures.hamburger} {totalCount} + {hasMore ? "+" : ""} {" "} total - {totalPages > 1 && ( + {(hasMore || hasPrev) && ( <> {" "} @@ -372,7 +373,8 @@ export function BenchmarkRunListScreen() { ) : ( - Page {currentPage + 1} of {totalPages} + Page {currentPage + 1} + {!hasMore ? ` of ${totalPages}` : ""} )} @@ -382,7 +384,8 @@ export function BenchmarkRunListScreen() { •{" "} - Showing {startIndex + 1}-{endIndex} of {totalCount} + Showing {startIndex + 1}-{endIndex} + {!hasMore ? ` of ${totalCount}` : ""} )} diff --git a/src/screens/ScenarioRunListScreen.tsx b/src/screens/ScenarioRunListScreen.tsx index df5543b6..b3dbb0fb 100644 --- a/src/screens/ScenarioRunListScreen.tsx +++ b/src/screens/ScenarioRunListScreen.tsx @@ -24,7 +24,10 @@ import { useViewportHeight } from "../hooks/useViewportHeight.js"; import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js"; import { useCursorPagination } from "../hooks/useCursorPagination.js"; import { useListSearch } from "../hooks/useListSearch.js"; -import { listScenarioRuns } from "../services/benchmarkService.js"; +import { + listScenarioRuns, + fetchAllScenarioRuns, +} from "../services/benchmarkService.js"; import type { ScenarioRun } from "../store/benchmarkStore.js"; interface ScenarioRunListScreenProps { @@ -65,15 +68,66 @@ export function ScenarioRunListScreen({ const remainingWidth = terminalWidth - baseWidth; const nameWidth = Math.min(60, Math.max(15, remainingWidth)); - // Fetch function for pagination hook + // --- Client-side pagination (when benchmarkRunId is provided) --- + // Fetches all scenario runs upfront for accurate total count and cached page navigation. + const [allScenarioRuns, setAllScenarioRuns] = React.useState( + [], + ); + const [allRunsLoaded, setAllRunsLoaded] = React.useState(false); + const [allRunsError, setAllRunsError] = React.useState(null); + const [clientPage, setClientPage] = React.useState(0); + + React.useEffect(() => { + if (!benchmarkRunId) return; + let cancelled = false; + + fetchAllScenarioRuns(benchmarkRunId) + .then((runs) => { + if (!cancelled) { + setAllScenarioRuns(runs); + setAllRunsLoaded(true); + } + }) + .catch((err) => { + if (!cancelled) { + setAllRunsError(err as Error); + setAllRunsLoaded(true); + } + }); + + return () => { + cancelled = true; + }; + }, [benchmarkRunId]); + + // Poll to refresh the client-side cache + React.useEffect(() => { + if (!benchmarkRunId || showPopup || search.searchMode) return; + + const interval = setInterval(() => { + fetchAllScenarioRuns(benchmarkRunId) + .then((runs) => setAllScenarioRuns(runs)) + .catch(() => {}); + }, 5000); + + return () => clearInterval(interval); + }, [benchmarkRunId, showPopup, search.searchMode]); + + // Reset client page when page size changes + React.useEffect(() => { + if (benchmarkRunId) setClientPage(0); + }, [PAGE_SIZE, benchmarkRunId]); + + // --- Cursor-based pagination (when no benchmarkRunId) --- const fetchPage = React.useCallback( async (params: { limit: number; startingAt?: string }) => { + if (benchmarkRunId) { + return { items: [] as ScenarioRun[], hasMore: false }; + } const result = await listScenarioRuns({ limit: params.limit, startingAfter: params.startingAt, - benchmarkRunId, }); - return { items: result.scenarioRuns, hasMore: result.hasMore, @@ -83,28 +137,51 @@ export function ScenarioRunListScreen({ [benchmarkRunId], ); - // Use the shared pagination hook - const { - items: scenarioRuns, - loading, - navigating, - error, - currentPage, - hasMore, - hasPrev, - totalCount, - nextPage, - prevPage, - refresh, - } = useCursorPagination({ + const cursor = useCursorPagination({ fetchPage, pageSize: PAGE_SIZE, getItemId: (run: ScenarioRun) => run.id, - pollInterval: 5000, - pollingEnabled: !showPopup && !search.searchMode, + pollInterval: benchmarkRunId ? 0 : 5000, + pollingEnabled: !benchmarkRunId && !showPopup && !search.searchMode, deps: [PAGE_SIZE, benchmarkRunId], }); + // --- Unified pagination interface --- + const useClientSide = !!benchmarkRunId; + const clientPageItems = allScenarioRuns.slice( + clientPage * PAGE_SIZE, + (clientPage + 1) * PAGE_SIZE, + ); + + const scenarioRuns = useClientSide ? clientPageItems : cursor.items; + const loading = useClientSide + ? !allRunsLoaded && allScenarioRuns.length === 0 + : cursor.loading; + const navigating = useClientSide ? false : cursor.navigating; + const error = useClientSide ? allRunsError : cursor.error; + const currentPage = useClientSide ? clientPage : cursor.currentPage; + const hasMore = useClientSide + ? (clientPage + 1) * PAGE_SIZE < allScenarioRuns.length + : cursor.hasMore; + const hasPrev = useClientSide ? clientPage > 0 : cursor.hasPrev; + const totalCount = useClientSide ? allScenarioRuns.length : cursor.totalCount; + + const nextPage = () => { + if (useClientSide) { + setClientPage((p) => p + 1); + } else { + cursor.nextPage(); + } + }; + + const prevPage = () => { + if (useClientSide) { + setClientPage((p) => Math.max(0, p - 1)); + } else { + cursor.prevPage(); + } + }; + // Operations for scenario runs const operations: Operation[] = React.useMemo( () => [ @@ -191,6 +268,7 @@ export function ScenarioRunListScreen({ const selectedRun = scenarioRuns[selectedIndex]; // Calculate pagination info for display + const totalIsExact = useClientSide || !hasMore; const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE)); const startIndex = currentPage * PAGE_SIZE; const endIndex = startIndex + scenarioRuns.length; @@ -328,7 +406,7 @@ export function ScenarioRunListScreen({ data={scenarioRuns} keyExtractor={(run: ScenarioRun) => run.id} selectedIndex={selectedIndex} - title={`scenario_runs[${totalCount}]`} + title={`scenario_runs[${totalCount}${!totalIsExact ? "+" : ""}]`} columns={columns} emptyState={ @@ -343,12 +421,13 @@ export function ScenarioRunListScreen({ {figures.hamburger} {totalCount} + {!totalIsExact ? "+" : ""} {" "} total - {totalPages > 1 && ( + {(hasMore || hasPrev) && ( <> {" "} @@ -360,7 +439,8 @@ export function ScenarioRunListScreen({ ) : ( - Page {currentPage + 1} of {totalPages} + Page {currentPage + 1} + {totalIsExact ? ` of ${totalPages}` : ""} )} @@ -370,7 +450,8 @@ export function ScenarioRunListScreen({ •{" "} - Showing {startIndex + 1}-{endIndex} of {totalCount} + Showing {startIndex + 1}-{endIndex} + {totalIsExact ? ` of ${totalCount}` : ""} {benchmarkRunId && ( <> diff --git a/src/services/benchmarkService.ts b/src/services/benchmarkService.ts index e6373464..5c7f6213 100644 --- a/src/services/benchmarkService.ts +++ b/src/services/benchmarkService.ts @@ -129,6 +129,29 @@ export async function listScenarioRuns( }; } +/** + * Fetch all scenario runs for a benchmark run, paginating through all results. + */ +export async function fetchAllScenarioRuns( + benchmarkRunId: string, +): Promise { + const allRuns: ScenarioRun[] = []; + let startingAfter: string | undefined; + + while (true) { + const result = await listScenarioRuns({ + limit: 100, + startingAfter, + benchmarkRunId, + }); + allRuns.push(...result.scenarioRuns); + if (!result.hasMore || result.scenarioRuns.length === 0) break; + startingAfter = result.scenarioRuns[result.scenarioRuns.length - 1].id; + } + + return allRuns; +} + /** * Get scenario run by ID */ diff --git a/src/store/navigationStateMachine.ts b/src/store/navigationStateMachine.ts index a67b4687..9104737d 100644 --- a/src/store/navigationStateMachine.ts +++ b/src/store/navigationStateMachine.ts @@ -105,6 +105,18 @@ export function reset(_state: NavigationState): NavigationState { }; } +/** + * Merge additional params into the current screen's params without navigating. + * Useful for preserving UI state (e.g. tab, cursor position) before navigating + * away, so that goBack() restores the screen with the correct state. + */ +export function updateCurrentParams( + state: NavigationState, + params: RouteParams, +): NavigationState { + return { ...state, params: { ...state.params, ...params } }; +} + /** * Whether there is a previous screen to go back to. */ diff --git a/src/store/navigationStore.tsx b/src/store/navigationStore.tsx index f9eb3765..26288205 100644 --- a/src/store/navigationStore.tsx +++ b/src/store/navigationStore.tsx @@ -7,6 +7,7 @@ import { goBack as navGoBack, reset as navReset, canGoBack as navCanGoBack, + updateCurrentParams as navUpdateCurrentParams, } from "./navigationStateMachine.js"; import type { NavigationState } from "./navigationStateMachine.js"; @@ -91,6 +92,7 @@ interface NavigationContextValue { goBack: () => void; reset: () => void; canGoBack: () => boolean; + updateCurrentParams: (params: RouteParams) => void; } const NavigationContext = React.createContext( @@ -153,6 +155,10 @@ export function NavigationProvider({ [state.history.length], ); + const updateCurrentParams = React.useCallback((newParams: RouteParams) => { + setState((prev) => navUpdateCurrentParams(prev, newParams)); + }, []); + const value = React.useMemo( () => ({ currentScreen: state.currentScreen, @@ -163,6 +169,7 @@ export function NavigationProvider({ goBack, reset, canGoBack, + updateCurrentParams, }), [ state.currentScreen, @@ -173,6 +180,7 @@ export function NavigationProvider({ goBack, reset, canGoBack, + updateCurrentParams, ], ); diff --git a/tests/__tests__/store/navigationStateMachine.test.ts b/tests/__tests__/store/navigationStateMachine.test.ts index a5d8212c..99497196 100644 --- a/tests/__tests__/store/navigationStateMachine.test.ts +++ b/tests/__tests__/store/navigationStateMachine.test.ts @@ -12,6 +12,7 @@ import { goBack, reset, canGoBack, + updateCurrentParams, } from "../../../src/store/navigationStateMachine.js"; import type { ScreenName, RouteParams } from "../../../src/store/navigationStore.js"; @@ -171,6 +172,31 @@ describe("navigationStateMachine", () => { }); }); + describe("updateCurrentParams", () => { + it("merges new params into current params without changing screen or history", () => { + let state = navigate(initialNavigationState, "benchmark-list" as ScreenName, { + benchmarkId: "bm_1", + } as RouteParams); + state = updateCurrentParams(state, { tab: "public", selectedIndex: "3" } as RouteParams); + expect(state.currentScreen).toBe("benchmark-list"); + expect(state.params.benchmarkId).toBe("bm_1"); + expect(state.params.tab).toBe("public"); + expect(state.params.selectedIndex).toBe("3"); + expect(state.history).toHaveLength(1); + }); + + it("updated params are captured in history on subsequent navigate", () => { + let state = navigate(initialNavigationState, "benchmark-list" as ScreenName, {}); + state = updateCurrentParams(state, { tab: "public" } as RouteParams); + state = navigate(state, "benchmark-detail" as ScreenName, { benchmarkId: "bm_1" } as RouteParams); + // History should contain the updated params from benchmark-list + expect(state.history[1]?.params.tab).toBe("public"); + state = goBack(state); + expect(state.currentScreen).toBe("benchmark-list"); + expect(state.params.tab).toBe("public"); + }); + }); + describe("canGoBack", () => { it("returns false when history is empty", () => { expect(canGoBack(initialNavigationState)).toBe(false);