From 1d27933eb8ce29c78730b0d71149e7fa72028ec7 Mon Sep 17 00:00:00 2001 From: Oskar Date: Fri, 5 Dec 2025 10:24:26 +0100 Subject: [PATCH 1/6] feat(webapp): persist runs table filters via tableState URL parameter - Store current filter state from runs table as `tableState` search param when navigating to individual run pages - Restore filters when navigating back from run detail view to runs list - Update `v3RunPath` and `v3RunSpanPath` helpers to accept optional searchParams - Use `useOptimisticLocation` to capture current search params in TaskRunsTable - Parse `tableState` param in run detail route and pass filters to back button This improves UX by remembering filter selections (task, status, date range, etc.) when users click into a run and then navigate back to the runs list. --- .../app/components/runs/v3/TaskRunsTable.tsx | 11 +++++++++-- .../route.tsx | 7 ++++++- apps/webapp/app/utils/pathBuilder.ts | 15 ++++++++++----- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx index 60017c69fb..8ba5591718 100644 --- a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx +++ b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx @@ -55,6 +55,7 @@ import { filterableTaskRunStatuses, TaskRunStatusCombo, } from "./TaskRunStatus"; +import { useOptimisticLocation } from "~/hooks/useOptimisticLocation"; type RunsTableProps = { total: number; @@ -81,6 +82,8 @@ export function TaskRunsTable({ const checkboxes = useRef<(HTMLInputElement | null)[]>([]); const { has, hasAll, select, deselect, toggle } = useSelectedItems(allowSelection); const { isManagedCloud } = useFeatures(); + const location = useOptimisticLocation(); + const tableStateParam = encodeURIComponent(location.search); const showCompute = isManagedCloud; @@ -293,16 +296,20 @@ export function TaskRunsTable({ ) : ( runs.map((run, index) => { + const searchParams = new URLSearchParams(); + if (tableStateParam) { + searchParams.set("tableState", tableStateParam); + } const path = v3RunSpanPath(organization, project, run.environment, run, { spanId: run.spanId, - }); + }, searchParams); return ( {allowSelection && ( { + onChange={() => { toggle(run.friendlyId); }} ref={(r) => { diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx index 79ab0b8e5b..4cfb3f378f 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx @@ -98,6 +98,7 @@ import { SpanView } from "../resources.orgs.$organizationSlug.projects.$projectP import { useSearchParams } from "~/hooks/useSearchParam"; import { CopyableText } from "~/components/primitives/CopyableText"; import type { SpanOverride } from "~/v3/eventRepository/eventRepository.types"; +import { getRunFiltersFromSearchParams } from "~/components/runs/v3/RunFilters"; const resizableSettings = { parent: { @@ -191,13 +192,17 @@ export default function Page() { logCount: trace?.events.length ?? 0, isCompleted: run.completedAt !== null, }); + const { value } = useSearchParams(); + const params = decodeURIComponent(value("tableState") ?? ""); + const searchParams = new URLSearchParams(params); + const filters = getRunFiltersFromSearchParams(searchParams); return ( <> } diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index f82165ae9d..a9a4bab906 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -288,15 +288,17 @@ export function v3RunPath( organization: OrgForPath, project: ProjectForPath, environment: EnvironmentForPath, - run: v3RunForPath + run: v3RunForPath, + searchParams?: URLSearchParams ) { - return `${v3RunsPath(organization, project, environment)}/${run.friendlyId}`; + const query = searchParams ? `?${searchParams.toString()}` : ""; + return `${v3RunsPath(organization, project, environment)}/${run.friendlyId}${query}`; } export function v3RunRedirectPath( organization: OrgForPath, project: ProjectForPath, - run: v3RunForPath + run: v3RunForPath, ) { return `${v3ProjectPath(organization, project)}/runs/${run.friendlyId}`; } @@ -310,9 +312,12 @@ export function v3RunSpanPath( project: ProjectForPath, environment: EnvironmentForPath, run: v3RunForPath, - span: v3SpanForPath + span: v3SpanForPath, + searchParams?: URLSearchParams ) { - return `${v3RunPath(organization, project, environment, run)}?span=${span.spanId}`; + searchParams = searchParams ?? new URLSearchParams(); + searchParams.set("span", encodeURIComponent(span.spanId)); + return `${v3RunPath(organization, project, environment, run, searchParams)}`; } export function v3RunStreamingPath( From 1e3a2da94c348357031abb6512f7934c892d37fe Mon Sep 17 00:00:00 2001 From: Oskar Date: Fri, 5 Dec 2025 11:19:11 +0100 Subject: [PATCH 2/6] feat(CopyableText): Add text-below variant with click-to-copy tooltip Add new text-below variant that shows "Click to copy" tooltip on hover and "Copied" on click. Also add controlled open/onOpenChange props to SimpleTooltip for managing tooltip visibility. --- .../components/primitives/CopyableText.tsx | 108 ++++++++++++------ .../app/components/primitives/Tooltip.tsx | 8 +- .../route.tsx | 2 +- 3 files changed, 78 insertions(+), 40 deletions(-) diff --git a/apps/webapp/app/components/primitives/CopyableText.tsx b/apps/webapp/app/components/primitives/CopyableText.tsx index 67e01af795..ac43f30c76 100644 --- a/apps/webapp/app/components/primitives/CopyableText.tsx +++ b/apps/webapp/app/components/primitives/CopyableText.tsx @@ -9,53 +9,87 @@ export function CopyableText({ copyValue, className, asChild, + variant, }: { value: string; copyValue?: string; className?: string; asChild?: boolean; + variant?: "icon-right" | "text-below"; }) { const [isHovered, setIsHovered] = useState(false); const { copy, copied } = useCopy(copyValue ?? value); - return ( - setIsHovered(false)} - > - setIsHovered(true)}>{value} + variant = variant ?? "icon-right"; + + if (variant === "icon-right") { + return ( e.stopPropagation()} - className={cn( - "absolute -right-6 top-0 z-10 size-6 font-sans", - isHovered ? "flex" : "hidden" - )} + className={cn("group relative inline-flex h-6 items-center", className)} + onMouseLeave={() => setIsHovered(false)} > - - {copied ? ( - - ) : ( - - )} - - } - content={copied ? "Copied!" : "Copy"} - className="font-sans" - disableHoverableContent - asChild={asChild} - /> + setIsHovered(true)}>{value} + e.stopPropagation()} + className={cn( + "absolute -right-6 top-0 z-10 size-6 font-sans", + isHovered ? "flex" : "hidden" + )} + > + + {copied ? ( + + ) : ( + + )} + + } + content={copied ? "Copied!" : "Copy"} + className="font-sans" + disableHoverableContent + asChild={asChild} + /> + - - ); + ); + } + + if (variant === "text-below") { + return ( + { + e.stopPropagation(); + copy(); + }} + className={cn( + "cursor-pointer text-text-bright transition-colors hover:text-white", + className + )} + > + {value} + + } + content={copied ? "Copied" : "Click to copy"} + className="font-sans px-2 py-1" + disableHoverableContent + open={isHovered || copied} + onOpenChange={setIsHovered} + /> + ); + } + + return null; } diff --git a/apps/webapp/app/components/primitives/Tooltip.tsx b/apps/webapp/app/components/primitives/Tooltip.tsx index 15dd72894a..5c681927b5 100644 --- a/apps/webapp/app/components/primitives/Tooltip.tsx +++ b/apps/webapp/app/components/primitives/Tooltip.tsx @@ -6,7 +6,7 @@ import { cn } from "~/utils/cn"; const variantClasses = { basic: "bg-background-bright border border-grid-bright rounded px-3 py-2 text-sm text-text-bright shadow-md fade-in-50", - dark: "bg-background-dimmed border border-grid-bright rounded px-3 py-2 text-sm text-text-bright shadow-md fade-in-50", + dark: "bg-background-dimmed border border-grid-bright rounded px-3 py-2 text-sm text-text-bright shadow-md fade-in-50" }; type Variant = keyof typeof variantClasses; @@ -64,6 +64,8 @@ function SimpleTooltip({ buttonStyle, asChild = false, sideOffset, + open, + onOpenChange, }: { button: React.ReactNode; content: React.ReactNode; @@ -76,10 +78,12 @@ function SimpleTooltip({ buttonStyle?: React.CSSProperties; asChild?: boolean; sideOffset?: number; + open?: boolean; + onOpenChange?: (open: boolean) => void; }) { return ( - + } + title={} /> {environment.type === "DEVELOPMENT" && } From 3be6a6b78e8b98dfb801aa2fe9144c24becf72f8 Mon Sep 17 00:00:00 2001 From: Oskar Date: Fri, 5 Dec 2025 17:01:58 +0100 Subject: [PATCH 3/6] feat(webapp): Add navigation for adjacent runs for Run page (with keyboard shortcuts) Add previous/next run navigation buttons to run detail page header Support [ and ] keyboard shortcuts to jump between adjacent runs Preserve runs table state (filters, pagination) when navigating Preload adjacent page runs at boundaries for seamless navigation Add actions prop to PageTitle component Document shortcut in keyboard shortcuts panel --- apps/webapp/app/components/Shortcuts.tsx | 4 + .../app/components/primitives/PageHeader.tsx | 4 +- .../app/components/runs/v3/TaskRunsTable.tsx | 2 +- .../route.tsx | 230 +++++++++++++++++- 4 files changed, 231 insertions(+), 9 deletions(-) diff --git a/apps/webapp/app/components/Shortcuts.tsx b/apps/webapp/app/components/Shortcuts.tsx index ab328afde7..b21c556595 100644 --- a/apps/webapp/app/components/Shortcuts.tsx +++ b/apps/webapp/app/components/Shortcuts.tsx @@ -134,6 +134,10 @@ function ShortcutContent() { + + + + diff --git a/apps/webapp/app/components/primitives/PageHeader.tsx b/apps/webapp/app/components/primitives/PageHeader.tsx index 7855e241e3..e6d3091db9 100644 --- a/apps/webapp/app/components/primitives/PageHeader.tsx +++ b/apps/webapp/app/components/primitives/PageHeader.tsx @@ -36,9 +36,10 @@ type PageTitleProps = { to: string; text: string; }; + actions?: ReactNode; }; -export function PageTitle({ title, backButton }: PageTitleProps) { +export function PageTitle({ title, backButton, actions }: PageTitleProps) { return (
{backButton && ( @@ -53,6 +54,7 @@ export function PageTitle({ title, backButton }: PageTitleProps) {
)} {title} + {actions &&
{actions}
} ); } diff --git a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx index 8ba5591718..c0ae8d2f62 100644 --- a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx +++ b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx @@ -83,7 +83,7 @@ export function TaskRunsTable({ const { has, hasAll, select, deselect, toggle } = useSelectedItems(allowSelection); const { isManagedCloud } = useFeatures(); const location = useOptimisticLocation(); - const tableStateParam = encodeURIComponent(location.search); + const tableStateParam = encodeURIComponent(location.search ? `${location.search}&rt=1` : "rt=1"); const showCompute = isManagedCloud; diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx index 2f780a14ba..a1de8026b0 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx @@ -2,6 +2,7 @@ import { ArrowUturnLeftIcon, BoltSlashIcon, BookOpenIcon, + ChevronUpIcon, ChevronDownIcon, ChevronRightIcon, InformationCircleIcon, @@ -20,9 +21,9 @@ import { nanosecondsToMilliseconds, tryCatch, } from "@trigger.dev/core/v3"; -import type { RuntimeEnvironmentType } from "@trigger.dev/database"; +import type { $Enums, RuntimeEnvironmentType } from "@trigger.dev/database"; import { motion } from "framer-motion"; -import { useCallback, useEffect, useRef, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { redirect } from "remix-typedjson"; import { MoveToTopIcon } from "~/assets/icons/MoveToTopIcon"; @@ -68,7 +69,6 @@ import { eventBorderClassName, } from "~/components/runs/v3/SpanTitle"; import { TaskRunStatusIcon, runStatusClassNameColor } from "~/components/runs/v3/TaskRunStatus"; -import { env } from "~/env.server"; import { useDebounce } from "~/hooks/useDebounce"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useEventSource } from "~/hooks/useEventSource"; @@ -88,6 +88,7 @@ import { docsPath, v3BillingPath, v3RunParamsSchema, + v3RunPath, v3RunRedirectPath, v3RunSpanPath, v3RunStreamingPath, @@ -99,6 +100,11 @@ import { useSearchParams } from "~/hooks/useSearchParam"; import { CopyableText } from "~/components/primitives/CopyableText"; import type { SpanOverride } from "~/v3/eventRepository/eventRepository.types"; import { getRunFiltersFromSearchParams } from "~/components/runs/v3/RunFilters"; +import { NextRunListPresenter } from "~/presenters/v3/NextRunListPresenter.server"; +import { $replica } from "~/db.server"; +import { clickhouseClient } from "~/services/clickhouseInstance.server"; +import { findProjectBySlug } from "~/models/project.server"; +import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; const resizableSettings = { parent: { @@ -170,6 +176,82 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const parent = await getResizableSnapshot(request, resizableSettings.parent.autosaveId); const tree = await getResizableSnapshot(request, resizableSettings.tree.autosaveId); + // Load runs list data from tableState if present + let runsList: { + runs: Array<{ friendlyId: string }>; + pagination: { next?: string; previous?: string }; + prevPageLastRun?: { friendlyId: string; cursor: string }; + nextPageFirstRun?: { friendlyId: string; cursor: string }; + } | null = null; + const tableStateParam = url.searchParams.get("tableState"); + if (tableStateParam) { + try { + const tableStateSearchParams = new URLSearchParams(decodeURIComponent(tableStateParam)); + const filters = getRunFiltersFromSearchParams(tableStateSearchParams); + + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + const environment = await findEnvironmentBySlug(project?.id ?? "", envParam, userId); + + if (project && environment) { + const runsListPresenter = new NextRunListPresenter($replica, clickhouseClient); + const currentPageResult = await runsListPresenter.call(project.organizationId, environment.id, { + userId, + projectId: project.id, + ...filters, + pageSize: 25, // Load enough runs to provide navigation context + }); + + runsList = { + runs: currentPageResult.runs, + pagination: currentPageResult.pagination, + }; + + // Check if the current run is at the boundary and preload adjacent page if needed + const currentRunIndex = currentPageResult.runs.findIndex((r) => r.friendlyId === runParam); + + // If current run is first in list and there's a previous page, load the last run from prev page + if (currentRunIndex === 0 && currentPageResult.pagination.previous) { + const prevPageResult = await runsListPresenter.call(project.organizationId, environment.id, { + userId, + projectId: project.id, + ...filters, + cursor: currentPageResult.pagination.previous, + direction: "backward", + pageSize: 1, // We only need the last run from the previous page + }); + if (prevPageResult.runs.length > 0) { + runsList.prevPageLastRun = { + friendlyId: prevPageResult.runs[0].friendlyId, + cursor: currentPageResult.pagination.previous, + }; + } + } + + // If current run is last in list and there's a next page, load the first run from next page + if (currentRunIndex === currentPageResult.runs.length - 1 && currentPageResult.pagination.next) { + const nextPageResult = await runsListPresenter.call(project.organizationId, environment.id, { + userId, + projectId: project.id, + ...filters, + cursor: currentPageResult.pagination.next, + direction: "forward", + pageSize: 1, // We only need the first run from the next page + }); + if (nextPageResult.runs.length > 0) { + runsList.nextPageFirstRun = { + friendlyId: nextPageResult.runs[0].friendlyId, + cursor: currentPageResult.pagination.next, + }; + } + } + } + } catch (error) { + // If there's an error parsing or loading runs list, just ignore it + // and don't include the runsList in the response + console.error("Error loading runs list from tableState:", error); + } + } + return json({ run: result.run, trace: result.trace, @@ -178,13 +260,14 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { parent, tree, }, + runsList, }); }; type LoaderData = SerializeFrom; export default function Page() { - const { run, trace, resizable, maximumLiveReloadingSetting } = useLoaderData(); + const { run, trace, resizable, maximumLiveReloadingSetting, runsList } = useLoaderData(); const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); @@ -193,9 +276,11 @@ export default function Page() { isCompleted: run.completedAt !== null, }); const { value } = useSearchParams(); - const params = decodeURIComponent(value("tableState") ?? ""); - const searchParams = new URLSearchParams(params); - const filters = getRunFiltersFromSearchParams(searchParams); + const tableState = decodeURIComponent(value("tableState") ?? ""); + const tableStateSearchParams = new URLSearchParams(tableState); + const filters = getRunFiltersFromSearchParams(tableStateSearchParams); + + const [previousRunPath, nextRunPath] = useAdjacentRunPaths({organization, project, environment, tableStateSearchParams, run, runsList}); return ( <> @@ -206,6 +291,12 @@ export default function Page() { text: "Runs", }} title={} + actions={ + tableState && (
+ + +
) + } /> {environment.type === "DEVELOPMENT" && } @@ -282,6 +373,7 @@ export default function Page() { trace={trace} maximumLiveReloadingSetting={maximumLiveReloadingSetting} resizable={resizable} + runsList={runsList} /> ) : ( )} @@ -1437,6 +1530,7 @@ function KeyboardShortcuts({ return ( <> + expandAllBelowDepth(0)} @@ -1453,6 +1547,17 @@ function KeyboardShortcuts({ ); } +function AdjacentRunsShortcuts({ +}) { + return (
+ + + + Adjacent runs + +
); +} + function ArrowKeyShortcuts() { return (
@@ -1531,3 +1636,114 @@ function SearchField({ onChange }: { onChange: (value: string) => void }) { /> ); } + +function useAdjacentRunPaths({ + organization, + project, + environment, + tableStateSearchParams, + run, + runsList, +}: { + organization: { slug: string }; + project: { slug: string }; + environment: { slug: string }; + tableStateSearchParams: URLSearchParams; + run: { friendlyId: string }; + runsList: { + runs: Array<{ friendlyId: string }>; + pagination: { next?: string; previous?: string }; + prevPageLastRun?: { friendlyId: string; cursor: string }; + nextPageFirstRun?: { friendlyId: string; cursor: string }; + } | null; +}): [string | null, string | null] { + return React.useMemo(() => { + if (!runsList || runsList.runs.length === 0) { + return [null, null]; + } + + const currentIndex = runsList.runs.findIndex((r) => r.friendlyId === run.friendlyId); + + if (currentIndex === -1) { + return [null, null]; + } + + // Determine previous run: use prevPageLastRun if at first position, otherwise use previous run in list + let previousRun: { friendlyId: string } | null = null; + const previousRunTableState = new URLSearchParams(tableStateSearchParams.toString()); + if (currentIndex > 0) { + previousRun = runsList.runs[currentIndex - 1]; + } else if (runsList.prevPageLastRun) { + previousRun = runsList.prevPageLastRun; + // Update tableState with the new cursor for the previous page + previousRunTableState.set("cursor", runsList.prevPageLastRun.cursor); + previousRunTableState.set("direction", "backward"); + } + + // Determine next run: use nextPageFirstRun if at last position, otherwise use next run in list + let nextRun: { friendlyId: string } | null = null; + const nextRunTableState = new URLSearchParams(tableStateSearchParams.toString()); + if (currentIndex < runsList.runs.length - 1) { + nextRun = runsList.runs[currentIndex + 1]; + } else if (runsList.nextPageFirstRun) { + nextRun = runsList.nextPageFirstRun; + // Update tableState with the new cursor for the next page + nextRunTableState.set("cursor", runsList.nextPageFirstRun.cursor); + nextRunTableState.set("direction", "forward"); + } + + const previousURLSearchParams = new URLSearchParams(); + previousURLSearchParams.set("tableState", previousRunTableState.toString()); + const previousRunPath = previousRun + ? v3RunPath(organization, project, environment, previousRun, previousURLSearchParams) + : null; + + const nextURLSearchParams = new URLSearchParams(); + nextURLSearchParams.set("tableState", nextRunTableState.toString()); + const nextRunPath = nextRun + ? v3RunPath(organization, project, environment, nextRun, nextURLSearchParams) + : null; + + return [previousRunPath, nextRunPath]; + }, [organization, project, environment, tableStateSearchParams, run.friendlyId, runsList]); +} + + +function PreviousRunButton({ to }: { to: string | null }) { + return ( +
+ !to && e.preventDefault()} + shortcut={{ key: "[" }} + tooltip="Previous Run" + /> +
+ ); +} + +function NextRunButton({ to }: { to: string | null }) { + return ( +
+ !to && e.preventDefault()} + shortcut={{ key: "]" }} + tooltip="Next Run" + /> +
+ ); +} + From 6a4a61e120a10ef91dc11646f9cab213fe05f049 Mon Sep 17 00:00:00 2001 From: Oskar Date: Fri, 5 Dec 2025 21:00:51 +0100 Subject: [PATCH 4/6] feat(webapp): UI improvements, PR feedback changes --- .../app/components/primitives/Buttons.tsx | 2 +- .../app/components/primitives/CopyableText.tsx | 16 +++++++++------- .../app/components/primitives/PageHeader.tsx | 4 +--- .../app/components/primitives/ShortcutKey.tsx | 6 +++--- .../route.tsx | 17 +++++++++-------- apps/webapp/app/utils/pathBuilder.ts | 2 +- 6 files changed, 24 insertions(+), 23 deletions(-) diff --git a/apps/webapp/app/components/primitives/Buttons.tsx b/apps/webapp/app/components/primitives/Buttons.tsx index 47c8d3d674..e7767b2bc7 100644 --- a/apps/webapp/app/components/primitives/Buttons.tsx +++ b/apps/webapp/app/components/primitives/Buttons.tsx @@ -408,7 +408,7 @@ export const NavLinkButton = ({ to, className, target, ...props }: NavLinkPropsT return ( {({ isActive, isPending }) => ( diff --git a/apps/webapp/app/components/primitives/CopyableText.tsx b/apps/webapp/app/components/primitives/CopyableText.tsx index ac43f30c76..9284897be5 100644 --- a/apps/webapp/app/components/primitives/CopyableText.tsx +++ b/apps/webapp/app/components/primitives/CopyableText.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { SimpleTooltip } from "~/components/primitives/Tooltip"; import { useCopy } from "~/hooks/useCopy"; import { cn } from "~/utils/cn"; +import { Button } from "./Buttons"; export function CopyableText({ value, @@ -20,9 +21,9 @@ export function CopyableText({ const [isHovered, setIsHovered] = useState(false); const { copy, copied } = useCopy(copyValue ?? value); - variant = variant ?? "icon-right"; + const resolvedVariant = variant ?? "icon-right"; - if (variant === "icon-right") { + if (resolvedVariant === "icon-right") { return ( { e.stopPropagation(); copy(); }} className={cn( - "cursor-pointer text-text-bright transition-colors hover:text-white", + "cursor-pointer bg-transparent py-0 px-1 text-left text-text-bright transition-colors hover:text-white hover:bg-transparent", className )} > - {value} - + {value} + } content={copied ? "Copied" : "Click to copy"} className="font-sans px-2 py-1" diff --git a/apps/webapp/app/components/primitives/PageHeader.tsx b/apps/webapp/app/components/primitives/PageHeader.tsx index e6d3091db9..7855e241e3 100644 --- a/apps/webapp/app/components/primitives/PageHeader.tsx +++ b/apps/webapp/app/components/primitives/PageHeader.tsx @@ -36,10 +36,9 @@ type PageTitleProps = { to: string; text: string; }; - actions?: ReactNode; }; -export function PageTitle({ title, backButton, actions }: PageTitleProps) { +export function PageTitle({ title, backButton }: PageTitleProps) { return (
{backButton && ( @@ -54,7 +53,6 @@ export function PageTitle({ title, backButton, actions }: PageTitleProps) {
)} {title} - {actions &&
{actions}
}
); } diff --git a/apps/webapp/app/components/primitives/ShortcutKey.tsx b/apps/webapp/app/components/primitives/ShortcutKey.tsx index 04b1f36737..567cf68d61 100644 --- a/apps/webapp/app/components/primitives/ShortcutKey.tsx +++ b/apps/webapp/app/components/primitives/ShortcutKey.tsx @@ -9,11 +9,11 @@ import { useOperatingSystem } from "./OperatingSystemProvider"; import { KeyboardEnterIcon } from "~/assets/icons/KeyboardEnterIcon"; const medium = - "text-[0.75rem] font-medium min-w-[17px] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1.5 border border-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-dimmed/60 transition uppercase"; + "justify-center min-w-[1.25rem] min-h-[1.25rem] text-[0.65rem] font-mono font-medium rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1.5 border border-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-dimmed/60 transition uppercase"; export const variants = { small: - "text-[0.6rem] font-medium min-w-[17px] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1 border border-text-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-text-dimmed/60 transition uppercase", + "justify-center text-[0.6rem] font-mono font-medium min-w-[1rem] min-h-[1rem] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1 border border-text-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-text-dimmed/60 transition uppercase", medium: cn(medium, "group-hover:border-charcoal-550"), "medium/bright": cn(medium, "bg-charcoal-750 text-text-bright border-charcoal-650"), }; @@ -57,7 +57,7 @@ export function ShortcutKey({ shortcut, variant, className }: ShortcutKeyProps) function keyString(key: string, isMac: boolean, variant: "small" | "medium" | "medium/bright") { key = key.toLowerCase(); - const className = variant === "small" ? "w-2.5 h-4" : "w-3 h-5"; + const className = variant === "small" ? "w-2.5 h-4" : "w-2.5 h-4.5"; switch (key) { case "enter": diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx index a1de8026b0..757a94a427 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx @@ -290,13 +290,13 @@ export default function Page() { to: v3RunsPath(organization, project, environment, filters), text: "Runs", }} - title={} - actions={ - tableState && (
+ title={<> + + {tableState && (
-
) - } +
)} + } /> {environment.type === "DEVELOPMENT" && } @@ -1547,8 +1547,7 @@ function KeyboardShortcuts({ ); } -function AdjacentRunsShortcuts({ -}) { +function AdjacentRunsShortcuts() { return (
@@ -1604,7 +1603,7 @@ function NumberShortcuts({ toggleLevel }: { toggleLevel: (depth: number) => void return (
0 - + 9 Toggle level @@ -1723,6 +1722,7 @@ function PreviousRunButton({ to }: { to: string | null }) { onClick={(e) => !to && e.preventDefault()} shortcut={{ key: "[" }} tooltip="Previous Run" + disabled={!to} />
); @@ -1742,6 +1742,7 @@ function NextRunButton({ to }: { to: string | null }) { onClick={(e) => !to && e.preventDefault()} shortcut={{ key: "]" }} tooltip="Next Run" + disabled={!to} />
); diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index a9a4bab906..3061082ed9 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -316,7 +316,7 @@ export function v3RunSpanPath( searchParams?: URLSearchParams ) { searchParams = searchParams ?? new URLSearchParams(); - searchParams.set("span", encodeURIComponent(span.spanId)); + searchParams.set("span", span.spanId); return `${v3RunPath(organization, project, environment, run, searchParams)}`; } From d334d6b5b3c8ea9ed47a546836676e0927014b8b Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Mon, 8 Dec 2025 18:26:58 +0000 Subject: [PATCH 5/6] feat(webapp): Small UI tweaks for task runs Fix text colors, incorporate PR feedback --- apps/webapp/app/components/primitives/Buttons.tsx | 6 +++--- .../app/components/primitives/CopyableText.tsx | 2 +- .../route.tsx | 14 +++++++------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/webapp/app/components/primitives/Buttons.tsx b/apps/webapp/app/components/primitives/Buttons.tsx index e7767b2bc7..67ba3c0924 100644 --- a/apps/webapp/app/components/primitives/Buttons.tsx +++ b/apps/webapp/app/components/primitives/Buttons.tsx @@ -372,7 +372,7 @@ export const LinkButton = ({ {({ isActive, isPending }) => ( diff --git a/apps/webapp/app/components/primitives/CopyableText.tsx b/apps/webapp/app/components/primitives/CopyableText.tsx index 9284897be5..fa02e56472 100644 --- a/apps/webapp/app/components/primitives/CopyableText.tsx +++ b/apps/webapp/app/components/primitives/CopyableText.tsx @@ -81,7 +81,7 @@ export function CopyableText({ className )} > - {value} + {value} } content={copied ? "Copied" : "Click to copy"} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx index 757a94a427..3763753f2b 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx @@ -21,7 +21,7 @@ import { nanosecondsToMilliseconds, tryCatch, } from "@trigger.dev/core/v3"; -import type { $Enums, RuntimeEnvironmentType } from "@trigger.dev/database"; +import type { RuntimeEnvironmentType } from "@trigger.dev/database"; import { motion } from "framer-motion"; import React, { useCallback, useEffect, useRef, useState } from "react"; import { useHotkeys } from "react-hotkeys-hook"; @@ -280,7 +280,7 @@ export default function Page() { const tableStateSearchParams = new URLSearchParams(tableState); const filters = getRunFiltersFromSearchParams(tableStateSearchParams); - const [previousRunPath, nextRunPath] = useAdjacentRunPaths({organization, project, environment, tableStateSearchParams, run, runsList}); + const [previousRunPath, nextRunPath] = useAdjacentRunPaths({organization, project, environment, tableState, run, runsList}); return ( <> @@ -1640,14 +1640,14 @@ function useAdjacentRunPaths({ organization, project, environment, - tableStateSearchParams, + tableState, run, runsList, }: { organization: { slug: string }; project: { slug: string }; environment: { slug: string }; - tableStateSearchParams: URLSearchParams; + tableState: string; run: { friendlyId: string }; runsList: { runs: Array<{ friendlyId: string }>; @@ -1669,7 +1669,7 @@ function useAdjacentRunPaths({ // Determine previous run: use prevPageLastRun if at first position, otherwise use previous run in list let previousRun: { friendlyId: string } | null = null; - const previousRunTableState = new URLSearchParams(tableStateSearchParams.toString()); + const previousRunTableState = new URLSearchParams(tableState); if (currentIndex > 0) { previousRun = runsList.runs[currentIndex - 1]; } else if (runsList.prevPageLastRun) { @@ -1681,7 +1681,7 @@ function useAdjacentRunPaths({ // Determine next run: use nextPageFirstRun if at last position, otherwise use next run in list let nextRun: { friendlyId: string } | null = null; - const nextRunTableState = new URLSearchParams(tableStateSearchParams.toString()); + const nextRunTableState = new URLSearchParams(tableState); if (currentIndex < runsList.runs.length - 1) { nextRun = runsList.runs[currentIndex + 1]; } else if (runsList.nextPageFirstRun) { @@ -1704,7 +1704,7 @@ function useAdjacentRunPaths({ : null; return [previousRunPath, nextRunPath]; - }, [organization, project, environment, tableStateSearchParams, run.friendlyId, runsList]); + }, [organization, project, environment, tableState, run.friendlyId, runsList]); } From 0077814ef0d38d0a62fdd1b061e1341742970d0c Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Tue, 9 Dec 2025 13:02:56 +0000 Subject: [PATCH 6/6] feat(webapp): Code improvements Extract runsList logic as a separate helper function, make PR feedback improvements --- .../route.tsx | 200 ++++++++++-------- 1 file changed, 109 insertions(+), 91 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx index 3763753f2b..5253e56f2e 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx @@ -23,7 +23,7 @@ import { } from "@trigger.dev/core/v3"; import type { RuntimeEnvironmentType } from "@trigger.dev/database"; import { motion } from "framer-motion"; -import React, { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { redirect } from "remix-typedjson"; import { MoveToTopIcon } from "~/assets/icons/MoveToTopIcon"; @@ -105,6 +105,7 @@ import { $replica } from "~/db.server"; import { clickhouseClient } from "~/services/clickhouseInstance.server"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; +import { logger } from "~/services/logger.server"; const resizableSettings = { parent: { @@ -138,6 +139,101 @@ const resizableSettings = { type TraceEvent = NonNullable["trace"]>["events"][0]; +type RunsListNavigation = { + runs: Array<{ friendlyId: string }>; + pagination: { next?: string; previous?: string }; + prevPageLastRun?: { friendlyId: string; cursor: string }; + nextPageFirstRun?: { friendlyId: string; cursor: string }; +}; + +async function getRunsListFromTableState({ + tableStateParam, + organizationSlug, + projectParam, + envParam, + runParam, + userId, +}: { + tableStateParam: string | null; + organizationSlug: string; + projectParam: string; + envParam: string; + runParam: string; + userId: string; +}): Promise { + if (!tableStateParam) { + return null; + } + + try { + const tableStateSearchParams = new URLSearchParams(decodeURIComponent(tableStateParam)); + const filters = getRunFiltersFromSearchParams(tableStateSearchParams); + + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + const environment = await findEnvironmentBySlug(project?.id ?? "", envParam, userId); + + if (!project || !environment) { + return null; + } + + const runsListPresenter = new NextRunListPresenter($replica, clickhouseClient); + const currentPageResult = await runsListPresenter.call(project.organizationId, environment.id, { + userId, + projectId: project.id, + ...filters, + pageSize: 25, // Load enough runs to provide navigation context + }); + + const runsList: RunsListNavigation = { + runs: currentPageResult.runs, + pagination: currentPageResult.pagination, + }; + + const currentRunIndex = currentPageResult.runs.findIndex((r) => r.friendlyId === runParam); + + if (currentRunIndex === 0 && currentPageResult.pagination.previous) { + const prevPageResult = await runsListPresenter.call(project.organizationId, environment.id, { + userId, + projectId: project.id, + ...filters, + cursor: currentPageResult.pagination.previous, + direction: "backward", + pageSize: 1, // We only need the last run from the previous page + }); + + if (prevPageResult.runs.length > 0) { + runsList.prevPageLastRun = { + friendlyId: prevPageResult.runs[0].friendlyId, + cursor: currentPageResult.pagination.previous, + }; + } + } + + if (currentRunIndex === currentPageResult.runs.length - 1 && currentPageResult.pagination.next) { + const nextPageResult = await runsListPresenter.call(project.organizationId, environment.id, { + userId, + projectId: project.id, + ...filters, + cursor: currentPageResult.pagination.next, + direction: "forward", + pageSize: 1, // We only need the first run from the next page + }); + + if (nextPageResult.runs.length > 0) { + runsList.nextPageFirstRun = { + friendlyId: nextPageResult.runs[0].friendlyId, + cursor: currentPageResult.pagination.next, + }; + } + } + + return runsList; + } catch (error) { + logger.error("Error loading runs list from tableState:", { error }); + return null; + } +} + export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); const impersonationId = await getImpersonationId(request); @@ -176,81 +272,14 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const parent = await getResizableSnapshot(request, resizableSettings.parent.autosaveId); const tree = await getResizableSnapshot(request, resizableSettings.tree.autosaveId); - // Load runs list data from tableState if present - let runsList: { - runs: Array<{ friendlyId: string }>; - pagination: { next?: string; previous?: string }; - prevPageLastRun?: { friendlyId: string; cursor: string }; - nextPageFirstRun?: { friendlyId: string; cursor: string }; - } | null = null; - const tableStateParam = url.searchParams.get("tableState"); - if (tableStateParam) { - try { - const tableStateSearchParams = new URLSearchParams(decodeURIComponent(tableStateParam)); - const filters = getRunFiltersFromSearchParams(tableStateSearchParams); - - const project = await findProjectBySlug(organizationSlug, projectParam, userId); - const environment = await findEnvironmentBySlug(project?.id ?? "", envParam, userId); - - if (project && environment) { - const runsListPresenter = new NextRunListPresenter($replica, clickhouseClient); - const currentPageResult = await runsListPresenter.call(project.organizationId, environment.id, { - userId, - projectId: project.id, - ...filters, - pageSize: 25, // Load enough runs to provide navigation context - }); - - runsList = { - runs: currentPageResult.runs, - pagination: currentPageResult.pagination, - }; - - // Check if the current run is at the boundary and preload adjacent page if needed - const currentRunIndex = currentPageResult.runs.findIndex((r) => r.friendlyId === runParam); - - // If current run is first in list and there's a previous page, load the last run from prev page - if (currentRunIndex === 0 && currentPageResult.pagination.previous) { - const prevPageResult = await runsListPresenter.call(project.organizationId, environment.id, { - userId, - projectId: project.id, - ...filters, - cursor: currentPageResult.pagination.previous, - direction: "backward", - pageSize: 1, // We only need the last run from the previous page - }); - if (prevPageResult.runs.length > 0) { - runsList.prevPageLastRun = { - friendlyId: prevPageResult.runs[0].friendlyId, - cursor: currentPageResult.pagination.previous, - }; - } - } - - // If current run is last in list and there's a next page, load the first run from next page - if (currentRunIndex === currentPageResult.runs.length - 1 && currentPageResult.pagination.next) { - const nextPageResult = await runsListPresenter.call(project.organizationId, environment.id, { - userId, - projectId: project.id, - ...filters, - cursor: currentPageResult.pagination.next, - direction: "forward", - pageSize: 1, // We only need the first run from the next page - }); - if (nextPageResult.runs.length > 0) { - runsList.nextPageFirstRun = { - friendlyId: nextPageResult.runs[0].friendlyId, - cursor: currentPageResult.pagination.next, - }; - } - } - } - } catch (error) { - // If there's an error parsing or loading runs list, just ignore it - // and don't include the runsList in the response - console.error("Error loading runs list from tableState:", error); - } - } + const runsList = await getRunsListFromTableState({ + tableStateParam: url.searchParams.get("tableState"), + organizationSlug, + projectParam, + envParam, + runParam, + userId, + }); return json({ run: result.run, @@ -372,16 +401,10 @@ export default function Page() { run={run} trace={trace} maximumLiveReloadingSetting={maximumLiveReloadingSetting} - resizable={resizable} - runsList={runsList} /> ) : ( )} @@ -389,7 +412,7 @@ export default function Page() { ); } -function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: LoaderData) { +function TraceView({ run, trace, maximumLiveReloadingSetting }: Pick) { const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); @@ -483,7 +506,7 @@ function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: Loade ); } -function NoLogsView({ run, resizable }: LoaderData) { +function NoLogsView({ run }: Pick) { const plan = useCurrentPlan(); const organization = useOrganization(); @@ -1649,14 +1672,9 @@ function useAdjacentRunPaths({ environment: { slug: string }; tableState: string; run: { friendlyId: string }; - runsList: { - runs: Array<{ friendlyId: string }>; - pagination: { next?: string; previous?: string }; - prevPageLastRun?: { friendlyId: string; cursor: string }; - nextPageFirstRun?: { friendlyId: string; cursor: string }; - } | null; + runsList: RunsListNavigation | null; }): [string | null, string | null] { - return React.useMemo(() => { + return useMemo(() => { if (!runsList || runsList.runs.length === 0) { return [null, null]; }