diff --git a/.changeset/chat-custom-agent-capture.md b/.changeset/chat-custom-agent-capture.md deleted file mode 100644 index 8729cda8c94..00000000000 --- a/.changeset/chat-custom-agent-capture.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -"@trigger.dev/sdk": patch ---- - -Custom chat agent loops get two ergonomic wins for owning the turn loop. - -`chat.writeTurnComplete()` now returns the turn boundary's resume cursors (`lastEventId` for the output stream and `sessionInEventId` for the input stream), so you can persist them straight from the task instead of round-tripping them back from the client. - -```ts -const { lastEventId, sessionInEventId } = await chat.writeTurnComplete(); -await db.chats.update(chatId, { lastEventId, sessionInEventId }); -``` - -`chat.pipeAndCapture()` no longer throws when a stream is stopped or fails. It now returns a `PipeAndCaptureResult` whose `message` holds any partial output captured before the stop or failure, alongside a typed `status` (`"complete" | "aborted" | "error"`) and, on failure, the `error`. Read the message off the result: - -```ts -const { message, status, error } = await chat.pipeAndCapture(result, { signal }); -if (message) conversation.addResponse(message); -if (status === "error") logger.error("turn failed", { error }); -``` - -Note: `pipeAndCapture` previously resolved to `UIMessage | undefined`. Update call sites to read `.message` from the returned result. diff --git a/.changeset/vite-ignore-optional-imports.md b/.changeset/vite-ignore-optional-imports.md index 286f80132de..1720d87044e 100644 --- a/.changeset/vite-ignore-optional-imports.md +++ b/.changeset/vite-ignore-optional-imports.md @@ -2,4 +2,4 @@ "@trigger.dev/sdk": patch --- -Suppress a build-time warning that could appear in Vite-based projects when the optional `@ai-sdk/otel` package is not installed. +Annotate the optional `@ai-sdk/otel` dynamic import with `@vite-ignore` so Vite-based bundlers don't warn about an unanalyzable import. diff --git a/.gitignore b/.gitignore index c1fe3103332..1ee5643d57d 100644 --- a/.gitignore +++ b/.gitignore @@ -76,3 +76,6 @@ apps/**/public/build ailogger-output.log # per-package vitest timing capture (transient; merged into root test-timings.json) .vitest-timing.json + +# local git worktree checkouts (not source) — keeps oxfmt/oxlint from descending into them +.worktrees/ diff --git a/.server-changes/default-billing-alerts.md b/.server-changes/default-billing-alerts.md new file mode 100644 index 00000000000..e828c506050 --- /dev/null +++ b/.server-changes/default-billing-alerts.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +Organizations without billing alerts now get default spend alert thresholds, so you're notified before usage grows unexpectedly. The billing limit page no longer pre-selects an option before you've set a limit and prompts you to configure one. Alert previews now update immediately after you change your billing limit. diff --git a/.server-changes/entrypoint-no-log-db-connection-strings.md b/.server-changes/entrypoint-no-log-db-connection-strings.md new file mode 100644 index 00000000000..611408cf14c --- /dev/null +++ b/.server-changes/entrypoint-no-log-db-connection-strings.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Container startup no longer prints database and ClickHouse connection strings (with credentials) to the logs. diff --git a/.server-changes/light-theme.md b/.server-changes/light-theme.md new file mode 100644 index 00000000000..ca2b34b7047 --- /dev/null +++ b/.server-changes/light-theme.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Adds an opt-in Interface theme setting on the account page behind a feature flag. Choose Classic (the default), System preference (follows your OS), Dark, or Light, and fine-tune the new themes with a contrast slider. System preference switches automatically when your OS appearance changes. diff --git a/.server-changes/prevent-duplicate-root-environments.md b/.server-changes/prevent-duplicate-root-environments.md deleted file mode 100644 index 0710595deb7..00000000000 --- a/.server-changes/prevent-duplicate-root-environments.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -area: webapp -type: fix ---- - -Prevent duplicate Staging and Preview environments when account setup requests overlap diff --git a/.server-changes/realtime-emission-fanout-metrics.md b/.server-changes/realtime-emission-fanout-metrics.md new file mode 100644 index 00000000000..bd9ef83afbc --- /dev/null +++ b/.server-changes/realtime-emission-fanout-metrics.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +Add metrics to the realtime backend that measure how often a single changed run is served to multiple subscriptions in one batch. diff --git a/.server-changes/side-menu-project-and-org-menus.md b/.server-changes/side-menu-project-and-org-menus.md deleted file mode 100644 index 5558a1cec3d..00000000000 --- a/.server-changes/side-menu-project-and-org-menus.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -area: webapp -type: improvement ---- - -Refreshed the side menu: separate organization and account menus, a new project switcher, and the menu is now resizable by dragging its edge. The account Profile page has also been redesigned. diff --git a/.server-changes/supervisor-outbound-request-metrics.md b/.server-changes/supervisor-outbound-request-metrics.md new file mode 100644 index 00000000000..c66a499b815 --- /dev/null +++ b/.server-changes/supervisor-outbound-request-metrics.md @@ -0,0 +1,6 @@ +--- +area: supervisor +type: improvement +--- + +Improved supervisor observability: it now reports metrics for its outbound requests, making failed calls to upstream services easier to monitor. diff --git a/apps/supervisor/src/index.ts b/apps/supervisor/src/index.ts index cf73be90eea..f30203df547 100644 --- a/apps/supervisor/src/index.ts +++ b/apps/supervisor/src/index.ts @@ -22,7 +22,7 @@ import { isKubernetesEnvironment, } from "@trigger.dev/core/v3/serverOnly"; import { createK8sApi, createApiserverMetricsFetcher } from "./clients/kubernetes.js"; -import { collectDefaultMetrics, Gauge, Histogram } from "prom-client"; +import { collectDefaultMetrics, Counter, Gauge, Histogram } from "prom-client"; import { register } from "./metrics.js"; import { PodCleaner } from "./services/podCleaner.js"; import { FailedPodHandler } from "./services/failedPodHandler.js"; @@ -60,6 +60,21 @@ const workloadCreateDuration = new Histogram({ registers: [register], }); +const outboundRequestsTotal = new Counter({ + name: "supervisor_outbound_request_total", + help: "Count of outbound HTTP requests from the supervisor, by target name, method, response status, and outcome (ok, http_error, invalid_response, network_error).", + labelNames: ["name", "method", "status", "outcome"], + registers: [register], +}); + +const outboundRequestDuration = new Histogram({ + name: "supervisor_outbound_request_duration_seconds", + help: "Duration of outbound HTTP requests from the supervisor, by target name and outcome. Includes the HTTP client's internal retries and backoff.", + labelNames: ["name", "outcome"], + buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2, 5, 10, 11, 12.5, 15, 20, 30, 60], + registers: [register], +}); + class ManagedSupervisor { private readonly workerSession: SupervisorSession; private readonly metricsServer?: HttpServer; @@ -322,6 +337,10 @@ class ManagedSupervisor { runNotificationsEnabled: env.TRIGGER_WORKLOAD_API_ENABLED, heartbeatIntervalSeconds: env.TRIGGER_WORKER_HEARTBEAT_INTERVAL_SECONDS, sendRunDebugLogs: env.SEND_RUN_DEBUG_LOGS, + onHttpRequestComplete: ({ name, method, status, outcome, durationMs }) => { + outboundRequestsTotal.inc({ name, method, status, outcome }); + outboundRequestDuration.observe({ name, outcome }, durationMs / 1000); + }, preDequeue: async () => { // Synchronous, hot-path-safe cached read; false when no monitors are active. const skipForBackpressure = this.backpressureMonitors.some((m) => m.shouldSkipDequeue()); @@ -692,6 +711,18 @@ class ManagedSupervisor { headers.traceparent = traceparent; } + const requestStart = performance.now(); + const record = ( + status: string, + outcome: "ok" | "http_error" | "invalid_response" | "network_error" + ) => { + outboundRequestsTotal.inc({ name: "warm_start", method: "POST", status, outcome }); + outboundRequestDuration.observe( + { name: "warm_start", outcome }, + (performance.now() - requestStart) / 1000 + ); + }; + try { const res = await fetch(warmStartUrlWithPath.href, { method: "POST", @@ -700,8 +731,10 @@ class ManagedSupervisor { }); if (!res.ok) { + record(String(res.status), "http_error"); this.logger.error("Warm start failed", { runId: dequeuedMessage.run.id, + statusCode: res.status, }); return false; } @@ -710,6 +743,7 @@ class ManagedSupervisor { const parsedData = z.object({ didWarmStart: z.boolean() }).safeParse(data); if (!parsedData.success) { + record(String(res.status), "invalid_response"); this.logger.error("Warm start response invalid", { runId: dequeuedMessage.run.id, data, @@ -717,8 +751,11 @@ class ManagedSupervisor { return false; } + record(String(res.status), "ok"); + return parsedData.data.didWarmStart; } catch (error) { + record("none", "network_error"); this.logger.error("Warm start error", { runId: dequeuedMessage.run.id, error, diff --git a/apps/supervisor/src/workloadToken.ts b/apps/supervisor/src/workloadToken.ts index 914411dd735..d28a6150744 100644 --- a/apps/supervisor/src/workloadToken.ts +++ b/apps/supervisor/src/workloadToken.ts @@ -4,7 +4,7 @@ import { type WorkloadDeploymentTokenClaims, type WorkloadDeploymentTokenInput, } from "@trigger.dev/core/v3"; -import { Counter } from "prom-client"; +import { Counter, Gauge } from "prom-client"; import { env } from "./env.js"; import { register } from "./metrics.js"; @@ -37,6 +37,16 @@ const verifyCounter = new Counter({ registers: [register], }); +// Exports the active mode (value 1 for the current WORKLOAD_TOKEN_ENFORCEMENT) so dashboards can show +// disabled/log/enforce at a glance — the counters alone don't distinguish log from enforce. +const enforcementModeGauge = new Gauge({ + name: "workload_token_enforcement_mode", + help: "Active runner-boundary auth mode: value 1 for the label matching WORKLOAD_TOKEN_ENFORCEMENT", + labelNames: ["mode"] as const, + registers: [register], +}); +enforcementModeGauge.set({ mode: env.WORKLOAD_TOKEN_ENFORCEMENT }, 1); + export async function mintDeploymentToken( claims: WorkloadDeploymentTokenInput ): Promise { diff --git a/apps/webapp/app/assets/icons/MachineIcon.tsx b/apps/webapp/app/assets/icons/MachineIcon.tsx index f07e7467b0d..896ccdfbc5f 100644 --- a/apps/webapp/app/assets/icons/MachineIcon.tsx +++ b/apps/webapp/app/assets/icons/MachineIcon.tsx @@ -48,20 +48,25 @@ export function MachineDefaultIcon({ className }: { className?: string }) { function MachineIconNoMachine({ className }: { className?: string }) { return ( - + @@ -109,12 +114,12 @@ function MachineIconSmall1x({ className }: { className?: string }) { /> @@ -130,12 +135,12 @@ function MachineIconSmall2x({ className }: { className?: string }) { /> @@ -155,7 +160,7 @@ function MachineIconMedium1x({ className }: { className?: string }) { /> @@ -175,7 +180,7 @@ function MachineIconMedium2x({ className }: { className?: string }) { /> diff --git a/apps/webapp/app/assets/images/blurred-dashboard-background-menu-bottom-light.jpg b/apps/webapp/app/assets/images/blurred-dashboard-background-menu-bottom-light.jpg new file mode 100644 index 00000000000..59514db9d7a Binary files /dev/null and b/apps/webapp/app/assets/images/blurred-dashboard-background-menu-bottom-light.jpg differ diff --git a/apps/webapp/app/assets/images/blurred-dashboard-background-menu-top-light.jpg b/apps/webapp/app/assets/images/blurred-dashboard-background-menu-top-light.jpg new file mode 100644 index 00000000000..08a4eab1d16 Binary files /dev/null and b/apps/webapp/app/assets/images/blurred-dashboard-background-menu-top-light.jpg differ diff --git a/apps/webapp/app/assets/images/blurred-dashboard-background-table-light.jpg b/apps/webapp/app/assets/images/blurred-dashboard-background-table-light.jpg new file mode 100644 index 00000000000..1c8bb8881e7 Binary files /dev/null and b/apps/webapp/app/assets/images/blurred-dashboard-background-table-light.jpg differ diff --git a/apps/webapp/app/components/AskAI.tsx b/apps/webapp/app/components/AskAI.tsx index 0d32265c251..d61ea0055fa 100644 --- a/apps/webapp/app/components/AskAI.tsx +++ b/apps/webapp/app/components/AskAI.tsx @@ -458,7 +458,7 @@ function ChatMessages({
Error generating answer: - + {error} If the problem persists after retrying, please contact support. diff --git a/apps/webapp/app/components/BackgroundWrapper.tsx b/apps/webapp/app/components/BackgroundWrapper.tsx index c13983a12af..f953332a845 100644 --- a/apps/webapp/app/components/BackgroundWrapper.tsx +++ b/apps/webapp/app/components/BackgroundWrapper.tsx @@ -1,42 +1,72 @@ import { type ReactNode } from "react"; import blurredDashboardBackgroundMenuTop from "~/assets/images/blurred-dashboard-background-menu-top.jpg"; +import blurredDashboardBackgroundMenuTopLight from "~/assets/images/blurred-dashboard-background-menu-top-light.jpg"; import blurredDashboardBackgroundMenuBottom from "~/assets/images/blurred-dashboard-background-menu-bottom.jpg"; +import blurredDashboardBackgroundMenuBottomLight from "~/assets/images/blurred-dashboard-background-menu-bottom-light.jpg"; import blurredDashboardBackgroundTable from "~/assets/images/blurred-dashboard-background-table.jpg"; +import blurredDashboardBackgroundTableLight from "~/assets/images/blurred-dashboard-background-table-light.jpg"; + +/* Blurred dashboard screenshots; the -light set is the same artwork with the + lightness inverted for the light theme. */ +const BACKDROPS = [ + { + images: { + menuTop: blurredDashboardBackgroundMenuTop, + menuBottom: blurredDashboardBackgroundMenuBottom, + table: blurredDashboardBackgroundTable, + }, + tableFill: "#101214", + className: "hidden lg:block light:lg:hidden", + }, + { + images: { + menuTop: blurredDashboardBackgroundMenuTopLight, + menuBottom: blurredDashboardBackgroundMenuBottomLight, + table: blurredDashboardBackgroundTableLight, + }, + tableFill: "#f4f5f7", + className: "hidden light:lg:block", + }, +]; export function BackgroundWrapper({ children }: { children: ReactNode }) { return (
-
+ {BACKDROPS.map(({ images, tableFill, className }) => ( +
+
-
+
-
+
+
+ ))}
{children}
diff --git a/apps/webapp/app/components/BlankStatePanels.tsx b/apps/webapp/app/components/BlankStatePanels.tsx index 56a7c0da45d..4aada6569b5 100644 --- a/apps/webapp/app/components/BlankStatePanels.tsx +++ b/apps/webapp/app/components/BlankStatePanels.tsx @@ -12,6 +12,7 @@ import { } from "@heroicons/react/20/solid"; import { useLocation } from "react-use"; import { AIChatIcon } from "~/assets/icons/AIChatIcon"; +import { AIPenIcon } from "~/assets/icons/AIPenIcon"; import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons"; import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon"; import openBulkActionsPanel from "~/assets/images/open-bulk-actions-panel.png"; @@ -744,15 +745,11 @@ export function PromptsNone() { return ( + Prompts docs } diff --git a/apps/webapp/app/components/BulkActionFilterSummary.tsx b/apps/webapp/app/components/BulkActionFilterSummary.tsx index 3c6871a23cd..53a20395f44 100644 --- a/apps/webapp/app/components/BulkActionFilterSummary.tsx +++ b/apps/webapp/app/components/BulkActionFilterSummary.tsx @@ -9,7 +9,7 @@ import { Paragraph } from "./primitives/Paragraph"; import simplur from "simplur"; import { appliedSummary, dateFromString, timeFilterRenderValues } from "./runs/v3/SharedFilters"; import { formatNumber } from "~/utils/numberFormatter"; -import { SpinnerWhite } from "./primitives/Spinner"; +import { Spinner } from "./primitives/Spinner"; import { ArrowPathIcon, CheckIcon, XCircleIcon } from "@heroicons/react/20/solid"; import { XCircleIcon as XCircleIconOutline } from "@heroicons/react/24/outline"; import assertNever from "assert-never"; @@ -306,5 +306,5 @@ export function EstimatedCount({ count }: { count?: number }) { return <>~{formatNumber(count)}; } - return ; + return ; } diff --git a/apps/webapp/app/components/DevPresence.tsx b/apps/webapp/app/components/DevPresence.tsx index 27d96954758..4ce4a480cb8 100644 --- a/apps/webapp/app/components/DevPresence.tsx +++ b/apps/webapp/app/components/DevPresence.tsx @@ -15,6 +15,7 @@ import disconnectedImage from "../assets/images/cli-disconnected.png"; import { InlineCode } from "./code/InlineCode"; import { Button } from "./primitives/Buttons"; import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "./primitives/Dialog"; +import { cn } from "~/utils/cn"; import { Paragraph } from "./primitives/Paragraph"; import { TextLink } from "./primitives/TextLink"; import { PackageManagerProvider, TriggerDevStepV3 } from "./SetupCommands"; @@ -165,7 +166,10 @@ export function DevPresencePanel({ isConnected }: { isConnected: boolean | undef width={282} height={45} /> - + {isConnected === undefined ? "Checking connection..." : isConnected diff --git a/apps/webapp/app/components/ErrorDisplay.tsx b/apps/webapp/app/components/ErrorDisplay.tsx index 374f427c504..4a1e2804807 100644 --- a/apps/webapp/app/components/ErrorDisplay.tsx +++ b/apps/webapp/app/components/ErrorDisplay.tsx @@ -60,10 +60,12 @@ type DisplayOptionsProps = { export function ErrorDisplay({ title, message, button }: DisplayOptionsProps) { return ( + // The backdrop stays dark in every theme (the rotating-logo animation is + // dark artwork), so the text pins to the dark-theme colors on light too.
- {title} - {message && {message}} + {title} + {message && {message}} + Alpha } @@ -35,7 +42,14 @@ export function BetaBadge({ inline = false, className }: { inline?: boolean; cla return ( + Beta } @@ -58,7 +72,11 @@ export function NewBadge({ inline = false, className }: { inline?: boolean; clas return ( New diff --git a/apps/webapp/app/components/LoginPageLayout.tsx b/apps/webapp/app/components/LoginPageLayout.tsx index 3e698ef87ce..1db614eb926 100644 --- a/apps/webapp/app/components/LoginPageLayout.tsx +++ b/apps/webapp/app/components/LoginPageLayout.tsx @@ -72,7 +72,7 @@ export function LoginPageLayout({ {rightContent ?? ( <>
- + {randomQuote?.quote} {randomQuote?.person} diff --git a/apps/webapp/app/components/admin/debugTooltip.tsx b/apps/webapp/app/components/admin/debugTooltip.tsx index b4ccb74f88d..2a561c1519a 100644 --- a/apps/webapp/app/components/admin/debugTooltip.tsx +++ b/apps/webapp/app/components/admin/debugTooltip.tsx @@ -1,4 +1,5 @@ import { ShieldCheckIcon } from "@heroicons/react/20/solid"; +import { CopyableText } from "~/components/primitives/CopyableText"; import * as Property from "~/components/primitives/PropertyTable"; import { Tooltip, @@ -25,7 +26,10 @@ export function AdminDebugTooltip({ children }: { children?: React.ReactNode }) - + {/* The copy controls below pass `hideTooltip` so their own tooltips don't fire + Radix's global close and dismiss this panel. `pr-8` leaves room for the + copy button, which is absolutely positioned to the right of each value. */} + {children} @@ -40,27 +44,35 @@ function Content({ children }: { children: React.ReactNode }) { const user = useUser(); return ( -
+
User ID - {user.id} + + + {organization && ( Org ID - {organization.id} + + + )} {project && ( <> Project ID - {project.id} + + + Project ref - {project.externalRef} + + + )} @@ -68,7 +80,9 @@ function Content({ children }: { children: React.ReactNode }) { <> Environment ID - {environment.id} + + + Environment type @@ -81,7 +95,7 @@ function Content({ children }: { children: React.ReactNode }) { )} -
{children}
+ {children &&
{children}
}
); } diff --git a/apps/webapp/app/components/billing/AnimatedOrgBannerBar.tsx b/apps/webapp/app/components/billing/AnimatedOrgBannerBar.tsx index b0f11b7eba3..17eef91ad5c 100644 --- a/apps/webapp/app/components/billing/AnimatedOrgBannerBar.tsx +++ b/apps/webapp/app/components/billing/AnimatedOrgBannerBar.tsx @@ -40,11 +40,16 @@ export function AnimatedOrgBannerBar({
{children} diff --git a/apps/webapp/app/components/billing/BillingLimitConfigSection.tsx b/apps/webapp/app/components/billing/BillingLimitConfigSection.tsx index c69d6f0c175..e6362d4cb1d 100644 --- a/apps/webapp/app/components/billing/BillingLimitConfigSection.tsx +++ b/apps/webapp/app/components/billing/BillingLimitConfigSection.tsx @@ -7,6 +7,7 @@ import { z } from "zod"; import { getBillingLimitMode } from "~/components/billing/billingAlertsFormat"; import { formatGracePeriodMs } from "~/components/billing/billingLimitFormat"; import { AnimatedCallout } from "~/components/primitives/AnimatedCallout"; +import { Callout } from "~/components/primitives/Callout"; import { Button } from "~/components/primitives/Buttons"; import { CheckboxWithLabel } from "~/components/primitives/Checkbox"; import { Fieldset } from "~/components/primitives/Fieldset"; @@ -51,10 +52,14 @@ type BillingLimitActionData = { export function isBillingLimitFormDirty(input: { billingLimit: BillingLimitResult; - mode: "none" | "plan" | "custom"; + mode: "" | "none" | "plan" | "custom"; customAmount: string; cancelInProgressRuns: boolean; }): boolean { + if (input.mode === "") { + return false; + } + const needsInitialSave = !input.billingLimit.isConfigured; const savedMode = getBillingLimitMode(input.billingLimit); const savedCustomAmount = @@ -75,7 +80,7 @@ export function isBillingLimitFormDirty(input: { export function getBillingLimitFormLastSubmission( submission: BillingLimitActionData["submission"] | undefined, - mode: "none" | "plan" | "custom", + mode: "" | "none" | "plan" | "custom", isDirty: boolean ) { if (!isDirty || !submission) { @@ -111,17 +116,20 @@ export function BillingLimitConfigSection({ : ""; const savedCancelInProgressRuns = billingLimit.isConfigured && billingLimit.cancelInProgressRuns; - const [mode, setMode] = useState<"none" | "plan" | "custom">(savedMode); + // Unconfigured limit starts with nothing selected. + const resetMode: "" | "none" | "plan" | "custom" = billingLimit.isConfigured ? savedMode : ""; + + const [mode, setMode] = useState<"" | "none" | "plan" | "custom">(resetMode); const [customAmount, setCustomAmount] = useState(savedCustomAmount); const [cancelInProgressRuns, setCancelInProgressRuns] = useState(savedCancelInProgressRuns); const customAmountInputRef = useRef(null); const formRef = useRef(null); useEffect(() => { - setMode(savedMode); + setMode(resetMode); setCustomAmount(savedCustomAmount); setCancelInProgressRuns(savedCancelInProgressRuns); - }, [savedMode, savedCustomAmount, savedCancelInProgressRuns]); + }, [resetMode, savedCustomAmount, savedCancelInProgressRuns]); function handleModeChange(value: string) { const nextMode = value as typeof mode; @@ -183,6 +191,13 @@ export function BillingLimitConfigSection({
+ {!billingLimit.isConfigured && ( + + Configure a monthly billing limit below to cap your spend, or set no limit to let runs + keep going. + + )} +
@@ -283,7 +298,7 @@ export function BillingLimitConfigSection({
- {mode !== "none" && ( + {(mode === "plan" || mode === "custom") && ( )} - - Save billing limit - - } - /> + {mode !== "" && ( + + Save billing limit + + } + /> + )}
diff --git a/apps/webapp/app/components/billing/BillingLimitRecoveryPanel.tsx b/apps/webapp/app/components/billing/BillingLimitRecoveryPanel.tsx index 53b3c1695de..1dfcdd73707 100644 --- a/apps/webapp/app/components/billing/BillingLimitRecoveryPanel.tsx +++ b/apps/webapp/app/components/billing/BillingLimitRecoveryPanel.tsx @@ -113,7 +113,7 @@ export function BillingLimitRecoveryPanel({
Action required - + {isGrace ? ( <> Your organization has reached its billing limit. Processing is paused and new runs diff --git a/apps/webapp/app/components/billing/OrgBanner.tsx b/apps/webapp/app/components/billing/OrgBanner.tsx index df86f1471f7..03214bab7ec 100644 --- a/apps/webapp/app/components/billing/OrgBanner.tsx +++ b/apps/webapp/app/components/billing/OrgBanner.tsx @@ -151,8 +151,14 @@ function NoLimitConfiguredBanner() { variant="warning" action={ canManageBillingLimits ? ( - - Configure billing limit + + + Configure billing limit + ) : undefined } diff --git a/apps/webapp/app/components/billing/UsageBar.tsx b/apps/webapp/app/components/billing/UsageBar.tsx index 49346492287..fcb6377757c 100644 --- a/apps/webapp/app/components/billing/UsageBar.tsx +++ b/apps/webapp/app/components/billing/UsageBar.tsx @@ -67,7 +67,7 @@ export function UsageBar({ current, billingLimit, tierLimit, isPaying }: UsageBa animate={{ width: tierRunLimitPercentage + "%" }} transition={{ duration: 1.5, type: "spring" }} style={{ width: `${tierRunLimitPercentage}%` }} - className="absolute h-3 rounded-l-sm bg-green-900/50" + className="absolute h-3 rounded-l-sm bg-green-900/20" > 0 && percentageAlertLevelsToUiThresholds(alerts.alertLevels).length > 0) { - return amountCents; + return effectiveLimitCents; } if (percentageAlertAmountMatches(amountCents, effectiveLimitCents, planLimitCents)) { return amountCents; diff --git a/apps/webapp/app/components/code/ChartConfigPanel.tsx b/apps/webapp/app/components/code/ChartConfigPanel.tsx index 1c915c2a449..7711f063d55 100644 --- a/apps/webapp/app/components/code/ChartConfigPanel.tsx +++ b/apps/webapp/app/components/code/ChartConfigPanel.tsx @@ -572,7 +572,7 @@ function SeriesColorPicker({ title="Change series color" > @@ -587,7 +587,7 @@ function SeriesColorPicker({ onColorChange(c); setOpen(false); }} - className="group/swatch flex h-6 w-6 items-center justify-center rounded-full border border-white/30" + className="group/swatch flex h-6 w-6 items-center justify-center rounded-full border border-text-bright/30" style={{ backgroundColor: c }} title={c} > diff --git a/apps/webapp/app/components/code/TSQLEditor.tsx b/apps/webapp/app/components/code/TSQLEditor.tsx index 3b4bf202954..0ddc7cf12d5 100644 --- a/apps/webapp/app/components/code/TSQLEditor.tsx +++ b/apps/webapp/app/components/code/TSQLEditor.tsx @@ -284,7 +284,7 @@ export function TSQLEditor(opts: TSQLEditorProps) { }} /> {showButtons && ( -
+
{additionalActions && additionalActions} {showFormatButton && (
- +
@@ -859,7 +859,7 @@ export function VercelOnboardingModal({ variant="primary/medium" onClick={handleProjectSelection} disabled={!selectedVercelProject || fetcher.state !== "idle"} - LeadingIcon={fetcher.state !== "idle" ? SpinnerWhite : undefined} + LeadingIcon={fetcher.state !== "idle" ? Spinner : undefined} > {fetcher.state !== "idle" ? "Connecting..." : "Connect Project"} @@ -930,7 +930,7 @@ export function VercelOnboardingModal({ variant="primary/medium" onClick={handleUpdateEnvMapping} disabled={envMappingFetcher.state !== "idle"} - LeadingIcon={envMappingFetcher.state !== "idle" ? SpinnerWhite : undefined} + LeadingIcon={envMappingFetcher.state !== "idle" ? Spinner : undefined} > Next @@ -1112,7 +1112,7 @@ export function VercelOnboardingModal({ LeadingIcon={ fromMarketplaceContext && (completeOnboardingFetcher.state !== "idle" || isRedirecting) - ? SpinnerWhite + ? Spinner : undefined } > @@ -1168,7 +1168,7 @@ export function VercelOnboardingModal({ disabled={completeOnboardingFetcher.state !== "idle" || isRedirecting} LeadingIcon={ completeOnboardingFetcher.state !== "idle" || isRedirecting - ? SpinnerWhite + ? Spinner : undefined } > diff --git a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx index 17bc4a047bb..8790e479421 100644 --- a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx +++ b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx @@ -163,11 +163,6 @@ export function OrganizationSettingsSideMenu({ inactiveIconColor="text-text-dimmed" to={organizationSsoPath(organization)} data-action="sso" - badge={ - currentPlan?.v3Subscription?.plan?.code === "enterprise" ? undefined : ( - Enterprise - ) - } /> )}
diff --git a/apps/webapp/app/components/navigation/SideMenuHeader.tsx b/apps/webapp/app/components/navigation/SideMenuHeader.tsx index 8ccbfa7de70..698ab1a2326 100644 --- a/apps/webapp/app/components/navigation/SideMenuHeader.tsx +++ b/apps/webapp/app/components/navigation/SideMenuHeader.tsx @@ -1,8 +1,7 @@ import { useNavigation } from "@remix-run/react"; import { useEffect, useState } from "react"; import { motion } from "framer-motion"; -import { Popover, PopoverContent, PopoverCustomTrigger } from "../primitives/Popover"; -import { EllipsisHorizontalIcon } from "@heroicons/react/20/solid"; +import { Popover, PopoverContent, PopoverEllipseTrigger } from "../primitives/Popover"; export function SideMenuHeader({ title, @@ -30,7 +29,7 @@ export function SideMenuHeader({ return ( {children !== undefined ? ( setHeaderMenuOpen(open)} open={isHeaderMenuOpen}> - - - + @@ -18,7 +18,7 @@ export function TreeConnectorBranch({ className }: { className?: string }) { export function TreeConnectorEnd({ className }: { className?: string }) { return ( diff --git a/apps/webapp/app/components/primitives/AppliedFilter.tsx b/apps/webapp/app/components/primitives/AppliedFilter.tsx index c8fbc4d3ac3..6390a5826d6 100644 --- a/apps/webapp/app/components/primitives/AppliedFilter.tsx +++ b/apps/webapp/app/components/primitives/AppliedFilter.tsx @@ -4,11 +4,11 @@ import { cn } from "~/utils/cn"; const variants = { "secondary/small": { - box: "h-6 bg-secondary rounded pl-1.5 gap-1.5 text-xs divide-x divide-black/15 group-hover:bg-surface-control group-hover:border-border-brighter text-text-bright border border-border-bright", + box: "h-6 bg-secondary rounded pl-1.5 gap-1.5 text-xs divide-x divide-black/15 shadow-xs group-hover:bg-background-raised text-text-bright border border-border-bright/50", clear: "size-6 text-text-bright hover:text-text-bright transition-colors", }, "tertiary/small": { - box: "h-6 bg-tertiary rounded pl-1.5 gap-1.5 text-xs divide-x divide-black/15 group-hover:bg-surface-control", + box: "h-6 bg-tertiary rounded pl-1.5 gap-1.5 text-xs divide-x divide-black/15 group-hover:bg-background-raised", clear: "size-6 text-text-dimmed hover:text-text-bright transition-colors", }, "minimal/medium": { diff --git a/apps/webapp/app/components/primitives/Avatar.tsx b/apps/webapp/app/components/primitives/Avatar.tsx index 9fc6832fcc4..728626271f4 100644 --- a/apps/webapp/app/components/primitives/Avatar.tsx +++ b/apps/webapp/app/components/primitives/Avatar.tsx @@ -1,10 +1,10 @@ +import { GlobeLinesIcon } from "~/assets/icons/GlobeLinesIcon"; import { BoltIcon, BuildingOffice2Icon, CodeBracketSquareIcon, FaceSmileIcon, FireIcon, - GlobeAltIcon, RocketLaunchIcon, StarIcon, } from "@heroicons/react/20/solid"; @@ -117,6 +117,16 @@ function styleFromSize(size: number) { }; } +// Bright tiles (Yellow, Orange) need dark letters for contrast; the rest read +// best with white. +function letterColorForBackground(hex: string): string { + const match = /^#?([0-9a-f]{6})$/i.exec(hex); + if (!match) return "#fff"; + const n = parseInt(match[1], 16); + const luminance = 0.299 * ((n >> 16) & 255) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255); + return luminance > 140 ? "#272A2E" : "#fff"; +} + function AvatarLetters({ avatar, size, @@ -132,15 +142,13 @@ function AvatarLetters({ const style = { backgroundColor: avatar.hex, + color: letterColorForBackground(avatar.hex), }; const scaleFactor = includePadding ? 0.8 : 1; return ( - + {/* This is the square container */} - + ); } diff --git a/apps/webapp/app/components/primitives/Badge.tsx b/apps/webapp/app/components/primitives/Badge.tsx index f21a544a4cd..15733bcc647 100644 --- a/apps/webapp/app/components/primitives/Badge.tsx +++ b/apps/webapp/app/components/primitives/Badge.tsx @@ -5,13 +5,13 @@ const variants = { default: "grid place-items-center rounded-full px-2 h-5 tracking-wider text-xxs bg-background-hover text-text-bright uppercase whitespace-nowrap", "extra-small": - "grid place-items-center border border-border-bright rounded-sm px-1 h-4 text-xxs bg-background-bright text-blue-500 whitespace-nowrap", + "grid place-items-center border border-border-bright rounded-sm px-1 h-4 text-xxs bg-background-bright text-blue-500 system:border-transparent system:bg-blue-500/10 system:text-blue-500 whitespace-nowrap", small: - "grid place-items-center border border-border-bright rounded-sm px-1 h-5 text-xs bg-background-bright text-blue-500 whitespace-nowrap", + "grid place-items-center border border-border-bright rounded-sm px-1 h-5 text-xs bg-background-bright text-blue-500 system:border-transparent system:bg-blue-500/10 system:text-blue-500 whitespace-nowrap", "outline-rounded": "grid place-items-center rounded-full px-1 h-4 tracking-wider text-xxs border border-blue-500 text-blue-500 uppercase whitespace-nowrap", rounded: - "grid place-items-center rounded-full px-1.5 h-4 text-xxs border bg-blue-600 text-text-bright uppercase whitespace-nowrap", + "grid place-items-center rounded-full px-1.5 h-4 text-xxs border bg-blue-600 text-text-bright system:border-transparent system:text-white uppercase whitespace-nowrap", }; type BadgeProps = React.HTMLAttributes & { diff --git a/apps/webapp/app/components/primitives/BreadcrumbIcon.tsx b/apps/webapp/app/components/primitives/BreadcrumbIcon.tsx index b5dfb55ba7e..7d7fae46be0 100644 --- a/apps/webapp/app/components/primitives/BreadcrumbIcon.tsx +++ b/apps/webapp/app/components/primitives/BreadcrumbIcon.tsx @@ -3,7 +3,7 @@ import { cn } from "~/utils/cn"; export function BreadcrumbIcon({ className }: { className?: string }) { return ( ( : props.readOnly ? "cursor-default" : "cursor-pointer", - "read-only:border-border-bright disabled:border-border-bright disabled:opacity-50 rounded-sm border border-border-bright bg-transparent transition checked:bg-indigo-500! read-only:bg-background-raised! group-hover:bg-background-deep checked:group-hover:bg-indigo-500 group-focus:ring-1 focus:ring-indigo-500 focus:ring-offset-0 focus:ring-offset-transparent focus-visible:outline-hidden focus-visible:ring-indigo-500 disabled:bg-background-raised!" + // NB: don't use the `read-only:` variant here — checkboxes always + // match :read-only, so it would override the checked style. + "rounded-sm border border-border-bright bg-transparent transition checked:bg-indigo-500! group-hover:bg-background-deep checked:group-hover:bg-indigo-500 group-focus:ring-1 focus:ring-indigo-500 focus:ring-offset-0 focus:ring-offset-transparent focus-visible:outline-hidden focus-visible:ring-indigo-500", + props.disabled && "opacity-50", + (props.disabled || props.readOnly) && + "bg-background-raised! checked:bg-background-raised! checked:group-hover:bg-background-raised! group-hover:bg-background-raised!", + className )} {...props} ref={ref} diff --git a/apps/webapp/app/components/primitives/ClientTabs.tsx b/apps/webapp/app/components/primitives/ClientTabs.tsx index 533a09c84e6..48757676d61 100644 --- a/apps/webapp/app/components/primitives/ClientTabs.tsx +++ b/apps/webapp/app/components/primitives/ClientTabs.tsx @@ -122,10 +122,10 @@ const ClientTabsTrigger = React.forwardRef< ) : ( -
+
) ) : null} @@ -175,7 +175,7 @@ const ClientTabsTrigger = React.forwardRef< ref={ref} tabIndex={0} className={cn( - "inline-flex items-center justify-center whitespace-nowrap border-r border-grid-bright px-2 text-sm transition-all first:pl-0 last:border-none focus-custom data-[state=active]:text-indigo-500 data-[state=inactive]:text-text-dimmed data-[state=inactive]:hover:text-text-bright disabled:pointer-events-none disabled:opacity-50", + "inline-flex items-center justify-center whitespace-nowrap border-r border-grid-bright px-2 text-sm transition-all first:pl-0 last:border-none focus-custom data-[state=active]:text-indigo-500 system:data-[state=active]:text-text-bright data-[state=inactive]:text-text-dimmed data-[state=inactive]:hover:text-text-bright disabled:pointer-events-none disabled:opacity-50", className )} {...props} diff --git a/apps/webapp/app/components/primitives/ClipboardField.tsx b/apps/webapp/app/components/primitives/ClipboardField.tsx index 0304e5bca6b..07d82f5efe3 100644 --- a/apps/webapp/app/components/primitives/ClipboardField.tsx +++ b/apps/webapp/app/components/primitives/ClipboardField.tsx @@ -5,7 +5,7 @@ import { CopyButton } from "./CopyButton"; const variants = { "primary/small": { container: - "flex items-center text-text-dimmed font-mono rounded border bg-background-hover text-xs transition hover:bg-background-raised focus-visible:outline-hidden focus-visible:ring-0 focus-visible:ring-offset-0 focus:border-transparent focus:outline-hidden focus:ring-0 focus:ring-transparent", + "flex items-center text-text-dimmed font-mono rounded border border-border-bright/50 shadow-xs bg-input-bg text-xs transition hover:bg-background-raised focus-visible:outline-hidden focus-visible:ring-0 focus-visible:ring-offset-0 focus:border-transparent focus:outline-hidden focus:ring-0 focus:ring-transparent", input: "bg-transparent border-0 text-xs px-2 w-auto rounded-l h-6 leading-6 focus:ring-transparent", buttonVariant: "primary" as const, @@ -14,7 +14,7 @@ const variants = { }, "secondary/small": { container: - "flex items-center text-text-dimmed font-mono rounded border bg-background-hover text-xs transition hover:bg-background-raised focus-visible:outline-hidden focus-visible:ring-0 focus-visible:ring-offset-0 focus:border-transparent focus:outline-hidden focus:ring-0 focus:ring-transparent", + "flex items-center text-text-dimmed font-mono rounded border border-border-bright/50 shadow-xs bg-input-bg text-xs transition hover:bg-background-raised focus-visible:outline-hidden focus-visible:ring-0 focus-visible:ring-offset-0 focus:border-transparent focus:outline-hidden focus:ring-0 focus:ring-transparent", input: "bg-transparent border-0 text-xs px-2 w-auto rounded-l h-6 leading-6 focus:ring-transparent", buttonVariant: "tertiary" as const, @@ -33,7 +33,7 @@ const variants = { }, "primary/medium": { container: - "flex items-center text-text-dimmed font-mono rounded border bg-background-hover text-sm transition hover:bg-background-raised focus-visible:outline-hidden focus-visible:ring-0 focus-visible:ring-offset-0 focus:border-transparent focus:outline-hidden focus:ring-0 focus:ring-transparent", + "flex items-center text-text-dimmed font-mono rounded border border-border-bright/50 shadow-xs bg-input-bg text-sm transition hover:bg-background-raised focus-visible:outline-hidden focus-visible:ring-0 focus-visible:ring-offset-0 focus:border-transparent focus:outline-hidden focus:ring-0 focus:ring-transparent", input: "bg-transparent border-0 text-sm px-3 w-auto rounded-l h-8 leading-6 focus:ring-transparent", buttonVariant: "primary" as const, @@ -42,7 +42,7 @@ const variants = { }, "secondary/medium": { container: - "flex items-center text-text-dimmed font-mono rounded bg-background-hover text-sm transition hover:bg-background-raised focus-visible:outline-hidden focus-visible:ring-0 focus-visible:ring-offset-0 focus:border-transparent focus:outline-hidden focus:ring-0 focus:ring-transparent", + "flex items-center text-text-dimmed font-mono rounded border border-border-bright/50 shadow-xs bg-input-bg text-sm transition hover:bg-background-raised focus-visible:outline-hidden focus-visible:ring-0 focus-visible:ring-offset-0 focus:border-transparent focus:outline-hidden focus:ring-0 focus:ring-transparent", input: "bg-transparent border-0 text-sm px-3 w-auto rounded-l h-8 leading-6 focus:ring-transparent", buttonVariant: "tertiary" as const, diff --git a/apps/webapp/app/components/primitives/CopyableText.tsx b/apps/webapp/app/components/primitives/CopyableText.tsx index f8e19402aed..9634ad1ac01 100644 --- a/apps/webapp/app/components/primitives/CopyableText.tsx +++ b/apps/webapp/app/components/primitives/CopyableText.tsx @@ -11,12 +11,19 @@ export function CopyableText({ className, asChild, variant, + hideTooltip, }: { value: string; copyValue?: string; className?: string; asChild?: boolean; variant?: "icon-right" | "text-below"; + /** + * Hide the "Copy"/"Copied" hint tooltip. Use when this is rendered inside another + * Radix tooltip (e.g. the admin debug panel): the nested tooltip would otherwise + * fire Radix's global "one tooltip open at a time" close and dismiss the parent. + */ + hideTooltip?: boolean; }) { const [isHovered, setIsHovered] = useState(false); const { copy, copied } = useCopy(copyValue ?? value); @@ -24,6 +31,24 @@ export function CopyableText({ const resolvedVariant = variant ?? "icon-right"; if (resolvedVariant === "icon-right") { + const iconButton = ( + + {copied ? ( + + ) : ( + + )} + + ); + return ( - - {copied ? ( - - ) : ( - - )} - - } - content={copied ? "Copied!" : "Copy"} - className="font-sans" - disableHoverableContent - asChild={asChild} - /> + {hideTooltip ? ( + iconButton + ) : ( + + )} ); diff --git a/apps/webapp/app/components/primitives/DateField.tsx b/apps/webapp/app/components/primitives/DateField.tsx index 56cf5086493..a68616fc1b1 100644 --- a/apps/webapp/app/components/primitives/DateField.tsx +++ b/apps/webapp/app/components/primitives/DateField.tsx @@ -275,7 +275,7 @@ function DateSegmentGuide({ segment }: { segment: DateSegment }) { style={{ minWidth: minWidthForSegment(segment), }} - className={`group box-content rounded-sm px-0.5 text-right text-sm tabular-nums text-rose-500 outline-hidden ${ + className={`group box-content rounded-sm px-0.5 text-right text-sm tabular-nums outline-hidden ${ !segment.isEditable ? "text-text-faint" : "text-text-bright" }`} > diff --git a/apps/webapp/app/components/primitives/DateTime.tsx b/apps/webapp/app/components/primitives/DateTime.tsx index 355af397a11..3c1227e0c9a 100644 --- a/apps/webapp/app/components/primitives/DateTime.tsx +++ b/apps/webapp/app/components/primitives/DateTime.tsx @@ -1,4 +1,5 @@ -import { GlobeAltIcon, GlobeAmericasIcon } from "@heroicons/react/20/solid"; +import { GlobeAmericasIcon } from "@heroicons/react/20/solid"; +import { GlobeLinesIcon } from "~/assets/icons/GlobeLinesIcon"; import { useRouteLoaderData } from "@remix-run/react"; import { formatDistanceToNow } from "date-fns"; import { Laptop } from "lucide-react"; @@ -523,7 +524,7 @@ function TooltipContent({ title="UTC" dateTime={formatDateTime(realDate, "UTC", locales, true, true, true)} isoDateTime={formatDateTimeISO(realDate, "UTC")} - icon={} + icon={} /> - +
diff --git a/apps/webapp/app/components/primitives/Popover.tsx b/apps/webapp/app/components/primitives/Popover.tsx index 21ff5cd9045..fccba52a89b 100644 --- a/apps/webapp/app/components/primitives/Popover.tsx +++ b/apps/webapp/app/components/primitives/Popover.tsx @@ -1,7 +1,7 @@ "use client"; import { CheckIcon } from "@heroicons/react/20/solid"; -import { EllipsisVerticalIcon } from "@heroicons/react/24/solid"; +import { EllipsisHorizontalIcon, EllipsisVerticalIcon } from "@heroicons/react/24/solid"; import * as PopoverPrimitive from "@radix-ui/react-popover"; import { Link } from "@remix-run/react"; import * as React from "react"; @@ -279,20 +279,29 @@ const popoverVerticalEllipseVariants = { "size-6 rounded border border-border-bright bg-secondary text-text-bright hover:bg-surface-control hover:border-border-brighter", icon: "size-4", }, + // No box/background — the icon inherits the trigger's text color, so callers + // can drive brightening from a parent hover (e.g. a section header). + ghost: { + trigger: "p-1 text-text-faint hover:text-text-bright", + icon: "size-4", + }, } as const; type PopoverVerticalEllipseVariant = keyof typeof popoverVerticalEllipseVariants; -function PopoverVerticalEllipseTrigger({ +function PopoverEllipseTrigger({ isOpen, variant = "minimal", + orientation = "vertical", className, ...props }: { isOpen?: boolean; variant?: PopoverVerticalEllipseVariant; + orientation?: "vertical" | "horizontal"; } & React.ComponentPropsWithoutRef) { const styles = popoverVerticalEllipseVariants[variant]; + const Icon = orientation === "horizontal" ? EllipsisHorizontalIcon : EllipsisVerticalIcon; return ( - + ); } +// Back-compat alias: the trigger now supports both orientations. +const PopoverVerticalEllipseTrigger = PopoverEllipseTrigger; + export { Popover, PopoverArrowTrigger, @@ -314,6 +326,7 @@ export { PopoverCustomTrigger, PopoverMenuItem, PopoverSectionHeader, + PopoverEllipseTrigger, PopoverSideMenuTrigger, PopoverTrigger, PopoverVerticalEllipseTrigger, diff --git a/apps/webapp/app/components/primitives/RadioButton.tsx b/apps/webapp/app/components/primitives/RadioButton.tsx index c52389f788f..d936a81b923 100644 --- a/apps/webapp/app/components/primitives/RadioButton.tsx +++ b/apps/webapp/app/components/primitives/RadioButton.tsx @@ -22,7 +22,7 @@ const variants = { }, "button/small": { button: - "flex items-center w-fit h-8 pl-2 pr-3 rounded-md border hover:data-[state=checked]:border-border-bright border-border-bright hover:border-border-bright transition data-disabled:opacity-70 data-disabled:hover:bg-transparent hover:data-[state=checked]:bg-white/4 data-[state=checked]:bg-white/4", + "flex items-center w-fit h-8 pl-2 pr-3 rounded-md border border-border-bright/50 shadow-xs bg-secondary transition hover:bg-background-raised data-disabled:opacity-70 data-disabled:hover:bg-secondary hover:data-[state=checked]:bg-text-bright/4 data-[state=checked]:bg-text-bright/4", label: "text-sm text-text-bright select-none", description: "text-text-dimmed", inputPosition: "mt-0", @@ -30,7 +30,7 @@ const variants = { }, button: { button: - "w-fit py-2 pl-3 pr-4 rounded border border-border-bright hover:bg-background-dimmed hover:border-border-brightest transition data-[state=checked]:bg-background-dimmed data-disabled:opacity-70", + "w-fit py-2 pl-3 pr-4 rounded border border-border-bright/50 shadow-xs bg-secondary hover:bg-background-raised transition data-[state=checked]:bg-background-dimmed data-disabled:opacity-70", label: "text-text-bright select-none", description: "text-text-dimmed", inputPosition: "mt-1", @@ -38,7 +38,7 @@ const variants = { }, description: { button: - "w-full p-2.5 hover:data-[state=checked]:bg-white/4 data-[state=checked]:bg-white/4 transition data-disabled:opacity-70 hover:border-border-bright border-border-bright hover:data-[state=checked]:border-border-bright border rounded-md", + "w-full p-2.5 rounded-md border border-border-bright/50 shadow-xs bg-secondary transition hover:bg-background-raised data-disabled:opacity-70 hover:data-[state=checked]:bg-text-bright/4 data-[state=checked]:bg-text-bright/4", label: "text-text-bright font-semibold -mt-0.5 text-left text-sm", description: "text-text-dimmed mt-0 text-left", inputPosition: "mt-0", @@ -46,7 +46,7 @@ const variants = { }, icon: { button: - "w-full p-2.5 pb-4 hover:bg-background-dimmed transition data-disabled:opacity-70 data-[state=checked]:bg-background-dimmed border-border-bright border rounded-sm", + "w-full p-2.5 pb-4 rounded-sm border border-border-bright/50 shadow-xs bg-secondary hover:bg-background-raised transition data-disabled:opacity-70 data-[state=checked]:bg-background-dimmed", label: "text-text-bright font-semibold -mt-1 text-left", description: "text-text-dimmed mt-0 text-left", inputPosition: "mt-0", @@ -82,7 +82,10 @@ export function RadioButtonCircle({ )} >
)} @@ -136,7 +139,7 @@ export const RadioGroupItem = React.forwardRef< )} > - +
diff --git a/apps/webapp/app/components/primitives/Resizable.tsx b/apps/webapp/app/components/primitives/Resizable.tsx index 59550332c26..0bd4f8e86d9 100644 --- a/apps/webapp/app/components/primitives/Resizable.tsx +++ b/apps/webapp/app/components/primitives/Resizable.tsx @@ -2,6 +2,8 @@ import React, { useRef } from "react"; import { PanelGroup, Panel, PanelResizer } from "@window-splitter/react"; +import { useTypedMatchesData } from "~/hooks/useTypedMatchData"; +import type { loader as rootLoader } from "~/root"; import { cn } from "~/utils/cn"; const ResizablePanelGroup = ({ className, ...props }: React.ComponentProps) => ( @@ -14,7 +16,25 @@ const ResizablePanelGroup = ({ className, ...props }: React.ComponentProps ); -const ResizablePanel = Panel; +// react-window-splitter drives the collapse animation through @react-spring/rafz, +// which has timing/interaction issues with Firefox that produce visual glitches +// (alternating frames, panels stuck at min, panelHasSpace invariant violations), +// so the animation is dropped on Firefox. The browser check must agree between +// SSR and hydration (a client-only `navigator` check made the panel tree differ +// and shifted useIds), so it comes from the root loader's user-agent sniff. +const ResizablePanel = React.forwardRef< + React.ElementRef, + React.ComponentProps +>(function ResizablePanel({ collapseAnimation, ...props }, ref) { + const rootData = useTypedMatchesData({ id: "root" }); + return ( + + ); +}); const ResizableHandle = ({ withHandle = true, @@ -69,14 +89,8 @@ const ResizableHandle = ({ ); -// react-window-splitter drives the collapse animation through @react-spring/rafz, -// which has timing/interaction issues with Firefox that produce visual glitches -// (alternating frames, panels stuck at min, panelHasSpace invariant violations). -// Disable the animation on Firefox; it works correctly in Chromium and Safari. -const RESIZABLE_PANEL_ANIMATION = - typeof navigator !== "undefined" && /firefox/i.test(navigator.userAgent) - ? undefined - : ({ easing: "ease-in-out", duration: 300 } as const); +// Firefox filtering happens inside ResizablePanel (see above). +const RESIZABLE_PANEL_ANIMATION = { easing: "ease-in-out", duration: 300 } as const; const COLLAPSIBLE_HANDLE_CLASSNAME = "transition-opacity duration-200"; diff --git a/apps/webapp/app/components/primitives/SegmentedControl.tsx b/apps/webapp/app/components/primitives/SegmentedControl.tsx index 2f749215c29..5cf65fce4dc 100644 --- a/apps/webapp/app/components/primitives/SegmentedControl.tsx +++ b/apps/webapp/app/components/primitives/SegmentedControl.tsx @@ -24,10 +24,11 @@ const theme = { selected: "absolute inset-0 rounded-[2px] outline-solid outline-3 outline-primary", }, secondary: { - base: "bg-background-raised/50", + base: "bg-transparent dark:bg-background-raised/50", active: "text-text-bright", inactive: "text-text-dimmed transition hover:text-text-bright", - selected: "absolute inset-0 rounded bg-background-raised border border-border-bright", + selected: + "absolute inset-0 rounded bg-white border border-grid-bright dark:bg-background-raised dark:border-border-bright", }, }; diff --git a/apps/webapp/app/components/primitives/Select.tsx b/apps/webapp/app/components/primitives/Select.tsx index 66b291b7524..68e1bae5882 100644 --- a/apps/webapp/app/components/primitives/Select.tsx +++ b/apps/webapp/app/components/primitives/Select.tsx @@ -30,7 +30,7 @@ const style = { }, secondary: { button: - "bg-secondary focus-custom border border-border-bright hover:text-text-bright hover:border-border-brighter text-text-bright hover:bg-surface-control", + "bg-secondary focus-custom border border-border-bright/50 shadow-xs hover:text-text-bright text-text-bright hover:bg-background-raised", }, }; @@ -62,6 +62,7 @@ type Section = { function isSection(data: TItem[] | Section[]): data is Section[] { const firstItem = data[0]; + if (!firstItem) return false; return ( (firstItem as Section).type === "section" && (firstItem as Section).items !== undefined && diff --git a/apps/webapp/app/components/primitives/Slider.tsx b/apps/webapp/app/components/primitives/Slider.tsx index b5af54e2ba4..4d6568bbf0b 100644 --- a/apps/webapp/app/components/primitives/Slider.tsx +++ b/apps/webapp/app/components/primitives/Slider.tsx @@ -5,6 +5,18 @@ import type { RenderIcon } from "./Icon"; import { Icon } from "./Icon"; const variants = { + /* Quiet variant for settings rows: no hover box, no thumb halo */ + settings: { + container: "h-6 gap-1 rounded-sm px-1", + icons: "h-4 w-4 text-text-bright", + root: "h-4 grow", + track: "h-1 bg-grid-bright", + range: "bg-transparent", + // Matches the Switch thumb; the secondary-button hairline+shadow keeps the + // white dot visible on the light track + thumb: + "h-3 w-3 border border-border-bright bg-white shadow-sm dark:border-transparent dark:bg-charcoal-200 dark:shadow-none", + }, tertiary: { container: "h-6 gap-1 rounded-sm hover:bg-background-raised px-1", icons: "h-4 w-4 text-text-bright", diff --git a/apps/webapp/app/components/primitives/Switch.tsx b/apps/webapp/app/components/primitives/Switch.tsx index 96943e9bebb..e07187176a8 100644 --- a/apps/webapp/app/components/primitives/Switch.tsx +++ b/apps/webapp/app/components/primitives/Switch.tsx @@ -36,7 +36,7 @@ const variations = { "secondary/small": { container: cn( small.container, - "border border-border-bright hover:border-border-brighter bg-secondary hover:bg-surface-control" + "border border-border-bright/50 shadow-xs bg-secondary hover:bg-background-raised" ), root: cn( small.root, @@ -98,7 +98,7 @@ export const Switch = React.forwardRef
diff --git a/apps/webapp/app/components/primitives/UsageSparkline.tsx b/apps/webapp/app/components/primitives/UsageSparkline.tsx index 89c471a405e..e0de505a8f3 100644 --- a/apps/webapp/app/components/primitives/UsageSparkline.tsx +++ b/apps/webapp/app/components/primitives/UsageSparkline.tsx @@ -107,7 +107,7 @@ export function UsageSparkline({
{hideTotal ? null : ( - + {formatTotal ? formatTotal(total) : total.toLocaleString()} )} diff --git a/apps/webapp/app/components/primitives/charts/Chart.tsx b/apps/webapp/app/components/primitives/charts/Chart.tsx index a742fbbde4c..e0b6e6bb211 100644 --- a/apps/webapp/app/components/primitives/charts/Chart.tsx +++ b/apps/webapp/app/components/primitives/charts/Chart.tsx @@ -5,7 +5,9 @@ import { AnimatedNumber } from "../AnimatedNumber"; import TooltipPortal from "../TooltipPortal"; // Format: { THEME_NAME: CSS_SELECTOR } -const THEMES = { light: "", dark: '[data-theme="dark"]' } as const; +// dark covers Classic too; :is() keeps it one scoped selector when the prefix is +// interpolated as `${prefix} [data-chart=...]` below (a comma would break scoping). +const THEMES = { light: "", dark: ':is([data-theme="dark"], [data-theme="classic"])' } as const; export type ChartState = "loading" | "noData" | "invalid" | "loaded" | undefined; diff --git a/apps/webapp/app/components/primitives/charts/ChartZoom.tsx b/apps/webapp/app/components/primitives/charts/ChartZoom.tsx index 375a133f445..4be839f3f68 100644 --- a/apps/webapp/app/components/primitives/charts/ChartZoom.tsx +++ b/apps/webapp/app/components/primitives/charts/ChartZoom.tsx @@ -126,8 +126,8 @@ export function ZoomTooltip({ className={cn( "absolute whitespace-nowrap rounded border px-2 py-1 text-xxs tabular-nums", invalidSelection - ? "border-amber-800 bg-amber-950 text-amber-400" - : "border-blue-800 bg-[#1B2334] text-blue-400" + ? "border-amber-800 bg-amber-950 text-amber-400 light:border-amber-300 light:bg-amber-50 light:text-amber-700" + : "border-blue-800 bg-[#1B2334] text-blue-400 light:border-blue-300 light:bg-blue-50 light:text-blue-700" )} style={{ left: coordinate?.x, @@ -140,8 +140,8 @@ export function ZoomTooltip({ className={cn( "absolute top-[-5px] left-1/2 h-2 w-2 -translate-x-1/2 rotate-45", invalidSelection - ? "border-l border-t border-amber-800 bg-amber-950" - : "border-l border-t border-blue-800 bg-[#1B2334]" + ? "border-l border-t border-amber-800 bg-amber-950 light:border-amber-300 light:bg-amber-50" + : "border-l border-t border-blue-800 bg-[#1B2334] light:border-blue-300 light:bg-blue-50" )} />
diff --git a/apps/webapp/app/components/query/QueryEditor.tsx b/apps/webapp/app/components/query/QueryEditor.tsx index 039bc7ccbf2..447f8398a4e 100644 --- a/apps/webapp/app/components/query/QueryEditor.tsx +++ b/apps/webapp/app/components/query/QueryEditor.tsx @@ -241,7 +241,7 @@ const QueryEditorForm = forwardRef< ); return ( -
+
{error} diff --git a/apps/webapp/app/components/runs/v3/BatchStatus.tsx b/apps/webapp/app/components/runs/v3/BatchStatus.tsx index 17d02b23378..243c5eaac19 100644 --- a/apps/webapp/app/components/runs/v3/BatchStatus.tsx +++ b/apps/webapp/app/components/runs/v3/BatchStatus.tsx @@ -42,7 +42,12 @@ export function BatchStatusCombo({ } export function BatchStatusLabel({ status }: { status: BatchTaskRunStatus }) { - return {batchStatusTitle(status)}; + // system-mono-label: System themes uncolor the label (see tailwind.css) + return ( + + {batchStatusTitle(status)} + + ); } export function BatchStatusIcon({ diff --git a/apps/webapp/app/components/runs/v3/BulkAction.tsx b/apps/webapp/app/components/runs/v3/BulkAction.tsx index 3c25aa4409a..c472ebdaaef 100644 --- a/apps/webapp/app/components/runs/v3/BulkAction.tsx +++ b/apps/webapp/app/components/runs/v3/BulkAction.tsx @@ -121,13 +121,14 @@ export function BulkActionStatusLabel({ status: BulkActionStatus; className?: string; }) { + // system-mono-label: System themes uncolor the label (see tailwind.css) switch (status) { case "PENDING": - return In progress; + return In progress; case "COMPLETED": - return Completed; + return Completed; case "ABORTED": - return Aborted; + return Aborted; default: { assertNever(status); } diff --git a/apps/webapp/app/components/runs/v3/DeploymentStatus.tsx b/apps/webapp/app/components/runs/v3/DeploymentStatus.tsx index f4142cffce2..aae5f97ecb4 100644 --- a/apps/webapp/app/components/runs/v3/DeploymentStatus.tsx +++ b/apps/webapp/app/components/runs/v3/DeploymentStatus.tsx @@ -35,7 +35,8 @@ export function DeploymentStatusLabel({ isBuilt: boolean; }) { return ( - + // system-mono-label: System themes uncolor the label (see tailwind.css) + {deploymentStatusTitle(status, isBuilt)} ); diff --git a/apps/webapp/app/components/runs/v3/RunFilters.tsx b/apps/webapp/app/components/runs/v3/RunFilters.tsx index f5980d9e597..1e4462577b5 100644 --- a/apps/webapp/app/components/runs/v3/RunFilters.tsx +++ b/apps/webapp/app/components/runs/v3/RunFilters.tsx @@ -1,9 +1,9 @@ import * as Ariakit from "@ariakit/react"; +import { GlobeLinesIcon } from "~/assets/icons/GlobeLinesIcon"; import { CalendarIcon, CpuChipIcon, FingerPrintIcon, - GlobeAltIcon, PlusIcon, RectangleStackIcon, Squares2X2Icon, @@ -282,7 +282,7 @@ export function filterIcon(filterKey: string): ReactNode | undefined { case "queues": return ; case "regions": - return ; + return ; case "machines": return ; case "versions": @@ -423,7 +423,7 @@ const filterTypes = [ { name: "tags", title: "Tags", icon: }, { name: "versions", title: "Versions", icon: }, { name: "queues", title: "Queues", icon: }, - { name: "regions", title: "Region", icon: }, + { name: "regions", title: "Region", icon: }, { name: "machines", title: "Machines", icon: }, { name: "run", title: "Run ID", icon: }, { name: "batch", title: "Batch ID", icon: }, @@ -673,7 +673,7 @@ function PermanentStatusFilter() { className="pl-1" /> ) : ( -
+
@@ -852,7 +852,7 @@ function PermanentTasksFilter({ possibleTasks }: Pick ) : ( -
+
{filterIcon("tasks")} Tasks
diff --git a/apps/webapp/app/components/runs/v3/RunTag.tsx b/apps/webapp/app/components/runs/v3/RunTag.tsx index 3d6c6751ce3..16d2333632e 100644 --- a/apps/webapp/app/components/runs/v3/RunTag.tsx +++ b/apps/webapp/app/components/runs/v3/RunTag.tsx @@ -1,5 +1,4 @@ import { useCallback, useMemo, useState } from "react"; -import tagLeftPath from "./tag-left.svg"; import { SimpleTooltip } from "~/components/primitives/Tooltip"; import { Link } from "@remix-run/react"; import { cn } from "~/utils/cn"; @@ -7,6 +6,26 @@ import { ClipboardCheckIcon, ClipboardIcon, XIcon } from "lucide-react"; type Tag = string | { key: string; value: string }; +function TagNotch() { + return ( + + ); +} + export function RunTag({ tag, to, @@ -26,8 +45,8 @@ export function RunTag({ if (typeof tagResult === "string") { return ( <> - - + + {tag} @@ -35,11 +54,11 @@ export function RunTag({ } else { return ( <> - - + + {tagResult.key} - + {tagResult.value} diff --git a/apps/webapp/app/components/runs/v3/SpanEvents.tsx b/apps/webapp/app/components/runs/v3/SpanEvents.tsx index c79a147b86f..069246c89b7 100644 --- a/apps/webapp/app/components/runs/v3/SpanEvents.tsx +++ b/apps/webapp/app/components/runs/v3/SpanEvents.tsx @@ -85,7 +85,7 @@ export function SpanEventError({ /> {enhancedException.message && ( -
+          
             {enhancedException.message}
           
diff --git a/apps/webapp/app/components/runs/v3/SpanTitle.tsx b/apps/webapp/app/components/runs/v3/SpanTitle.tsx index e01b1599cae..be363ca6ab3 100644 --- a/apps/webapp/app/components/runs/v3/SpanTitle.tsx +++ b/apps/webapp/app/components/runs/v3/SpanTitle.tsx @@ -231,7 +231,8 @@ export function eventBorderClassName(event: RunEvent) { function textClassNameForVariant(variant: TaskEventStyle["variant"]) { switch (variant) { case "primary": { - return "text-blue-500"; + // System themes: monochrome span titles, icons carry the color + return "text-blue-500 system:text-text-bright"; } default: { return "text-text-dimmed"; diff --git a/apps/webapp/app/components/runs/v3/TaskRunAttemptStatus.tsx b/apps/webapp/app/components/runs/v3/TaskRunAttemptStatus.tsx index 36333f6d968..6358ccecd9a 100644 --- a/apps/webapp/app/components/runs/v3/TaskRunAttemptStatus.tsx +++ b/apps/webapp/app/components/runs/v3/TaskRunAttemptStatus.tsx @@ -39,7 +39,10 @@ export function TaskRunAttemptStatusLabel({ status: ExtendedTaskAttemptStatus | null; }) { return ( - {runAttemptStatusTitle(status)} + // system-mono-label: System themes uncolor the label (see tailwind.css) + + {runAttemptStatusTitle(status)} + ); } diff --git a/apps/webapp/app/components/runs/v3/TaskRunStatus.tsx b/apps/webapp/app/components/runs/v3/TaskRunStatus.tsx index 9d20f5a525e..d0a4c74ffbb 100644 --- a/apps/webapp/app/components/runs/v3/TaskRunStatus.tsx +++ b/apps/webapp/app/components/runs/v3/TaskRunStatus.tsx @@ -149,7 +149,12 @@ export function TaskRunStatusReason({ } export function TaskRunStatusLabel({ status }: { status: TaskRunStatus }) { - return {runStatusTitle(status)}; + // system-mono-label: System themes uncolor the label (see tailwind.css) + return ( + + {runStatusTitle(status)} + + ); } export function TaskRunStatusIcon({ diff --git a/apps/webapp/app/components/runs/v3/WaitpointStatus.tsx b/apps/webapp/app/components/runs/v3/WaitpointStatus.tsx index 9879286e565..e5825088b3a 100644 --- a/apps/webapp/app/components/runs/v3/WaitpointStatus.tsx +++ b/apps/webapp/app/components/runs/v3/WaitpointStatus.tsx @@ -24,7 +24,10 @@ export function WaitpointStatusCombo({ export function WaitpointStatusLabel({ status }: { status: WaitpointTokenStatus }) { return ( - {waitpointStatusTitle(status)} + // system-mono-label: System themes uncolor the label (see tailwind.css) + + {waitpointStatusTitle(status)} + ); } diff --git a/apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx b/apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx index a82d8b9d0c6..e696f202559 100644 --- a/apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx +++ b/apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx @@ -136,7 +136,9 @@ export function renderPart(part: UIMessage["parts"][number], i: number) { return (
-
{p.text ?? ""}
+
+ {p.text ?? ""} +
); diff --git a/apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx b/apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx index a5115897f97..ae46cbe867c 100644 --- a/apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx +++ b/apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx @@ -483,7 +483,7 @@ function SubAgentContent({ parts }: { parts: any[] }) { if (partType === "reasoning" && part.text) { return (
-
+
{part.text}
diff --git a/apps/webapp/app/components/runs/v3/ai/AIModelSummary.tsx b/apps/webapp/app/components/runs/v3/ai/AIModelSummary.tsx index 11ef7aa8a52..775efd7c308 100644 --- a/apps/webapp/app/components/runs/v3/ai/AIModelSummary.tsx +++ b/apps/webapp/app/components/runs/v3/ai/AIModelSummary.tsx @@ -128,7 +128,7 @@ function MetricRow({ bold?: boolean; }) { return ( -
+
{label} +
{label} {value}
diff --git a/apps/webapp/app/components/sessions/v1/SessionStatus.tsx b/apps/webapp/app/components/sessions/v1/SessionStatus.tsx index 5c917b0d30f..69dfdf5092d 100644 --- a/apps/webapp/app/components/sessions/v1/SessionStatus.tsx +++ b/apps/webapp/app/components/sessions/v1/SessionStatus.tsx @@ -74,7 +74,12 @@ export function SessionStatusIcon({ } export function SessionStatusLabel({ status }: { status: SessionStatus }) { - return {sessionStatusTitle(status)}; + // system-mono-label: System themes uncolor the label (see tailwind.css) + return ( + + {sessionStatusTitle(status)} + + ); } export function SessionStatusCombo({ diff --git a/apps/webapp/app/hooks/useSystemThemeSync.ts b/apps/webapp/app/hooks/useSystemThemeSync.ts new file mode 100644 index 00000000000..6b2a678396f --- /dev/null +++ b/apps/webapp/app/hooks/useSystemThemeSync.ts @@ -0,0 +1,29 @@ +import { useEffect } from "react"; +import { type ThemePreference } from "~/utils/themePreference"; + +/** + * Keeps `data-theme` on in sync with the preference. For `system` it + * follows the OS color scheme live; for pinned themes it writes the attribute + * explicitly - React can skip the write when its virtual DOM already matched + * the SSR fallback while the inline script had changed the real attribute. + * The single resolution rule (dark vs light) lives here and in the blocking + * inline script in root.tsx; downstream consumers react to the `data-theme` + * mutation (see useThemeColor). + */ +export function useSystemThemeSync(preference: ThemePreference) { + useEffect(() => { + if (preference !== "system") { + document.documentElement.setAttribute("data-theme", preference); + return; + } + + const media = window.matchMedia("(prefers-color-scheme: dark)"); + const apply = () => { + document.documentElement.setAttribute("data-theme", media.matches ? "dark" : "light"); + }; + + apply(); + media.addEventListener("change", apply); + return () => media.removeEventListener("change", apply); + }, [preference]); +} diff --git a/apps/webapp/app/hooks/useThemeColor.ts b/apps/webapp/app/hooks/useThemeColor.ts index d78fd39fa79..f088d16b525 100644 --- a/apps/webapp/app/hooks/useThemeColor.ts +++ b/apps/webapp/app/hooks/useThemeColor.ts @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; /** * Normalize any CSS color (hex, oklch, hsl, ...) to rgb()/rgba() by rendering @@ -17,16 +17,28 @@ function toRgb(color: string): string { } /** - * Resolve a theme CSS variable to a concrete, animatable color once on mount. + * Resolve a theme CSS variable to a concrete, animatable color. * framer-motion can't interpolate `var()` strings or oklch values, so animated - * colors must be resolved and normalized first. The fallback is used during - * SSR and should match the default dark theme (see tailwind.css). + * colors must be resolved and normalized first. Resolution happens in an + * effect so server and hydration renders both use the fallback — resolving + * during render caused hydration style mismatches. Long-lived components + * (e.g. the side menu) outlive theme switches, so re-resolve whenever + * `data-theme` flips on . */ export function useThemeColor(variable: `--${string}`, fallback: string): string { - const [color] = useState(() => { - if (typeof document === "undefined") return fallback; - const value = getComputedStyle(document.documentElement).getPropertyValue(variable).trim(); - return value ? toRgb(value) : fallback; - }); + const [color, setColor] = useState(fallback); + useEffect(() => { + const resolve = () => { + const value = getComputedStyle(document.documentElement).getPropertyValue(variable).trim(); + if (value) setColor(toRgb(value)); + }; + resolve(); + const observer = new MutationObserver(resolve); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["data-theme"], + }); + return () => observer.disconnect(); + }, [variable]); return color; } diff --git a/apps/webapp/app/models/organization.server.ts b/apps/webapp/app/models/organization.server.ts index a4b8a8ab5f5..9f7fb382315 100644 --- a/apps/webapp/app/models/organization.server.ts +++ b/apps/webapp/app/models/organization.server.ts @@ -6,6 +6,7 @@ import type { RuntimeEnvironment, User, } from "@trigger.dev/database"; +import { tryCatch } from "@trigger.dev/core/utils"; import { customAlphabet } from "nanoid"; import { generate } from "random-words"; import slug from "slug"; @@ -13,8 +14,14 @@ import { $replica, prisma, type PrismaClientOrTransaction } from "~/db.server"; import { env } from "~/env.server"; import { featuresForUrl } from "~/features.server"; import { createApiKeyForEnv, createPkApiKeyForEnv, envSlug } from "./api-key.server"; -import { getDefaultEnvironmentConcurrencyLimit } from "~/services/platform.v3.server"; +import { + getDefaultEnvironmentConcurrencyLimit, + isBillingConfigured, + setBillingAlert, +} from "~/services/platform.v3.server"; +import { buildDefaultBillingAlerts } from "~/services/billingAlertsDefaults.server"; import { enqueueAttioWorkspaceSync } from "~/services/attio.server"; +import { logger } from "~/services/logger.server"; import { applyBillingLimitPauseAfterEnvCreate, getInitialEnvPauseStateForBillingLimit, @@ -122,9 +129,39 @@ export async function createOrganization( adminUserId: userId, }); + // Awaited so the seed can't land after the user's first alert edit. + await seedDefaultBillingAlerts(organization.id); + return { ...organization }; } +// The platform client has no request timeout; don't let a slow billing backend stall org creation. +const SEED_ALERTS_TIMEOUT_MS = 5_000; + +/** Seed default billing alerts for a new org. Never fails org creation. */ +async function seedDefaultBillingAlerts(organizationId: string): Promise { + if (!isBillingConfigured()) { + return; + } + + let timer: NodeJS.Timeout | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("Timed out")), SEED_ALERTS_TIMEOUT_MS); + }); + + const [error] = await tryCatch( + Promise.race([setBillingAlert(organizationId, buildDefaultBillingAlerts()), timeout]).finally( + () => clearTimeout(timer) + ) + ); + if (error) { + logger.warn("Failed to seed default billing alerts for new org", { + organizationId, + error: error instanceof Error ? error.message : error, + }); + } +} + export async function createEnvironment({ organization, project, diff --git a/apps/webapp/app/root.tsx b/apps/webapp/app/root.tsx index 839f44b4bbc..219afc83541 100644 --- a/apps/webapp/app/root.tsx +++ b/apps/webapp/app/root.tsx @@ -1,4 +1,5 @@ import type { LinksFunction, LoaderFunctionArgs, MetaFunction } from "@remix-run/node"; +import type { CSSProperties } from "react"; import type { ShouldRevalidateFunction } from "@remix-run/react"; import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "@remix-run/react"; import { type UseDataFunctionReturn, typedjson, useTypedLoaderData } from "remix-typedjson"; @@ -19,7 +20,14 @@ import { TimezoneSetter } from "./components/TimezoneSetter"; import { env } from "./env.server"; import { featuresForRequest } from "./features.server"; import { usePostHog } from "./hooks/usePostHog"; +import { useSystemThemeSync } from "./hooks/useSystemThemeSync"; import { getUser } from "./services/session.server"; +import { + normalizeThemeContrast, + normalizeThemePreference, + type ThemePreference, +} from "~/utils/themePreference"; +import { flag } from "~/v3/featureFlags.server"; import { getTimezonePreference } from "./services/preferences/uiPreferences.server"; import { appEnvTitleTag } from "./utils"; @@ -70,6 +78,18 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { }; const user = await getUser(request); + // Theme switching is feature-flagged; while off, everyone stays on the + // classic theme even if a preference was saved earlier. + const showThemeSwitcher = user + ? await flag({ key: "hasThemeSwitcher", defaultValue: true }) + : false; + // Logged-out pages (login, invites) always render the branded Classic look. + const themePreference: ThemePreference = showThemeSwitcher + ? normalizeThemePreference(user?.dashboardPreferences.theme) + : "classic"; + const themeContrast = showThemeSwitcher + ? normalizeThemeContrast(user?.dashboardPreferences.contrast) + : 0; const headers = new Headers(); headers.append("Set-Cookie", await commitSession(session)); @@ -87,6 +107,12 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { triggerCliTag: env.TRIGGER_CLI_TAG, kapa, timezone, + showThemeSwitcher, + themePreference, + themeContrast, + // Consumed by ResizablePanel: the browser check must match between SSR + // and hydration, so it is derived from the request user-agent. + isFirefox: /firefox/i.test(request.headers.get("user-agent") ?? ""), }, { headers } ); @@ -105,7 +131,7 @@ export const shouldRevalidate: ShouldRevalidateFunction = (options) => { export function ErrorBoundary() { return ( <> - + @@ -129,13 +155,35 @@ export function ErrorBoundary() { } export default function App() { - const { posthogProjectKey, posthogUiHost, kapa: _kapa } = useTypedLoaderData(); + const { + posthogProjectKey, + posthogUiHost, + kapa: _kapa, + themePreference, + themeContrast, + } = useTypedLoaderData(); usePostHog(posthogProjectKey, posthogUiHost); + useSystemThemeSync(themePreference); + // SSR falls back to dark for `system`; the inline script below corrects it + // before paint, and useSystemThemeSync keeps it live afterwards. + const resolvedTheme = themePreference === "system" ? "dark" : themePreference; return ( <> - + +