From 48ababe3854c5191972cc7b0fa5275522b0f043a Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Tue, 28 Jul 2026 19:18:02 +0100 Subject: [PATCH 1/5] feat(webapp): move Webhooks into the main nav group Webhooks was a section holding a single "Deliveries" item. It now sits as a top-level item below Sessions, and the page is titled "Webhook deliveries". Registering the page in the favorites registry also fixes its favorite name: favoriting it added "Page: Deliveries" to the side menu, and now adds "Webhook deliveries". --- .../app/components/navigation/SideMenu.tsx | 30 ++++++++----------- .../components/navigation/favoritePages.tsx | 3 ++ .../components/navigation/sideMenuTypes.ts | 1 - .../route.tsx | 2 +- 4 files changed, 17 insertions(+), 19 deletions(-) diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 968e18073b2..185c0a8a559 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -886,23 +886,6 @@ export function SideMenu({ }); } - if (user.admin || user.isImpersonating || featureFlags.hasWebhooksAccess) { - staticSections.push({ - id: "webhooks", - title: "Webhooks", - items: [ - { - id: "webhook-deliveries", - name: "Deliveries", - icon: WebhookIcon, - activeIconColor: "text-webhooks", - to: v3WebhooksPath(organization, project, environment), - dataAction: "webhook-deliveries", - }, - ], - }); - } - staticSections.push({ id: "deployments", title: "Deployments", @@ -1204,6 +1187,19 @@ export function SideMenu({ isCollapsed={isCollapsed} yieldActiveToFavorite /> + {(user.admin || user.isImpersonating || featureFlags.hasWebhooksAccess) && ( + } + isCollapsed={isCollapsed} + yieldActiveToFavorite + /> + )} {orderedSectionIds.map((sectionId) => { diff --git a/apps/webapp/app/components/navigation/favoritePages.tsx b/apps/webapp/app/components/navigation/favoritePages.tsx index 4439aadedfd..c07ba2d6d5d 100644 --- a/apps/webapp/app/components/navigation/favoritePages.tsx +++ b/apps/webapp/app/components/navigation/favoritePages.tsx @@ -40,6 +40,7 @@ import { TasksIcon } from "~/assets/icons/TasksIcon"; import { UsageIcon } from "~/assets/icons/UsageIcon"; import { UserGroupIcon } from "~/assets/icons/UserGroupIcon"; import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon"; +import { WebhookIcon } from "~/assets/icons/WebhookIcon"; import { VercelLogo } from "~/components/integrations/VercelLogo"; import { useOptionalUser } from "~/hooks/useUser"; import { type FavoritePage } from "~/services/dashboardPreferences.server"; @@ -71,6 +72,7 @@ const FAVORITE_PAGE_ICONS: Record< "task-agent": { icon: CubeSparkleIcon, activeColor: "text-agents" }, runs: { icon: RunsIcon, activeColor: "text-runs" }, sessions: { icon: AIChatIcon, activeColor: "text-sessions" }, + webhooks: { icon: WebhookIcon, activeColor: "text-webhooks" }, prompts: { icon: AIPenIcon, activeColor: "text-aiPrompts" }, models: { icon: Box3DIcon, activeColor: "text-models" }, logs: { icon: LogsIcon, activeColor: "text-logs" }, @@ -213,6 +215,7 @@ const ENV_PAGE_META: Record = { "": { icon: "tasks", name: "Tasks", singular: "Task" }, runs: { icon: "runs", name: "Runs", singular: "Run" }, sessions: { icon: "sessions", name: "Sessions", singular: "Session" }, + webhooks: { icon: "webhooks", name: "Webhook deliveries" }, prompts: { icon: "prompts", name: "Prompts", singular: "Prompt" }, models: { icon: "models", name: "Models", singular: "Model" }, logs: { icon: "logs", name: "Logs" }, diff --git a/apps/webapp/app/components/navigation/sideMenuTypes.ts b/apps/webapp/app/components/navigation/sideMenuTypes.ts index 0aa3dd03985..508c4121175 100644 --- a/apps/webapp/app/components/navigation/sideMenuTypes.ts +++ b/apps/webapp/app/components/navigation/sideMenuTypes.ts @@ -9,7 +9,6 @@ export const SideMenuSectionIdSchema = z.enum([ "deployments", "project-settings", "tasks", - "webhooks", ]); // Inferred type from the schema diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks._index/route.tsx index c8eee2552c7..2a95511612f 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks._index/route.tsx @@ -152,7 +152,7 @@ export default function Page() { return ( <> - +
From c86bd2a8803f1f6059e53052defbfc9b6e0d47a3 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Tue, 28 Jul 2026 22:37:20 +0100 Subject: [PATCH 2/5] fix(webapp): tidy up the webhook deliveries table and filters Make the whole delivery row clickable. The external delivery ID, created, processed and error cells carried no link, and the target cell only linked when the delivery had a run or session, so most of each row was dead. Dimmed "None" and "Unknown" cells now brighten with the rest of the row on hover. Move the new-deliveries button inline, to the left of the pager, rather than on its own row beneath it. Brighten the Test filter icon, drop the redundant search field from the Status and More filters menus, use the shared Badge for the "Test" tag, size the webhook icon to match the Tasks page, and show 60 deliveries per page instead of 25. --- .../webhookDeliveries/v1/DeliveriesTable.tsx | 40 ++++++---- .../v1/WebhookDeliveryFilters.tsx | 27 ++----- .../WebhookDeliveriesListPresenter.server.ts | 2 +- .../route.tsx | 75 +++++++++---------- 4 files changed, 64 insertions(+), 80 deletions(-) diff --git a/apps/webapp/app/components/webhookDeliveries/v1/DeliveriesTable.tsx b/apps/webapp/app/components/webhookDeliveries/v1/DeliveriesTable.tsx index 253864aef35..c22abe055e8 100644 --- a/apps/webapp/app/components/webhookDeliveries/v1/DeliveriesTable.tsx +++ b/apps/webapp/app/components/webhookDeliveries/v1/DeliveriesTable.tsx @@ -3,6 +3,7 @@ import { useLocation, useNavigation } from "@remix-run/react"; import { AIChatIcon } from "~/assets/icons/AIChatIcon"; import { RunsIcon } from "~/assets/icons/RunsIcon"; import { WebhookIcon } from "~/assets/icons/WebhookIcon"; +import { Badge } from "~/components/primitives/Badge"; import { DateTime } from "~/components/primitives/DateTime"; import { MiddleTruncate } from "~/components/primitives/MiddleTruncate"; import { Paragraph } from "~/components/primitives/Paragraph"; @@ -121,28 +122,26 @@ export function DeliveriesTable({ {delivery.webhook ? ( - + {delivery.webhook.slug} ) : ( - Unknown + + Unknown + )} )} {delivery.friendlyId} - {delivery.isTest ? ( - - Test - - ) : null} + {delivery.isTest ? Test : null} - + {delivery.externalDeliveryId ? (
) : ( - None + + None + )}
- + {/* Falls back to the delivery so the whole row stays clickable when there is no target */} + {delivery.session ? ( @@ -166,20 +168,24 @@ export function DeliveriesTable({ {delivery.run.friendlyId} ) : ( - None + + None + )} - + - + {delivery.processedAt ? ( ) : ( - None + + None + )} - + {delivery.status === "FAILED" && delivery.errorMessage ? ( ) : ( - None + + None + )} { - return filterTypes.filter((item) => - item.title.toLowerCase().includes(searchValue.toLowerCase()) - ); - }, [searchValue]); - +function MainMenu({ trigger, clearSearchValue, setFilterType }: MenuProps) { return ( {trigger} - - {filtered.map((type, index) => ( + {filterTypes.map((type, index) => ( { @@ -190,12 +183,10 @@ function MainMenu({ searchValue, trigger, clearSearchValue, setFilterType }: Men function StatusDropdown({ trigger, clearSearchValue, - searchValue, onClose, }: { trigger: ReactNode; clearSearchValue: () => void; - searchValue: string; onClose?: () => void; }) { const { values, replace } = useSearchParams(); @@ -205,12 +196,6 @@ function StatusDropdown({ replace({ statuses: values, cursor: undefined, direction: undefined }); }; - const filtered = useMemo(() => { - return deliveryStatuses.filter((item) => - item.title.toLowerCase().includes(searchValue.toLowerCase()) - ); - }, [searchValue]); - return ( {trigger} @@ -225,9 +210,8 @@ function StatusDropdown({ return true; }} > - - {filtered.map((item, index) => ( + {deliveryStatuses.map((item, index) => ( - {(search, setSearch) => ( + {(_search, setSearch) => ( @@ -309,7 +293,6 @@ function PermanentStatusFilter() { } - searchValue={search} clearSearchValue={() => setSearch("")} /> )} @@ -536,7 +519,7 @@ function PermanentTestFilter() { /> ) : (
- + Test
)} diff --git a/apps/webapp/app/presenters/v3/WebhookDeliveriesListPresenter.server.ts b/apps/webapp/app/presenters/v3/WebhookDeliveriesListPresenter.server.ts index b2c22e244b2..efe5e146129 100644 --- a/apps/webapp/app/presenters/v3/WebhookDeliveriesListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/WebhookDeliveriesListPresenter.server.ts @@ -9,7 +9,7 @@ import { type WebhookDeliveryListItem, } from "./WebhookDetailPresenter.server"; -const DELIVERIES_PAGE_SIZE = 25; +const DELIVERIES_PAGE_SIZE = 60; type Direction = "forward" | "backward"; export type WebhookDeliveriesListResult = { diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks._index/route.tsx index 2a95511612f..9c9f84c1ef0 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks._index/route.tsx @@ -149,6 +149,8 @@ export default function Page() { const { deliveries, pagination, possibleWebhooks, hasFilters } = useTypedLoaderData(); + const { visibleDeliveries, newDeliveriesButton } = useLiveDeliveries(deliveries); + return ( <> @@ -158,22 +160,27 @@ export default function Page() {
- + {/* The new-deliveries button sits inline, immediately left of the pager */} +
+ {newDeliveriesButton} + +
+
+
+
-
); } -function LiveDeliveriesTable({ - deliveries, - hasFilters, -}: { - deliveries: WebhookDeliveryListItem[]; - hasFilters: boolean; -}) { +function useLiveDeliveries(deliveries: WebhookDeliveryListItem[]) { const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); @@ -204,36 +211,22 @@ function LiveDeliveriesTable({ revalidator.revalidate(); }; - return ( -
- {showNewDeliveriesBanner ? ( -
- - - -
- ) : null} -
- -
-
- ); + const newDeliveriesButton = showNewDeliveriesBanner ? ( + + + + ) : null; + + return { visibleDeliveries, newDeliveriesButton }; } From 255035387f3800c31039a11aaf7b7ef2e8ead0ec Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 29 Jul 2026 12:12:58 +0100 Subject: [PATCH 3/5] fix(webapp): tidy up the webhook delivery detail page Drop the duplicate status badge from the title bar (the sidebar already has a Status row), centre the "nothing was captured" tab messages and set them a size larger, and ellipsise the copyable sidebar values so an unbreakable hash or opaque id no longer overflows its column. Those values move up to text-sm. Round the top of the delivery timeline's thick bar. The run timeline gets that corner from the start-cap-thick event above its thick line, but a delivery has only two timestamps, so the line itself starts the bar and had a square top on every succeeded and failed delivery. CopyableText gains an opt-in truncate prop and RunTimelineLine an opt-in roundedTop, so other callers are untouched. --- .../components/primitives/CopyableText.tsx | 24 ++++++++-- .../webapp/app/components/run/RunTimeline.tsx | 13 +++++- .../webhookDeliveries/v1/DeliveryTimeline.tsx | 3 ++ .../route.tsx | 44 ++++++++++++++----- 4 files changed, 67 insertions(+), 17 deletions(-) diff --git a/apps/webapp/app/components/primitives/CopyableText.tsx b/apps/webapp/app/components/primitives/CopyableText.tsx index 9634ad1ac01..bf5898390a3 100644 --- a/apps/webapp/app/components/primitives/CopyableText.tsx +++ b/apps/webapp/app/components/primitives/CopyableText.tsx @@ -12,6 +12,7 @@ export function CopyableText({ asChild, variant, hideTooltip, + truncate, }: { value: string; copyValue?: string; @@ -24,6 +25,12 @@ export function CopyableText({ * fire Radix's global "one tooltip open at a time" close and dismiss the parent. */ hideTooltip?: boolean; + /** + * Ellipsise the value rather than letting it overflow its column. For unbreakable strings + * (hashes, opaque ids) that offer no wrap opportunity. The copy button moves into a reserved + * right gutter so it stays visible instead of sitting outside the column. + */ + truncate?: boolean; }) { const [isHovered, setIsHovered] = useState(false); const { copy, copied } = useCopy(copyValue ?? value); @@ -51,15 +58,26 @@ export function CopyableText({ return ( setIsHovered(false)} > - setIsHovered(true)}>{value} + setIsHovered(true)} + > + {value} + e.stopPropagation()} className={cn( - "absolute -right-6 top-0 z-10 size-6 font-sans", + "absolute top-0 z-10 size-6 font-sans", + // Truncated values reserve a right gutter, so the button sits inside it + truncate ? "right-0" : "-right-6", isHovered ? "flex" : "hidden" )} > diff --git a/apps/webapp/app/components/run/RunTimeline.tsx b/apps/webapp/app/components/run/RunTimeline.tsx index 1672a2b2f78..edc576980c6 100644 --- a/apps/webapp/app/components/run/RunTimeline.tsx +++ b/apps/webapp/app/components/run/RunTimeline.tsx @@ -483,6 +483,12 @@ export type RunTimelineLineProps = { state?: TimelineEventState; variant?: TimelineLineVariant; style?: TimelineStyle; + /** + * Round the top of a thick ("normal") line. Needed when the line itself starts the thick bar, + * as in the delivery timeline, where nothing above it supplies a `start-cap-thick`. The run + * timeline always precedes its thick line with that cap, so it leaves this off. + */ + roundedTop?: boolean; }; export function RunTimelineLine({ @@ -490,11 +496,12 @@ export function RunTimelineLine({ state, variant = "normal", style = "normal", + roundedTop = false, }: RunTimelineLineProps) { return (
- +
{title} @@ -507,10 +514,12 @@ function LineMarker({ state, variant, style, + roundedTop = false, }: { state?: TimelineEventState; variant: TimelineLineVariant; style?: TimelineStyle; + roundedTop?: boolean; }) { let containerClass = "bg-text-dimmed"; switch (state) { @@ -532,7 +541,7 @@ function LineMarker({ switch (variant) { case "normal": return ( -
+
{state === "inprogress" && (
{item.to ? ( diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks.deliveries.$deliveryParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks.deliveries.$deliveryParam/route.tsx index 303a54b5035..552cd5fdcd1 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks.deliveries.$deliveryParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks.deliveries.$deliveryParam/route.tsx @@ -1,7 +1,7 @@ import { BookOpenIcon } from "@heroicons/react/24/solid"; import { type MetaFunction, useRevalidator } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { useState } from "react"; +import { type ReactNode, useState } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { AIChatIcon } from "~/assets/icons/AIChatIcon"; @@ -106,6 +106,17 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { return typedjson({ delivery, retentionDays: env.WEBHOOK_PARTITION_RETENTION_DAYS }); }; +/** Centred placeholder for a tab whose content was never captured. */ +function EmptyTabMessage({ children }: { children: ReactNode }) { + return ( +
+ + {children} + +
+ ); +} + function formatDuration(createdAt: Date, processedAt: Date | null): string | null { if (!processedAt) return null; const ms = processedAt.getTime() - createdAt.getTime(); @@ -183,7 +194,6 @@ export default function Page() { {delivery.friendlyId} - } /> @@ -224,9 +234,9 @@ export default function Page() { eventJson ? ( ) : ( - + No event payload was captured for this delivery. - + ) ) : headersJson ? ( ) : ( - + No request headers were captured for this delivery. - + )}
@@ -262,7 +272,11 @@ export default function Page() { ID - + @@ -305,7 +319,7 @@ export default function Page() { {delivery.session.friendlyId} @@ -319,7 +333,7 @@ export default function Page() { {delivery.run && runPath ? ( {delivery.run.friendlyId} @@ -335,7 +349,8 @@ export default function Page() { {delivery.externalDeliveryId ? ( ) : ( None @@ -348,7 +363,8 @@ export default function Page() { {delivery.idempotencyKey ? ( ) : ( None @@ -359,7 +375,11 @@ export default function Page() { Raw body hash {delivery.rawBodyHash ? ( - + ) : ( None )} From ca9d4a3e5d403857d016bedbba95b388f91cfbc8 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 29 Jul 2026 12:13:00 +0100 Subject: [PATCH 4/5] fix(webapp): make seeded webhook deliveries open from the list The delivery seed script minted `id` and `friendlyId` as two independent ids, but the detail lookup derives the row id from the friendlyId, so every seeded delivery's page reported that the delivery could not be found. --- apps/webapp/seed-webhook-deliveries.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/webapp/seed-webhook-deliveries.ts b/apps/webapp/seed-webhook-deliveries.ts index 856e6c37f35..436a7eaa9a3 100644 --- a/apps/webapp/seed-webhook-deliveries.ts +++ b/apps/webapp/seed-webhook-deliveries.ts @@ -469,9 +469,13 @@ async function main() { [ep.spec.signatureHeader.toLowerCase()]: status === "FAILED" ? "tampered" : `sig_${nanoid()}`, }; + // friendlyId must be the id plus the prefix, matching WebhookDeliveryId. The detail + // lookup strips "whd_" and queries Postgres by `id`, so minting the two independently + // makes every seeded delivery's detail page 404. + const deliveryId = nanoid(); rows.push({ - id: nanoid(), - friendlyId: `whd_${nanoid()}`, + id: deliveryId, + friendlyId: `whd_${deliveryId}`, endpointId: ep.id, source: ep.spec.source, status, From dc97f47c651568be567be60f2a725ae1dfecd7f3 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Mon, 3 Aug 2026 15:34:48 +0100 Subject: [PATCH 5/5] fix(webapp): let the webhook deliveries table scroll The deliveries list passed stickyHeader, which switches the table container to overflow-visible and so stops it being the scroll container. Every other list in the app (runs, sessions, batches) leaves it off and renders the table directly in the layout's content row. The header stays sticky regardless, because TableHeader always sets sticky top-0. --- .../route.tsx | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks._index/route.tsx index 9c9f84c1ef0..37ab62c52d7 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks._index/route.tsx @@ -166,14 +166,11 @@ export default function Page() {
-
- -
+ {/* Sits directly in the 1fr row, like the runs, sessions and batches lists. No + stickyHeader: that switches Table's container to overflow-visible, which stops it + being the scroll container. The header is sticky either way (TableHeader always sets + sticky top-0), and the other webhook tables only pass it because an ancestor scrolls. */} +