diff --git a/.changeset/target-cli-notifications.md b/.changeset/target-cli-notifications.md new file mode 100644 index 00000000000..bdb6f7240c0 --- /dev/null +++ b/.changeset/target-cli-notifications.md @@ -0,0 +1,5 @@ +--- +"trigger.dev": patch +--- + +Send the running CLI version when checking for platform notifications so notices can be limited to compatible CLI releases. diff --git a/.github/workflows/check-review-md.yml b/.github/workflows/check-review-md.yml index 06f0d2d82c4..79b9d7eacd5 100644 --- a/.github/workflows/check-review-md.yml +++ b/.github/workflows/check-review-md.yml @@ -38,7 +38,7 @@ jobs: with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} use_sticky_comment: true - allowed_bots: "devin-ai-integration[bot]" + allowed_bots: "devin-ai-integration[bot],claude[bot]" claude_args: | --max-turns 30 diff --git a/.github/workflows/claude-md-audit.yml b/.github/workflows/claude-md-audit.yml index aa62444c9df..d1b1bbdb45a 100644 --- a/.github/workflows/claude-md-audit.yml +++ b/.github/workflows/claude-md-audit.yml @@ -40,7 +40,7 @@ jobs: with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} use_sticky_comment: true - allowed_bots: "devin-ai-integration[bot]" + allowed_bots: "devin-ai-integration[bot],claude[bot]" claude_args: | --max-turns 25 diff --git a/.github/workflows/dependabot-critical-alerts.yml b/.github/workflows/dependabot-critical-alerts.yml deleted file mode 100644 index 62d68c30023..00000000000 --- a/.github/workflows/dependabot-critical-alerts.yml +++ /dev/null @@ -1,87 +0,0 @@ -name: Dependabot Critical Alerts - -on: - schedule: - - cron: "0 8 * * *" # Daily 08:00 UTC - workflow_dispatch: - inputs: - severity: - description: "Severity to alert on" - type: choice - options: - - critical - - high - - medium - - low - default: critical - -concurrency: - group: ${{ github.workflow }} - cancel-in-progress: false - -permissions: - contents: read - -jobs: - alert: - name: Post critical alerts - # Set the ENABLE_DEPENDABOT_ALERTS repository variable to 'false' to turn off - # the Dependabot alert/summary notifiers — e.g. forks/mirrors that lack the - # DEPENDABOT_ALERTS_TOKEN / SLACK_BOT_TOKEN secrets. Defaults to enabled. - if: ${{ vars.ENABLE_DEPENDABOT_ALERTS != 'false' }} - runs-on: warp-ubuntu-latest-x64-2x - environment: dependabot-summary - env: - SEVERITY: ${{ inputs.severity || 'critical' }} - steps: - - name: Fetch alerts - id: alerts - env: - GH_TOKEN: ${{ secrets.DEPENDABOT_ALERTS_TOKEN }} - REPO: ${{ github.repository }} - run: | - set -euo pipefail - gh api -X GET "/repos/$REPO/dependabot/alerts" \ - -F state=open -F severity="$SEVERITY" --paginate > pages.json - jq -s 'add' pages.json > alerts.json - TOTAL=$(jq 'length' alerts.json) - echo "total=$TOTAL" >> "$GITHUB_OUTPUT" - if [ "$TOTAL" = "0" ]; then - exit 0 - fi - LIST=$(jq -r ' - map("• <\(.html_url)|#\(.number)> *\(.dependency.package.name)* - \(.security_advisory.summary)") - | join("\n") - ' alerts.json) - { - echo "list<> "$GITHUB_OUTPUT" - - - name: Build Slack payload - if: steps.alerts.outputs.total != '0' - env: - REPO: ${{ github.repository }} - CHANNEL: ${{ vars.SLACK_CHANNEL_ID }} - TOTAL: ${{ steps.alerts.outputs.total }} - LIST: ${{ steps.alerts.outputs.list }} - run: | - jq -n \ - --arg channel "$CHANNEL" \ - --arg repo "$REPO" \ - --arg total "$TOTAL" \ - --arg list "$LIST" \ - --arg severity "$SEVERITY" \ - '{ - channel: $channel, - text: ":bufo-alarma: `\($repo)` - *\($total) open \($severity) alert(s)*\n\($list)\n\n" - }' > payload.json - - - name: Post Slack alert - if: steps.alerts.outputs.total != '0' - uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3 - with: - method: chat.postMessage - token: ${{ secrets.SLACK_BOT_TOKEN }} - payload-file-path: payload.json diff --git a/.github/workflows/dependabot-weekly-summary.yml b/.github/workflows/dependabot-weekly-summary.yml deleted file mode 100644 index fc9eaebcb94..00000000000 --- a/.github/workflows/dependabot-weekly-summary.yml +++ /dev/null @@ -1,210 +0,0 @@ -name: Dependabot Weekly Summary - -on: - schedule: - - cron: "0 8 * * 1" # Mon 08:00 UTC - workflow_dispatch: - -# Single-purpose monitoring workflow; serialise on workflow name only - we never -# want two concurrent summary runs racing to post the same digest. -concurrency: - group: ${{ github.workflow }} - cancel-in-progress: false - -permissions: - contents: read # gh CLI baseline - pull-requests: read # gh pr list (open dependabot PRs) - actions: read # gh run list / view (parse latest dependabot run logs) - -jobs: - summary: - name: Post weekly Dependabot summary - # Set the ENABLE_DEPENDABOT_ALERTS repository variable to 'false' to turn off - # the Dependabot alert/summary notifiers — e.g. forks/mirrors that lack the - # DEPENDABOT_ALERTS_TOKEN / SLACK_BOT_TOKEN secrets. Defaults to enabled. - if: ${{ vars.ENABLE_DEPENDABOT_ALERTS != 'false' }} - runs-on: warp-ubuntu-latest-x64-2x - environment: dependabot-summary - env: - # Severities surface in the actions list when their remaining TTR drops - # below this many days. Override via repo/env var ACTION_THRESHOLD_DAYS. - THRESHOLD_DAYS: ${{ vars.ACTION_THRESHOLD_DAYS || '7' }} - steps: - - name: Fetch alerts and compute summaries - id: alerts - env: - GH_TOKEN: ${{ secrets.DEPENDABOT_ALERTS_TOKEN }} - REPO: ${{ github.repository }} - run: | - if ! gh api -X GET "/repos/$REPO/dependabot/alerts" --paginate > pages.json 2> err.txt; then - echo "total=?" >> "$GITHUB_OUTPUT" - ERR=$(head -c 200 err.txt | tr '\n' ' ') - echo "by_severity=:x: _failed to fetch alerts: ${ERR}_" >> "$GITHUB_OUTPUT" - echo "actions=:x: _alerts unavailable_" >> "$GITHUB_OUTPUT" - exit 0 - fi - jq -s '[.[][] | select(.state == "open")]' pages.json > open.json - - TOTAL=$(jq 'length' open.json) - echo "total=$TOTAL" >> "$GITHUB_OUTPUT" - - if [ "$TOTAL" = "0" ]; then - echo "by_severity=:white_check_mark: No open alerts." >> "$GITHUB_OUTPUT" - echo "actions=_None_" >> "$GITHUB_OUTPUT" - exit 0 - fi - - # Severity breakdown - real newlines so jq --arg in the payload - # builder encodes them as proper \n in JSON (Slack renders as breaks). - BY_SEV=$(jq -r ' - group_by(.security_advisory.severity) - | map({sev: .[0].security_advisory.severity, - count: length, - weight: ({"critical":0,"high":1,"medium":2,"low":3}[.[0].security_advisory.severity])}) - | sort_by(.weight) - | map("• *\(.count)* \(.sev)") - | join("\n") - ' open.json) - { - echo "by_severity<> "$GITHUB_OUTPUT" - - # Actions: alerts within THRESHOLD_DAYS of their TTR (P0=7d, P1=30d, P2=90d, P3=no deadline) - # Grouped by (package, severity); shows earliest deadline per group. - ACTIONS=$(jq -r --argjson threshold "$THRESHOLD_DAYS" ' - [.[] - | (.security_advisory.severity) as $sev - | ({"critical":7,"high":30,"medium":90,"low":null}[$sev]) as $ttr - | select($ttr != null) - | ((now - (.created_at | fromdateiso8601)) / 86400 | floor) as $age - | {pkg: .dependency.package.name, sev: $sev, remaining: ($ttr - $age)} - ] - | group_by([.pkg, .sev]) - | map({pkg: .[0].pkg, sev: .[0].sev, count: length, min_remaining: ([.[].remaining] | min)}) - | map(select(.min_remaining < $threshold)) - | sort_by(.min_remaining) - | if length == 0 then "_None_" - else (map( - "• *\(.pkg)* (\(.sev))" + - (if .count > 1 then " ×\(.count)" else "" end) + " - " + - (if .min_remaining < 0 then "*OVERDUE* by \(-.min_remaining)d" - else "\(.min_remaining)d remaining" end) - ) | join("\n")) - end - ' open.json) - { - echo "actions<> "$GITHUB_OUTPUT" - - - name: Fetch open dependabot PRs - id: prs - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - REPO: ${{ github.repository }} - REPO_URL: https://github.com/${{ github.repository }} - run: | - if ! PR_JSON=$(gh pr list --repo "$REPO" --state open --author "app/dependabot" --json number,title 2> err.txt); then - ERR=$(head -c 200 err.txt | tr '\n' ' ') - echo "list=:x: _failed to fetch PRs: ${ERR}_" >> "$GITHUB_OUTPUT" - exit 0 - fi - LIST=$(echo "$PR_JSON" | jq -r --arg url "$REPO_URL" ' - if length == 0 then "_None_" - else (map("• <\($url)/pull/\(.number)|#\(.number)> \(.title)") | join("\n")) - end - ') - { - echo "list<> "$GITHUB_OUTPUT" - - - name: Find latest npm dependabot run - id: latest - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - REPO: ${{ github.repository }} - run: | - # Repos without a dependabot.yml have no "Dependabot Updates" workflow; - # treat the lookup failure as "no recent run found" rather than failing. - if ! RUN_ID=$(gh run list --repo "$REPO" --workflow "Dependabot Updates" --status success --limit 30 --json databaseId,name --jq 'first(.[] | select(.name | startswith("npm_and_yarn")) | .databaseId) // empty' 2>/dev/null); then - RUN_ID="" - fi - echo "run_id=$RUN_ID" >> "$GITHUB_OUTPUT" - - - name: Extract stuck deps (only if actions pending) - id: stuck - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - REPO: ${{ github.repository }} - RUN_ID: ${{ steps.latest.outputs.run_id }} - ACTIONS: ${{ steps.alerts.outputs.actions }} - run: | - # Skip the stuck section entirely when nothing in the actions list - # - keeps the digest tidy when there's nothing to actually act on. - if [ "$ACTIONS" = "_None_" ]; then - echo "section=" >> "$GITHUB_OUTPUT" - exit 0 - fi - HEADER=$'\n\n*Couldn\'t auto-fix (need manual `pnpm.overrides`):*\n' - if [ -z "$RUN_ID" ]; then - { - echo "section<> "$GITHUB_OUTPUT" - exit 0 - fi - gh run view "$RUN_ID" --repo "$REPO" --log > log.txt 2>&1 || true - STUCK=$(grep -oE "No update possible for [^[:space:]]+ [0-9][^[:space:]]*" log.txt | sed 's/No update possible for //' | sort -u || true) - if [ -z "$STUCK" ]; then - { - echo "section<> "$GITHUB_OUTPUT" - exit 0 - fi - LIST=$(echo "$STUCK" | awk 'NR>1{printf "\n"} {printf "• *%s* %s", $1, $2}') - { - echo "section<> "$GITHUB_OUTPUT" - - - name: Build Slack payload - env: - REPO: ${{ github.repository }} - CHANNEL: ${{ vars.SLACK_CHANNEL_ID }} - TOTAL: ${{ steps.alerts.outputs.total }} - BY_SEVERITY: ${{ steps.alerts.outputs.by_severity }} - PRS_LIST: ${{ steps.prs.outputs.list }} - ACTIONS: ${{ steps.alerts.outputs.actions }} - STUCK: ${{ steps.stuck.outputs.section }} - run: | - # Build payload via jq so PR titles or error strings containing - # quotes/backslashes/newlines can't break the JSON. - jq -n \ - --arg channel "$CHANNEL" \ - --arg repo "$REPO" \ - --arg total "$TOTAL" \ - --arg by_severity "$BY_SEVERITY" \ - --arg prs_list "$PRS_LIST" \ - --arg actions "$ACTIONS" \ - --arg stuck "$STUCK" \ - --arg threshold "$THRESHOLD_DAYS" \ - '{ - channel: $channel, - text: ":calendar: *Weekly Dependabot summary* - `\($repo)`\n\n*Open alerts (\($total)):*\n\($by_severity)\n\n*Open Dependabot PRs:*\n\($prs_list)\n\n*Actions needed (<\($threshold)d remaining):*\n\($actions)\($stuck)\n\n" - }' > payload.json - - - name: Post Slack summary - uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3 - with: - method: chat.postMessage - token: ${{ secrets.SLACK_BOT_TOKEN }} - payload-file-path: payload.json diff --git a/.gitignore b/.gitignore index 1ee5643d57d..7d9dc169042 100644 --- a/.gitignore +++ b/.gitignore @@ -79,3 +79,6 @@ ailogger-output.log # local git worktree checkouts (not source) — keeps oxfmt/oxlint from descending into them .worktrees/ + +# local planning/design docs, not committed +**/docs/superpowers/ diff --git a/.server-changes/default-billing-alerts.md b/.server-changes/default-billing-alerts.md deleted file mode 100644 index e828c506050..00000000000 --- a/.server-changes/default-billing-alerts.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -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/internal-api-origin-flag.md b/.server-changes/internal-api-origin-flag.md new file mode 100644 index 00000000000..48b26ba8536 --- /dev/null +++ b/.server-changes/internal-api-origin-flag.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +Self-hosted instances can now serve deployed runs' API traffic from a different origin than the public one, per organization, via the `INTERNAL_API_ORIGIN` environment variable and a feature flag. diff --git a/.server-changes/realtime-emission-fanout-metrics.md b/.server-changes/realtime-emission-fanout-metrics.md deleted file mode 100644 index bd9ef83afbc..00000000000 --- a/.server-changes/realtime-emission-fanout-metrics.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -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/remove-unused-trace-sync-route.md b/.server-changes/remove-unused-trace-sync-route.md new file mode 100644 index 00000000000..b2e857405a0 --- /dev/null +++ b/.server-changes/remove-unused-trace-sync-route.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Removed an unused internal dashboard sync route that is no longer part of any product surface. diff --git a/apps/supervisor/src/env.ts b/apps/supervisor/src/env.ts index 6c954b4e76a..2d96cb1e407 100644 --- a/apps/supervisor/src/env.ts +++ b/apps/supervisor/src/env.ts @@ -112,6 +112,7 @@ export const Env = z // Optional services TRIGGER_WARM_START_URL: z.string().optional(), + TRIGGER_WARM_START_DISPATCH_URL: z.string().optional(), TRIGGER_CHECKPOINT_URL: z.string().optional(), TRIGGER_METADATA_URL: z.string().optional(), diff --git a/apps/supervisor/src/index.ts b/apps/supervisor/src/index.ts index cf73be90eea..39b9aa47e0a 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; @@ -80,6 +95,8 @@ class ManagedSupervisor { private readonly isKubernetes = isKubernetesEnvironment(env.KUBERNETES_FORCE_ENABLED); private readonly warmStartUrl = env.TRIGGER_WARM_START_URL; + private readonly warmStartDispatchUrl = + env.TRIGGER_WARM_START_DISPATCH_URL ?? env.TRIGGER_WARM_START_URL; private readonly wideEventOpts: WideEventOptions = { service: "supervisor", @@ -322,6 +339,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()); @@ -679,7 +700,7 @@ class ManagedSupervisor { return false; } - const warmStartUrlWithPath = new URL("/warm-start", this.warmStartUrl); + const warmStartUrlWithPath = new URL("/warm-start", this.warmStartDispatchUrl); const headers: Record = { "Content-Type": "application/json", @@ -692,6 +713,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 +733,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 +745,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 +753,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/webapp/app/assets/icons/CrossIcon.tsx b/apps/webapp/app/assets/icons/CrossIcon.tsx new file mode 100644 index 00000000000..fa0c714602f --- /dev/null +++ b/apps/webapp/app/assets/icons/CrossIcon.tsx @@ -0,0 +1,14 @@ +export function CrossIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/apps/webapp/app/assets/icons/EyeClosedIcon.tsx b/apps/webapp/app/assets/icons/EyeClosedIcon.tsx new file mode 100644 index 00000000000..c83d2499267 --- /dev/null +++ b/apps/webapp/app/assets/icons/EyeClosedIcon.tsx @@ -0,0 +1,35 @@ +export function EyeClosedIcon({ className }: { className?: string }) { + return ( + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/EyeOpenIcon.tsx b/apps/webapp/app/assets/icons/EyeOpenIcon.tsx new file mode 100644 index 00000000000..e00cfa0fcf5 --- /dev/null +++ b/apps/webapp/app/assets/icons/EyeOpenIcon.tsx @@ -0,0 +1,27 @@ +export function EyeOpenIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/PadlockRoundedIcon.tsx b/apps/webapp/app/assets/icons/PadlockRoundedIcon.tsx new file mode 100644 index 00000000000..56f2543fc3f --- /dev/null +++ b/apps/webapp/app/assets/icons/PadlockRoundedIcon.tsx @@ -0,0 +1,34 @@ +export function PadlockRoundedIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/RenameIcon.tsx b/apps/webapp/app/assets/icons/RenameIcon.tsx new file mode 100644 index 00000000000..6514e0b09cd --- /dev/null +++ b/apps/webapp/app/assets/icons/RenameIcon.tsx @@ -0,0 +1,27 @@ +export function RenameIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/SidebarCustomizeIcon.tsx b/apps/webapp/app/assets/icons/SidebarCustomizeIcon.tsx new file mode 100644 index 00000000000..0960e4a2011 --- /dev/null +++ b/apps/webapp/app/assets/icons/SidebarCustomizeIcon.tsx @@ -0,0 +1,26 @@ +export function SidebarCustomizeIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/components/BlankStatePanels.tsx b/apps/webapp/app/components/BlankStatePanels.tsx index 5d166158e0b..5fde85cc050 100644 --- a/apps/webapp/app/components/BlankStatePanels.tsx +++ b/apps/webapp/app/components/BlankStatePanels.tsx @@ -701,14 +701,12 @@ function DeploymentOnboardingSteps() { Deploy automatically with every push. Read the{" "} full guide. -
- -
+ diff --git a/apps/webapp/app/components/Shortcuts.tsx b/apps/webapp/app/components/Shortcuts.tsx index 87e5d08f371..c4ce2db6d0f 100644 --- a/apps/webapp/app/components/Shortcuts.tsx +++ b/apps/webapp/app/components/Shortcuts.tsx @@ -73,6 +73,10 @@ function ShortcutContent() { + + + + diff --git a/apps/webapp/app/components/integrations/VercelBuildSettings.tsx b/apps/webapp/app/components/integrations/VercelBuildSettings.tsx index 1abd54b0121..fc45d2f6019 100644 --- a/apps/webapp/app/components/integrations/VercelBuildSettings.tsx +++ b/apps/webapp/app/components/integrations/VercelBuildSettings.tsx @@ -1,5 +1,12 @@ import { Switch } from "~/components/primitives/Switch"; +import { LinkButton } from "~/components/primitives/Buttons"; import { Label } from "~/components/primitives/Label"; +import { + SettingsRow, + SettingsRowDescription, + SettingsRowTitle, +} from "~/components/primitives/SettingsLayout"; +import { cn } from "~/utils/cn"; import { Hint } from "~/components/primitives/Hint"; import { TextLink } from "~/components/primitives/TextLink"; import { SimpleTooltip } from "~/components/primitives/Tooltip"; @@ -31,6 +38,7 @@ type BuildSettingsFieldsProps = { currentTriggerVersionFetchFailed?: boolean; /** Hide the section-level master toggles for "Pull env vars" and "Discover new env vars". */ hideSectionToggles?: boolean; + layout?: "settings" | "card"; }; export function BuildSettingsFields({ @@ -48,187 +56,326 @@ export function BuildSettingsFields({ currentTriggerVersion, currentTriggerVersionFetchFailed, hideSectionToggles, + layout = "card", }: BuildSettingsFieldsProps) { const isSlugDisabled = (slug: EnvSlug) => !!disabledEnvSlugs?.[slug]; const enabledSlugs = availableEnvSlugs.filter((s) => !isSlugDisabled(s)); + const envVarSections = + layout === "settings" ? ( + <> + + Configure env vars + + ) : undefined + } + /> + {availableEnvSlugs.map((slug) => ( + { + onPullEnvVarsChange( + checked + ? [...pullEnvVarsBeforeBuild, slug] + : pullEnvVarsBeforeBuild.filter((s) => s !== slug) + ); + }} + /> + ))} + + + {availableEnvSlugs.map((slug) => { + const pullOff = !pullEnvVarsBeforeBuild.includes(slug); + return ( + { + onDiscoverEnvVarsChange( + checked ? [...discoverEnvVars, slug] : discoverEnvVars.filter((s) => s !== slug) + ); + }} + /> + ); + })} + + ) : null; + + const atomicSections = + layout === "settings" ? ( + <> + { + onAtomicBuildsChange(checked ? ["prod"] : []); + }} + /> + } + > +
+ Atomic deployments + + Promotes your Vercel deployment and your tasks together in Production, so your app + never runs against a mismatched task version. Requires turning off "Auto-assign Custom + Production Domains" on your Vercel project, which Trigger.dev does for you.{" "} + + Learn more + + . + + {currentTriggerVersion && ( + + Currently pinned to{" "} + {currentTriggerVersion} in + Vercel production. + + )} + {!currentTriggerVersion && currentTriggerVersionFetchFailed && ( + + Couldn't read TRIGGER_VERSION{" "} + from Vercel. Check the Vercel dashboard to confirm the production pin. + + )} +
+
+ + {atomicBuilds.includes("prod") && onAutoPromoteChange !== undefined && ( + + } + /> + )} + + ) : null; + return ( <> + {envVarSections} {/* Pull env vars before build */} -
-
-
- - {!hideSectionToggles && availableEnvSlugs.length > 1 && ( - 0 && - enabledSlugs.every((s) => pullEnvVarsBeforeBuild.includes(s)) - } - onCheckedChange={(checked) => { - onPullEnvVarsChange(checked ? [...enabledSlugs] : []); - }} - /> - )} -
- - Select which environments should pull environment variables from Vercel before each - build.{" "} - {envVarsConfigLink && ( - <> - Configure which variables to pull. - - )} - -
-
- {availableEnvSlugs.map((slug) => { - const envType = envSlugToType(slug); - const disabled = isSlugDisabled(slug); - const disabledReason = disabledEnvSlugs?.[slug]; - const row = ( -
-
- - - {environmentFullTitle({ type: envType })} - -
+ {layout === "card" && ( +
+
+
+ + {!hideSectionToggles && availableEnvSlugs.length > 1 && ( 0 && + enabledSlugs.every((s) => pullEnvVarsBeforeBuild.includes(s)) + } onCheckedChange={(checked) => { - onPullEnvVarsChange( - checked - ? [...pullEnvVarsBeforeBuild, slug] - : pullEnvVarsBeforeBuild.filter((s) => s !== slug) - ); + onPullEnvVarsChange(checked ? [...enabledSlugs] : []); }} /> -
- ); - if (disabled && disabledReason) { - return ; - } - return row; - })} + )} +
+ + Select which environments should pull environment variables from Vercel before each + build.{" "} + {envVarsConfigLink && ( + <> + Configure which variables to pull. + + )} + +
+
+ {availableEnvSlugs.map((slug) => { + const envType = envSlugToType(slug); + const disabled = isSlugDisabled(slug); + const disabledReason = disabledEnvSlugs?.[slug]; + const row = ( +
+
+ + + {environmentFullTitle({ type: envType })} + +
+ { + onPullEnvVarsChange( + checked + ? [...pullEnvVarsBeforeBuild, slug] + : pullEnvVarsBeforeBuild.filter((s) => s !== slug) + ); + }} + /> +
+ ); + if (disabled && disabledReason) { + return ( + + ); + } + return row; + })} +
-
+ )} {/* Discover new env vars */} -
-
-
- - {!hideSectionToggles && availableEnvSlugs.length > 1 && ( - 0 && - enabledSlugs.every( - (s) => discoverEnvVars.includes(s) || !pullEnvVarsBeforeBuild.includes(s) - ) && - enabledSlugs.some((s) => discoverEnvVars.includes(s)) - } - disabled={!enabledSlugs.some((s) => pullEnvVarsBeforeBuild.includes(s))} - onCheckedChange={(checked) => { - onDiscoverEnvVarsChange( - checked ? enabledSlugs.filter((s) => pullEnvVarsBeforeBuild.includes(s)) : [] - ); - }} - /> - )} -
- - Select which environments should automatically discover and create new environment - variables from Vercel during builds. - -
-
- {availableEnvSlugs.map((slug) => { - const envType = envSlugToType(slug); - const disabled = isSlugDisabled(slug); - const disabledReason = disabledEnvSlugs?.[slug]; - const isPullDisabled = !pullEnvVarsBeforeBuild.includes(slug); - const row = ( -
-
- - - {environmentFullTitle({ type: envType })} - -
+ {layout === "card" && ( +
+
+
+ + {!hideSectionToggles && availableEnvSlugs.length > 1 && ( 0 && + enabledSlugs.every( + (s) => discoverEnvVars.includes(s) || !pullEnvVarsBeforeBuild.includes(s) + ) && + enabledSlugs.some((s) => discoverEnvVars.includes(s)) + } + disabled={!enabledSlugs.some((s) => pullEnvVarsBeforeBuild.includes(s))} onCheckedChange={(checked) => { onDiscoverEnvVarsChange( - checked - ? [...discoverEnvVars, slug] - : discoverEnvVars.filter((s) => s !== slug) + checked ? enabledSlugs.filter((s) => pullEnvVarsBeforeBuild.includes(s)) : [] ); }} /> -
- ); - if (disabled && disabledReason) { - return ; - } - return row; - })} + )} +
+ + Select which environments should automatically discover and create new environment + variables from Vercel during builds. + +
+
+ {availableEnvSlugs.map((slug) => { + const envType = envSlugToType(slug); + const disabled = isSlugDisabled(slug); + const disabledReason = disabledEnvSlugs?.[slug]; + const isPullDisabled = !pullEnvVarsBeforeBuild.includes(slug); + const row = ( +
+
+ + + {environmentFullTitle({ type: envType })} + +
+ { + onDiscoverEnvVarsChange( + checked + ? [...discoverEnvVars, slug] + : discoverEnvVars.filter((s) => s !== slug) + ); + }} + /> +
+ ); + if (disabled && disabledReason) { + return ( + + ); + } + return row; + })} +
-
+ )} + + {atomicSections} {/* Atomic deployments */} -
-
- - { - onAtomicBuildsChange(checked ? ["prod"] : []); - }} - /> -
- - When enabled, production deployments wait for Vercel deployment to complete before - promoting the Trigger.dev deployment. This will disable the "Auto-assign Custom Production - Domains" option in your Vercel project settings to perform staged deployments.{" "} - - Learn more - - . - - {currentTriggerVersion && ( + {layout === "card" && ( +
+
+ + { + onAtomicBuildsChange(checked ? ["prod"] : []); + }} + /> +
- Currently pinned to{" "} - {currentTriggerVersion} in Vercel - production. + When enabled, production deployments wait for Vercel deployment to complete before + promoting the Trigger.dev deployment. This will disable the "Auto-assign Custom + Production Domains" option in your Vercel project settings to perform staged + deployments.{" "} + + Learn more + + . - )} - {!currentTriggerVersion && currentTriggerVersionFetchFailed && ( - - Couldn't read TRIGGER_VERSION from - Vercel — check the Vercel dashboard to confirm the production pin. - - )} -
+ {currentTriggerVersion && ( + + Currently pinned to{" "} + {currentTriggerVersion} in Vercel + production. + + )} + {!currentTriggerVersion && currentTriggerVersionFetchFailed && ( + + Couldn't read TRIGGER_VERSION from + Vercel — check the Vercel dashboard to confirm the production pin. + + )} +
+ )} {/* Auto promotion — only visible when atomic deployments are on */} - {atomicBuilds.includes("prod") && onAutoPromoteChange !== undefined && ( + {layout === "card" && atomicBuilds.includes("prod") && onAutoPromoteChange !== undefined && (
@@ -248,3 +395,59 @@ export function BuildSettingsFields({ ); } + +function EnvToggleRow({ + slug, + checked, + disabled, + disabledReason, + unlockHint, + unlockTarget, + onCheckedChange, +}: { + slug: EnvSlug; + checked: boolean; + disabled: boolean; + disabledReason?: string; + unlockHint?: string; + unlockTarget?: string; + onCheckedChange: (checked: boolean) => void; +}) { + const envType = envSlugToType(slug); + + return ( + + + + } + > +
+
+ + + {environmentFullTitle({ type: envType })} + +
+ {disabled && disabledReason ? ( + {disabledReason} + ) : null} +
+
+ ); +} diff --git a/apps/webapp/app/components/integrations/VercelOnboardingModal.tsx b/apps/webapp/app/components/integrations/VercelOnboardingModal.tsx index 85c7816b73e..cd5f9eee505 100644 --- a/apps/webapp/app/components/integrations/VercelOnboardingModal.tsx +++ b/apps/webapp/app/components/integrations/VercelOnboardingModal.tsx @@ -13,6 +13,7 @@ import { FormButtons } from "~/components/primitives/FormButtons"; import { FormError } from "~/components/primitives/FormError"; import { Header3 } from "~/components/primitives/Headers"; import { Hint } from "~/components/primitives/Hint"; +import { InputGroup } from "~/components/primitives/InputGroup"; import { Label } from "~/components/primitives/Label"; import { Paragraph } from "~/components/primitives/Paragraph"; import { Select, SelectItem } from "~/components/primitives/Select"; @@ -797,10 +798,8 @@ export function VercelOnboardingModal({
{showProjectSelection && (
- Select Vercel Project - - Choose which Vercel project to connect with this Trigger.dev project. Your API keys - will be automatically synced to Vercel. + + Choose the Vercel project to pair with this Trigger.dev project. {availableProjects.length === 0 ? ( @@ -808,41 +807,42 @@ export function VercelOnboardingModal({ No Vercel projects found. Please create a project in Vercel first. ) : ( - + + + + Your{" "} + + TRIGGER_SECRET_KEY + {" "} + is synced to Vercel for each environment once connected. + + )} {projectSelectionError && {projectSelectionError}} - - Once connected, your{" "} - - TRIGGER_SECRET_KEY - {" "} - will be automatically synced to Vercel for each environment. - - @@ -866,7 +866,7 @@ export function VercelOnboardingModal({
} cancelButton={ - } diff --git a/apps/webapp/app/components/navigation/CustomizeSidebarDialog.tsx b/apps/webapp/app/components/navigation/CustomizeSidebarDialog.tsx new file mode 100644 index 00000000000..d2c18442e3b --- /dev/null +++ b/apps/webapp/app/components/navigation/CustomizeSidebarDialog.tsx @@ -0,0 +1,506 @@ +import { DialogClose } from "@radix-ui/react-dialog"; +import { ArrowDownIcon, ArrowUpIcon } from "@heroicons/react/20/solid"; +import { GripVerticalIcon } from "lucide-react"; +import { useState } from "react"; +import ReactGridLayout, { type Layout, useContainerWidth } from "react-grid-layout"; +import { CrossIcon } from "~/assets/icons/CrossIcon"; +import { EyeClosedIcon } from "~/assets/icons/EyeClosedIcon"; +import { EyeOpenIcon } from "~/assets/icons/EyeOpenIcon"; +import { cn } from "~/utils/cn"; +import { Button } from "../primitives/Buttons"; +import { DialogContent, DialogFooter, DialogHeader } from "../primitives/Dialog"; +import { FormError } from "../primitives/FormError"; +import { Header3 } from "../primitives/Headers"; +import { Icon, type RenderIcon } from "../primitives/Icon"; +import { Input } from "../primitives/Input"; +import { isItemHidden, orderByPreference } from "./sideMenuTypes"; + +export type CustomizeSidebarItem = { + id: string; + name: string; + icon: RenderIcon; + iconClassName?: string; + defaultHidden?: boolean; + /** Favorites get an inline-editable name in the modal. */ + isFavorite?: boolean; +}; + +export type CustomizeSidebarSection = { + id: string; + title: string; + /** Items in DEFAULT order (favorites: saved order — that is their default). */ + items: CustomizeSidebarItem[]; +}; + +type SavedPreferences = { + sectionOrder?: string[]; + hiddenItems?: Record; + sectionItemOrder?: Record; +}; + +/** What Confirm produces; null clears a stored preference back to its default. */ +export type SidebarCustomizationPayload = { + sectionOrder: string[] | null; + hiddenItems: Record | null; + sectionItemOrder: Record | null; + favorites?: Array<{ id: string; label: string }>; + removedFavoriteIds?: string[]; +}; + +type DialogState = { + sectionOrder: string[]; + /** section id -> item ids in order */ + itemOrders: Record; + /** item id -> effective hidden */ + hidden: Record; + /** favorite id -> label being edited */ + labels: Record; + /** favorite ids staged for removal; applied on Confirm */ + removed: string[]; +}; + +const FAVORITES_SECTION_ID = "favorites"; +const ROW_HEIGHT = 44; + +function buildState( + sections: CustomizeSidebarSection[], + prefs: SavedPreferences | undefined +): DialogState { + const orderedSections = prefs ? orderByPreference(sections, prefs.sectionOrder) : sections; + + const itemOrders: Record = {}; + const hidden: Record = {}; + const labels: Record = {}; + + for (const section of sections) { + // Favorites' array order is canonical (already applied), so saved item order only applies to + // the static sections. + const orderedItems = + prefs && section.id !== FAVORITES_SECTION_ID + ? orderByPreference(section.items, prefs.sectionItemOrder?.[section.id]) + : section.items; + itemOrders[section.id] = orderedItems.map((item) => item.id); + + for (const item of section.items) { + hidden[item.id] = prefs + ? isItemHidden(item, prefs.hiddenItems) + : (item.defaultHidden ?? false); + if (item.isFavorite) { + labels[item.id] = item.name; + } + } + } + + return { + sectionOrder: orderedSections.map((section) => section.id), + itemOrders, + hidden, + labels, + removed: [], + }; +} + +function arraysEqual(a: string[], b: string[]) { + return a.length === b.length && a.every((value, index) => value === b[index]); +} + +/** + * The "Customize sidebar" modal: reorder sections (arrows), reorder items (drag), hide/show items + * (eye), and rename favorites inline. Nothing is applied until Confirm; Reset restores the default + * layout without touching which pages are favorited. + */ +export function CustomizeSidebarDialog({ + sections, + prefs, + onConfirm, + isConfirming, + confirmError, +}: { + sections: CustomizeSidebarSection[]; + prefs: SavedPreferences | undefined; + /** + * Owned by the parent: closing this dialog unmounts it, so it can't run its own fetcher. The + * parent submits the payload and closes the dialog once the save lands (or reports back via + * `confirmError`), so a failed save never silently reads as a successful one. + */ + onConfirm: (payload: SidebarCustomizationPayload) => void; + /** True from Confirm until the save (and the refreshed side menu data) lands. */ + isConfirming: boolean; + /** Save failure to surface next to Confirm; the dialog stays open for a retry. */ + confirmError?: string; +}) { + const [state, setState] = useState(() => buildState(sections, prefs)); + + // The Favorites section disappears with its last staged-removed favorite, matching the side + // menu (which hides the section when empty) + const displayedSections = (current: DialogState) => + current.sectionOrder + .map((id) => sections.find((section) => section.id === id)) + .filter((section): section is CustomizeSidebarSection => section !== undefined) + .filter( + (section) => + section.id !== FAVORITES_SECTION_ID || + section.items.some((item) => !current.removed.includes(item.id)) + ); + + const orderedSections = displayedSections(state); + + const moveSection = (sectionId: string, direction: -1 | 1) => { + setState((current) => { + // Swap with the DISPLAYED neighbor: a hidden Favorites entry may still sit in + // sectionOrder between two visible sections + const displayed = displayedSections(current).map((section) => section.id); + const neighborId = displayed[displayed.indexOf(sectionId) + direction]; + if (!neighborId) return current; + const next = [...current.sectionOrder]; + const a = next.indexOf(sectionId); + const b = next.indexOf(neighborId); + [next[a], next[b]] = [next[b], next[a]]; + return { ...current, sectionOrder: next }; + }); + }; + + const reorderItems = (sectionId: string, itemIds: string[]) => { + setState((current) => ({ + ...current, + itemOrders: { ...current.itemOrders, [sectionId]: itemIds }, + })); + }; + + const toggleHidden = (itemId: string) => { + setState((current) => ({ + ...current, + hidden: { ...current.hidden, [itemId]: !current.hidden[itemId] }, + })); + }; + + const setLabel = (itemId: string, label: string) => { + setState((current) => ({ ...current, labels: { ...current.labels, [itemId]: label } })); + }; + + const removeFavorite = (itemId: string) => { + setState((current) => ({ ...current, removed: [...current.removed, itemId] })); + }; + + // Reset restores the default layout (positions + visibility) but never touches favorite names + // or staged removals; Cancel is the way out of those + const reset = () => + setState((current) => ({ + ...buildState(sections, undefined), + labels: current.labels, + removed: current.removed, + })); + + const hasBlankLabels = sections.some((section) => + section.items.some( + (item) => + item.isFavorite && + !state.removed.includes(item.id) && + (state.labels[item.id] ?? item.name).trim().length === 0 + ) + ); + + const confirm = () => { + const defaults = buildState(sections, undefined); + + const hiddenOverrides: Record = {}; + for (const section of sections) { + for (const item of section.items) { + if (state.removed.includes(item.id)) continue; + const isHidden = state.hidden[item.id] ?? false; + if (isHidden !== (item.defaultHidden ?? false)) { + hiddenOverrides[item.id] = isHidden; + } + } + } + + const sectionItemOrder: Record = {}; + for (const section of sections) { + if (section.id === FAVORITES_SECTION_ID) continue; + const order = state.itemOrders[section.id] ?? []; + if (!arraysEqual(order, defaults.itemOrders[section.id] ?? [])) { + sectionItemOrder[section.id] = order; + } + } + + const favoritesSection = sections.find((section) => section.id === FAVORITES_SECTION_ID); + const favoriteOrder = (state.itemOrders[FAVORITES_SECTION_ID] ?? []).filter( + (id) => !state.removed.includes(id) + ); + const favoritesChanged = + favoritesSection !== undefined && + (state.removed.length > 0 || + !arraysEqual(favoriteOrder, defaults.itemOrders[FAVORITES_SECTION_ID] ?? []) || + favoritesSection.items.some( + (item) => + !state.removed.includes(item.id) && + (state.labels[item.id] ?? item.name).trim() !== item.name + )); + + // Parts equal to the defaults are sent as null so the stored preference is cleared, not pinned + const payload: SidebarCustomizationPayload = { + sectionOrder: arraysEqual(state.sectionOrder, defaults.sectionOrder) + ? null + : state.sectionOrder, + hiddenItems: Object.keys(hiddenOverrides).length > 0 ? hiddenOverrides : null, + sectionItemOrder: Object.keys(sectionItemOrder).length > 0 ? sectionItemOrder : null, + favorites: favoritesChanged + ? favoriteOrder.map((id) => ({ id, label: state.labels[id] ?? "" })) + : undefined, + removedFavoriteIds: state.removed.length > 0 ? state.removed : undefined, + }; + + onConfirm(payload); + }; + + return ( + + Customize sidebar + {/* Bleeds through the container's right padding (-mr-4/pr-4) so the scrollbar sits at the + modal edge, and through the vertical grid gaps (-mt-1.25/-mb-4) so the scrollport (and + scrollbar) starts at the header divider and ends at the footer border. pt-3/pb-3 are + INSIDE the scrollport: resting gaps around the list that content scrolls through. */} +
+ {orderedSections.map((section, index) => ( +
+
+ {section.title} +
+ moveSection(section.id, -1)} + > + + + moveSection(section.id, 1)} + > + + +
+
+ item.id)).filter( + (id) => !state.removed.includes(id) + )} + hidden={state.hidden} + labels={state.labels} + onReorder={(itemIds) => reorderItems(section.id, itemIds)} + onToggleHidden={toggleHidden} + onLabelChange={setLabel} + onRemove={removeFavorite} + /> +
+ ))} +
+ {/* Negative margins stretch the top divider across the modal's full width */} + +
+ + + + +
+
+ {confirmError && !isConfirming && ( + {confirmError} + )} + +
+
+
+ ); +} + +function SectionMoveButton({ + label, + disabled, + onClick, + children, +}: { + label: string; + disabled: boolean; + onClick: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} + +function SectionItemList({ + section, + order, + hidden, + labels, + onReorder, + onToggleHidden, + onLabelChange, + onRemove, +}: { + section: CustomizeSidebarSection; + order: string[]; + hidden: Record; + labels: Record; + onReorder: (itemIds: string[]) => void; + onToggleHidden: (itemId: string) => void; + onLabelChange: (itemId: string, label: string) => void; + onRemove: (itemId: string) => void; +}) { + const { width, containerRef } = useContainerWidth({ initialWidth: 416 }); + + const items = order + .map((id) => section.items.find((item) => item.id === id)) + .filter((item): item is CustomizeSidebarItem => item !== undefined); + + const layout = items.map((item, index) => ({ i: item.id, x: 0, y: index, w: 1, h: 1 })); + + const handleDragStop = (nextLayout: Layout) => { + const sorted = [...nextLayout].sort((a, b) => a.y - b.y).map((entry) => entry.i); + if (!arraysEqual(sorted, order)) { + onReorder(sorted); + } + }; + + const renderRow = (item: CustomizeSidebarItem, options: { draggable: boolean }) => ( + onToggleHidden(item.id)} + onLabelChange={(label) => onLabelChange(item.id, label)} + onRemove={() => onRemove(item.id)} + /> + ); + + return ( +
}> + {items.length >= 2 ? ( + + {items.map((item) => ( +
{renderRow(item, { draggable: true })}
+ ))} +
+ ) : ( + items.map((item) =>
{renderRow(item, { draggable: false })}
) + )} +
+ ); +} + +function ModalItemRow({ + item, + isHidden, + label, + draggable, + onToggleHidden, + onLabelChange, + onRemove, +}: { + item: CustomizeSidebarItem; + isHidden: boolean; + label: string | undefined; + draggable: boolean; + onToggleHidden: () => void; + onLabelChange: (label: string) => void; + onRemove: () => void; +}) { + return ( +
+
+ + {item.isFavorite ? ( + <> + onLabelChange(e.target.value)} + variant="medium" + maxLength={64} + containerClassName="max-w-60" + aria-label={`Rename ${item.name}`} + /> + {(label ?? item.name).trim().length === 0 && ( + Name can't be blank + )} + + ) : ( + {item.name} + )} +
+
+ {item.isFavorite && ( + + )} + + {draggable ? ( +
+ +
+ ) : ( +
+ )} +
+
+ ); +} diff --git a/apps/webapp/app/components/navigation/DashboardList.tsx b/apps/webapp/app/components/navigation/DashboardList.tsx index 7f3b6e3bacb..39a7de00c1d 100644 --- a/apps/webapp/app/components/navigation/DashboardList.tsx +++ b/apps/webapp/app/components/navigation/DashboardList.tsx @@ -184,6 +184,7 @@ function DashboardChildMenuItem({ to={item.path} isCollapsed={isCollapsed} disableIconHover + yieldActiveToFavorite action={ showDragHandle ? (
diff --git a/apps/webapp/app/components/navigation/FavoritePageButton.tsx b/apps/webapp/app/components/navigation/FavoritePageButton.tsx new file mode 100644 index 00000000000..c0d03453b38 --- /dev/null +++ b/apps/webapp/app/components/navigation/FavoritePageButton.tsx @@ -0,0 +1,144 @@ +import { StarIcon as StarIconOutline } from "@heroicons/react/24/outline"; +import { StarIcon as StarIconSolid } from "@heroicons/react/20/solid"; +import { useFetcher, useLocation, useSearchParams } from "@remix-run/react"; +import { useEffect } from "react"; +import { useIsImpersonating } from "~/hooks/useOrganizations"; +import { useShortcutKeys } from "~/hooks/useShortcutKeys"; +import { useOptionalUser } from "~/hooks/useUser"; +import { cn } from "~/utils/cn"; +import { Button } from "../primitives/Buttons"; +import { ShortcutKey } from "../primitives/ShortcutKey"; +import { SimpleTooltip } from "../primitives/Tooltip"; +import { + buildFavoriteLabel, + canonicalFavoriteUrl, + FAVORITE_SEARCH_PARAM, + FAVORITES_ACTION_PATH, + favoritePageUrl, + resolvePageMeta, + useFavorites, +} from "./favoritePages"; + +/** + * The star in the page header that favorites the current page (full URL, including filters and + * tabs) to the side menu. Toggled by click or Option+F. + */ +export function FavoritePageButton({ + pageTitle, + className, +}: { + pageTitle?: string; + className?: string; +}) { + const user = useOptionalUser(); + const isImpersonating = useIsImpersonating(); + const location = useLocation(); + const favorites = useFavorites(); + const fetcher = useFetcher(); + const [, setSearchParams] = useSearchParams(); + + // The marker param and pagination position never count toward URL identity, so paging through + // a favorited view keeps the same favorite (and never saves a soon-stale cursor) + const url = favoritePageUrl(location.pathname, location.search); + + // A marker that isn't one of this user's favorites came from a shared link (or a favorite + // that's since been removed): clean it from the URL so the page behaves like a normal visit. + const marker = new URLSearchParams(location.search).get(FAVORITE_SEARCH_PARAM); + const hasForeignMarker = + user !== undefined && marker !== null && !favorites.some((f) => f.id === marker); + + useEffect(() => { + if (!hasForeignMarker) return; + setSearchParams( + (previous) => { + const next = new URLSearchParams(previous); + next.delete(FAVORITE_SEARCH_PARAM); + return next; + }, + { replace: true, preventScrollReset: true } + ); + }, [hasForeignMarker, setSearchParams]); + const existing = favorites.find((favorite) => canonicalFavoriteUrl(favorite.url) === url); + const isFavorited = existing !== undefined; + // The tooltip names the favorite: its custom name once saved, else the label saving would use + // (which includes detail-page ids and filter summaries, e.g. "Runs: Completed, last 7d") + const pageName = + existing?.label ?? buildFavoriteLabel(location.pathname, location.search, pageTitle); + + const toggle = () => { + if (existing) { + fetcher.submit( + { intent: "remove", id: existing.id }, + { method: "POST", action: FAVORITES_ACTION_PATH } + ); + } else { + fetcher.submit( + { + intent: "add", + id: crypto.randomUUID(), + url, + label: buildFavoriteLabel(location.pathname, location.search, pageTitle), + icon: resolvePageMeta(location.pathname).icon, + }, + { method: "POST", action: FAVORITES_ACTION_PATH } + ); + } + }; + + const showButton = user !== undefined && !isImpersonating; + + // Option+F reports event.key "ƒ" on macOS, but the hotkeys matcher falls back to the physical + // event.code ("KeyF"), so the standard hook captures it; exact modifier matching keeps the + // bare "f" filter shortcut separate. + useShortcutKeys({ + shortcut: { key: "f", modifiers: ["alt"] }, + action: (event) => { + event.preventDefault(); + toggle(); + }, + disabled: !showButton, + }); + + if (!showButton) { + return null; + } + + const tooltipLabel = isFavorited + ? `Remove ${pageName} from favorites` + : `Add ${pageName} to favorites`; + + return ( + +
@@ -1097,10 +1270,274 @@ export function SideMenu({
+ { + setCustomizeOpen(open); + if (!open) { + // Cancel/ESC during a pending confirm abandons the wait; a still-running save is + // harmless (the menu revalidates whenever it lands) + setCustomizeConfirmPending(false); + setCustomizeError(undefined); + } + }} + > + {/* Mounted only while open so the modal state re-seeds from current preferences each time */} + {isCustomizeOpen && ( + + )} +
); } +/** A section built from {@link SideMenuItemConfig}s with the user's order/hidden prefs applied. */ +function CustomizableSideMenuSection({ + section, + itemOrder, + hiddenItems, + isCollapsed, + initialCollapsed, + onCollapseToggle, + headerMenu, + onCustomize, +}: { + section: SideMenuSectionConfig; + itemOrder: string[] | undefined; + hiddenItems: Record | undefined; + isCollapsed: boolean; + initialCollapsed: boolean; + onCollapseToggle: (collapsed: boolean) => void; + headerMenu: ReactNode; + /** Undefined when customizing isn't offered (impersonated sessions can't persist a layout). */ + onCustomize: (() => void) | undefined; +}) { + const orderedItems = orderByPreference(section.items, itemOrder); + const visibleItems = orderedItems.filter((item) => !isItemHidden(item, hiddenItems)); + const moreItems = orderedItems.filter((item) => isItemHidden(item, hiddenItems)); + + return ( + + {visibleItems.map((item) => ( + + + {item.after} + + ))} + {moreItems.length > 0 && ( + ({ + id: item.id, + name: item.name, + icon: item.icon, + to: item.to, + }))} + isCollapsed={isCollapsed} + onCustomize={onCustomize} + /> + )} + + ); +} + +/** The user's favorited pages; hidden favorites collapse into the trailing "More" item. */ +function FavoritesSideMenuSection({ + favorites, + hiddenItems, + isCollapsed, + initialCollapsed, + onCollapseToggle, + headerMenu, + onCustomize, + onRemoveFavorite, + onRenameFavorite, +}: { + favorites: FavoritePage[]; + hiddenItems: Record | undefined; + isCollapsed: boolean; + initialCollapsed: boolean; + onCollapseToggle: (collapsed: boolean) => void; + headerMenu: ReactNode; + /** Undefined when customizing isn't offered (impersonated sessions can't persist a layout). */ + onCustomize: (() => void) | undefined; + onRemoveFavorite: (id: string) => void; + onRenameFavorite: (id: string, label: string) => void; +}) { + const visible = favorites.filter((favorite) => !(hiddenItems?.[favorite.id] ?? false)); + const hidden = favorites.filter((favorite) => hiddenItems?.[favorite.id] ?? false); + + return ( + + {visible.map((favorite) => ( + + ))} + {hidden.length > 0 && ( + ({ + id: favorite.id, + name: favorite.label, + icon: favoritePageIcon(favorite.icon), + iconClassName: favoritePageIconClassName(favorite.icon), + to: favoriteLinkTo(favorite), + }))} + isCollapsed={isCollapsed} + onCustomize={onCustomize} + /> + )} + + ); +} + +/** + * The trailing "More" item of a section: a popover listing that section's hidden items. Only + * rendered when the section has hidden items. + */ +function SideMenuMoreItem({ + items, + isCollapsed, + onCustomize, +}: { + items: Array<{ id: string; name: string; icon: RenderIcon; iconClassName?: string; to: string }>; + isCollapsed: boolean; + /** Undefined when customizing isn't offered (impersonated sessions can't persist a layout). */ + onCustomize: (() => void) | undefined; +}) { + const [isOpen, setOpen] = useState(false); + const navigation = useNavigation(); + + // Watch search too: navigating to a favorite can change only the search on the same pathname + useEffect(() => { + setOpen(false); + }, [navigation.location?.pathname, navigation.location?.search]); + + return ( + + + + + More + + + } + content="More" + side="right" + sideOffset={8} + hidden={!isCollapsed} + asChild + tabbable + disableHoverableContent + /> + +
+ {items.map((item) => ( + + ))} +
+ {/* flex-col blockifies the inline-block menu item, avoiding stray line-box space below */} + {onCustomize && ( +
+ { + setOpen(false); + onCustomize(); + }} + /> +
+ )} +
+
+ ); +} + +/** The ellipsis on section headers (visible on hover) opening the sidebar customization menu. */ +function SectionHeaderMenu({ onCustomize }: { onCustomize: () => void }) { + const [isOpen, setOpen] = useState(false); + + return ( + + + + + + {/* flex-col blockifies the inline-block menu item, avoiding stray line-box space below */} +
+ { + setOpen(false); + onCustomize(); + }} + /> +
+
+
+ ); +} + function V3DeprecationPanel({ isCollapsed, isV3, diff --git a/apps/webapp/app/components/navigation/SideMenuItem.tsx b/apps/webapp/app/components/navigation/SideMenuItem.tsx index f17051266d4..035fcc54370 100644 --- a/apps/webapp/app/components/navigation/SideMenuItem.tsx +++ b/apps/webapp/app/components/navigation/SideMenuItem.tsx @@ -3,6 +3,9 @@ import { type ButtonHTMLAttributes, forwardRef, type ReactNode, + useEffect, + useRef, + useState, } from "react"; import { Link } from "@remix-run/react"; import { motion } from "framer-motion"; @@ -10,6 +13,60 @@ import { usePathName } from "~/hooks/usePathName"; import { cn } from "~/utils/cn"; import { type RenderIcon, Icon } from "../primitives/Icon"; import { SimpleTooltip } from "../primitives/Tooltip"; +import { useActiveFavoriteId } from "./favoritePages"; + +/** Right-edge fade shown instead of a hard clip, only while the label actually overflows. */ +const LABEL_OVERFLOW_MASK = "linear-gradient(to right, black calc(100% - 1.5rem), transparent)"; + +/** + * A menu label that fades out at its right edge when (and only when) the text overflows. Text + * that fits renders exactly as before, with no mask. Overflow is re-measured when the element + * resizes (e.g. while drag-resizing the menu) and when the label text changes. + */ +export function SideMenuLabel({ + children, + className, + style, +}: { + children: ReactNode; + className?: string; + style?: React.CSSProperties; +}) { + const ref = useRef(null); + const [isOverflowing, setIsOverflowing] = useState(false); + const checkRef = useRef<() => void>(() => {}); + + useEffect(() => { + const el = ref.current; + if (!el) return; + const check = () => setIsOverflowing(el.scrollWidth > el.clientWidth + 1); + checkRef.current = check; + check(); + const observer = new ResizeObserver(check); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + // Re-measure when the text changes (a rename can flip overflow without resizing the element) + useEffect(() => { + checkRef.current(); + }, [children]); + + return ( + + {children} + + ); +} export function SideMenuItem({ icon, @@ -27,6 +84,8 @@ export function SideMenuItem({ action, disableIconHover = false, indented = false, + isActive: isActiveOverride, + yieldActiveToFavorite = false, "data-action": dataAction, }: { icon?: RenderIcon; @@ -45,10 +104,24 @@ export function SideMenuItem({ disableIconHover?: boolean; /** Indented variant for grouped sub-items; only applied when the menu is expanded. */ indented?: boolean; + /** Overrides the default pathname === to active check (e.g. favorites match on full URL). */ + isActive?: boolean; + /** + * In menus that render the Favorites section (the main project menu), an active favorite owns + * the highlight, so the plain item yields its active state to it. Menus without a favorites + * list (org settings, account) must not set this: they have no favorite item to carry the + * highlight instead. + */ + yieldActiveToFavorite?: boolean; "data-action"?: string; }) { const pathName = usePathName(); - const isActive = pathName === to; + // Only the user's OWN favorites own a view (via the marker param); markers from shared links + // don't count (see useActiveFavoriteId). + const activeFavoriteId = useActiveFavoriteId(); + const isActive = + isActiveOverride ?? + (pathName === to && (!yieldActiveToFavorite || activeFavoriteId === undefined)); const isIndented = indented && !isCollapsed; @@ -92,14 +165,14 @@ export function SideMenuItem({ className="flex w-full min-w-0 items-center justify-between" style={{ opacity: "var(--sm-label-opacity, 1)" }} > - {name} - + {badge && !isCollapsed && (
{badge}
)} @@ -189,9 +262,9 @@ export const SideMenuItemButton = forwardRef< icon={icon} className="size-5 shrink-0 text-text-dimmed group-hover/menuitem:text-text-bright" /> - + {name} - + {trailing && {trailing}} ); diff --git a/apps/webapp/app/components/navigation/SideMenuSection.tsx b/apps/webapp/app/components/navigation/SideMenuSection.tsx index 8225d9e701c..4f0d37e2716 100644 --- a/apps/webapp/app/components/navigation/SideMenuSection.tsx +++ b/apps/webapp/app/components/navigation/SideMenuSection.tsx @@ -12,6 +12,11 @@ type Props = { itemSpacingClassName?: string; /** Optional action element (e.g., + button) to render on the right side of the header */ headerAction?: React.ReactNode; + /** + * Optional menu (e.g. an ellipsis popover) overlaid on the right of the header. Only visible + * while hovering the header row, or while its popover is open. + */ + headerMenu?: React.ReactNode; }; /** A collapsible section for the side menu. Collapsed state is controlled via props + a toggle callback. */ @@ -23,6 +28,7 @@ export function SideMenuSection({ isSideMenuCollapsed = false, itemSpacingClassName = "space-y-px", headerAction, + headerMenu, }: Props) { const [isCollapsed, setIsCollapsed] = useState(initialCollapsed); const contentRef = useRef(null); @@ -45,7 +51,7 @@ export function SideMenuSection({ return (
{/* Header container - stays in DOM to preserve height */} -
+
{/* Header fades out as the menu narrows via --sm-label-opacity (falls back to 1 unset). Hover background and text color snap (no transition), matching the nav items. @@ -53,8 +59,9 @@ export function SideMenuSection({ + {headerMenu !== undefined && + !isSideMenuCollapsed && ( + // Outer div fades with the labels (inline style would defeat the hover opacity classes + // on the inner div, so they're split). +
+ {/* focus-within keeps the trigger visible for keyboard users tabbing onto it */} +
+ {headerMenu} +
+
+ )} {/* Divider fades in via --sm-collapse (0 → 1) as the header fades out. Only while expanded. */} diff --git a/apps/webapp/app/components/navigation/favoritePages.tsx b/apps/webapp/app/components/navigation/favoritePages.tsx new file mode 100644 index 00000000000..4439aadedfd --- /dev/null +++ b/apps/webapp/app/components/navigation/favoritePages.tsx @@ -0,0 +1,511 @@ +import { BeakerIcon } from "@heroicons/react/24/outline"; +import { IconChartHistogram } from "@tabler/icons-react"; +import { useFetchers, useLocation } from "@remix-run/react"; +import { ClockIcon } from "~/assets/icons/ClockIcon"; +import { CubeSparkleIcon } from "~/assets/icons/CubeSparkleIcon"; +import { TaskIconSmall } from "~/assets/icons/TaskIcon"; +import { AIChatIcon } from "~/assets/icons/AIChatIcon"; +import { AIMetricsIcon } from "~/assets/icons/AIMetricsIcon"; +import { AIPenIcon } from "~/assets/icons/AIPenIcon"; +import { AvatarCircleIcon } from "~/assets/icons/AvatarCircleIcon"; +import { BatchesIcon } from "~/assets/icons/BatchesIcon"; +import { BellIcon } from "~/assets/icons/BellIcon"; +import { Box3DIcon } from "~/assets/icons/Box3DIcon"; +import { BugIcon } from "~/assets/icons/BugIcon"; +import { ChainLinkIcon } from "~/assets/icons/ChainLinkIcon"; +import { ChartArrowIcon } from "~/assets/icons/ChartArrowIcon"; +import { ChartBarIcon } from "~/assets/icons/ChartBarIcon"; +import { CodeSquareIcon } from "~/assets/icons/CodeSquareIcon"; +import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon"; +import { CreditCardIcon } from "~/assets/icons/CreditCardIcon"; +import { DeploymentsIcon } from "~/assets/icons/DeploymentsIcon"; +import { DialIcon } from "~/assets/icons/DialIcon"; +import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons"; +import { FolderOpenIcon } from "~/assets/icons/FolderOpenIcon"; +import { GlobeLinesIcon } from "~/assets/icons/GlobeLinesIcon"; +import { IDIcon } from "~/assets/icons/IDIcon"; +import { IntegrationsIcon } from "~/assets/icons/IntegrationsIcon"; +import { KeyIcon } from "~/assets/icons/KeyIcon"; +import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon"; +import { LogsIcon } from "~/assets/icons/LogsIcon"; +import { PadlockIcon } from "~/assets/icons/PadlockIcon"; +import { QueuesIcon } from "~/assets/icons/QueuesIcon"; +import { RolesIcon } from "~/assets/icons/RolesIcon"; +import { RunsIcon } from "~/assets/icons/RunsIcon"; +import { ShieldIcon } from "~/assets/icons/ShieldIcon"; +import { SlackIcon } from "~/assets/icons/SlackIcon"; +import { SlidersIcon } from "~/assets/icons/SlidersIcon"; +import { StarIcon } from "~/assets/icons/StarIcon"; +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 { VercelLogo } from "~/components/integrations/VercelLogo"; +import { useOptionalUser } from "~/hooks/useUser"; +import { type FavoritePage } from "~/services/dashboardPreferences.server"; +import { type RenderIcon } from "../primitives/Icon"; + +export const FAVORITES_ACTION_PATH = "/resources/preferences/favorites"; + +/** + * Marker search param appended to favorite links. It makes a favorited URL distinct from its + * plain counterpart, so only the favorite (never the matching main menu item) highlights as + * active, and the marker identifies WHICH favorite when several share a pathname. + */ +export const FAVORITE_SEARCH_PARAM = "fav"; + +/** + * Icons a favorited page can be saved with, keyed by a stable string so preferences never store + * component references, plus the icon color used when the favorite is the active page and an + * optional size override for icons drawn without internal padding (brand logos). Unknown keys + * fall back to the star. + */ +const FAVORITE_PAGE_ICONS: Record< + string, + { icon: RenderIcon; activeColor: string; className?: string } +> = { + tasks: { icon: TasksIcon, activeColor: "text-tasks" }, + // Task detail pages carry their task-type icon, matching TaskTriggerSourceIcon + "task-standard": { icon: TaskIconSmall, activeColor: "text-tasks" }, + "task-scheduled": { icon: ClockIcon, activeColor: "text-schedules" }, + "task-agent": { icon: CubeSparkleIcon, activeColor: "text-agents" }, + runs: { icon: RunsIcon, activeColor: "text-runs" }, + sessions: { icon: AIChatIcon, activeColor: "text-sessions" }, + prompts: { icon: AIPenIcon, activeColor: "text-aiPrompts" }, + models: { icon: Box3DIcon, activeColor: "text-models" }, + logs: { icon: LogsIcon, activeColor: "text-logs" }, + errors: { icon: BugIcon, activeColor: "text-errors" }, + query: { icon: CodeSquareIcon, activeColor: "text-query" }, + queues: { icon: QueuesIcon, activeColor: "text-queues" }, + dashboards: { icon: ChartBarIcon, activeColor: "text-metrics" }, + "run-metrics": { icon: ChartArrowIcon, activeColor: "text-runs" }, + "ai-metrics": { icon: AIMetricsIcon, activeColor: "text-aiMetrics" }, + "custom-dashboard": { icon: IconChartHistogram, activeColor: "text-text-bright" }, + deployments: { icon: DeploymentsIcon, activeColor: "text-deployments" }, + "environment-variables": { icon: IDIcon, activeColor: "text-environmentVariables" }, + branches: { icon: BranchEnvironmentIconSmall, activeColor: "text-previewBranches" }, + regions: { icon: GlobeLinesIcon, activeColor: "text-regions" }, + waitpoints: { icon: WaitpointTokenIcon, activeColor: "text-sky-500" }, + batches: { icon: BatchesIcon, activeColor: "text-batches" }, + "bulk-actions": { icon: ListCheckedIcon, activeColor: "text-text-bright" }, + apikeys: { icon: KeyIcon, activeColor: "text-text-bright" }, + alerts: { icon: BellIcon, activeColor: "text-text-bright" }, + concurrency: { icon: ConcurrencyIcon, activeColor: "text-text-bright" }, + limits: { icon: DialIcon, activeColor: "text-text-bright" }, + schedules: { icon: ClockIcon, activeColor: "text-schedules" }, + test: { icon: BeakerIcon, activeColor: "text-text-bright" }, + "project-settings": { icon: SlidersIcon, activeColor: "text-text-bright" }, + integrations: { icon: IntegrationsIcon, activeColor: "text-text-bright" }, + // Brand logos have no internal padding, so they render one step smaller (matching the org menu) + slack: { icon: SlackIcon, activeColor: "text-text-bright", className: "size-4" }, + vercel: { icon: VercelLogo, activeColor: "text-text-bright", className: "size-4" }, + project: { icon: FolderOpenIcon, activeColor: "text-text-bright" }, + "org-settings": { icon: SlidersIcon, activeColor: "text-text-bright" }, + team: { icon: UserGroupIcon, activeColor: "text-text-bright" }, + billing: { icon: CreditCardIcon, activeColor: "text-text-bright" }, + usage: { icon: UsageIcon, activeColor: "text-text-bright" }, + roles: { icon: RolesIcon, activeColor: "text-text-bright" }, + sso: { icon: PadlockIcon, activeColor: "text-text-bright" }, + "private-connections": { icon: ChainLinkIcon, activeColor: "text-text-bright" }, + account: { icon: AvatarCircleIcon, activeColor: "text-text-bright" }, + tokens: { icon: ShieldIcon, activeColor: "text-text-bright" }, + security: { icon: PadlockIcon, activeColor: "text-text-bright" }, + page: { icon: StarIcon, activeColor: "text-text-bright" }, +}; + +export function favoritePageIcon(iconKey: string | undefined): RenderIcon { + return (iconKey ? FAVORITE_PAGE_ICONS[iconKey]?.icon : undefined) ?? StarIcon; +} + +export function favoritePageActiveColor(iconKey: string | undefined): string { + return (iconKey ? FAVORITE_PAGE_ICONS[iconKey]?.activeColor : undefined) ?? "text-text-bright"; +} + +/** Size override for favorite icons that need one (see FAVORITE_PAGE_ICONS). */ +export function favoritePageIconClassName(iconKey: string | undefined): string | undefined { + return iconKey ? FAVORITE_PAGE_ICONS[iconKey]?.className : undefined; +} + +/** Href for a favorite: its saved URL plus the marker param (see FAVORITE_SEARCH_PARAM). */ +export function favoriteLinkTo(favorite: FavoritePage): string { + const [path, search = ""] = favorite.url.split("?"); + const params = new URLSearchParams(search); + params.set(FAVORITE_SEARCH_PARAM, favorite.id); + return `${path}?${params.toString()}`; +} + +/** Pagination position params: never part of a favorite's identity (see favoritePageUrl). */ +const PAGINATION_PARAMS = ["cursor", "direction", "page"]; + +/** + * The canonical URL a favorite saves and matches against: the path and search minus the favorite + * marker (presentation-only) and the pagination position (cursors go stale, and page N of a view + * is not a different view). A favorite pins filters and tabs, never a transient page of them. + */ +export function favoritePageUrl(pathname: string, search: string): string { + const params = new URLSearchParams(search); + params.delete(FAVORITE_SEARCH_PARAM); + for (const param of PAGINATION_PARAMS) { + params.delete(param); + } + const result = params.toString(); + return pathname + (result.length > 0 ? `?${result}` : ""); +} + +/** favoritePageUrl for an already-joined URL, e.g. a favorite's stored one (which may predate + * pagination stripping). */ +export function canonicalFavoriteUrl(url: string): string { + const [pathname, search = ""] = url.split("?"); + return favoritePageUrl(pathname, search); +} + +/** + * A favorite is active only while the URL is the view it saved: its marker param is present AND + * the canonical URL still matches. Changing any filter on the page diverges the URL from the + * favorite, so it deactivates (and the regular menu item takes over) — but paging within the + * view keeps it active, matching what the favorite pins. + */ +export function isFavoriteActive( + favorite: FavoritePage, + pathname: string, + search: string +): boolean { + return ( + new URLSearchParams(search).get(FAVORITE_SEARCH_PARAM) === favorite.id && + canonicalFavoriteUrl(favorite.url) === favoritePageUrl(pathname, search) + ); +} + +/** + * The id of the favorite driving the current view: the URL's marker param, but only when it + * belongs to one of the current user's favorites AND the URL still matches that favorite's + * saved view. A marker from someone else's shared link, a removed favorite's stale link, or a + * view whose filters have since been changed resolves to undefined, so regular menu + * highlighting applies. + */ +export function useActiveFavoriteId(): string | undefined { + const location = useLocation(); + const favorites = useFavorites(); + + const marker = new URLSearchParams(location.search).get(FAVORITE_SEARCH_PARAM); + if (!marker) return undefined; + const favorite = favorites.find((f) => f.id === marker); + if (!favorite) return undefined; + return isFavoriteActive(favorite, location.pathname, location.search) ? marker : undefined; +} + +type PageMeta = { + /** Key into FAVORITE_PAGE_ICONS. */ + icon: string; + /** The page's name as shown in navigation, e.g. "Queues". */ + name: string; + /** Singular label-prefix for detail pages, e.g. "Queue" -> "Queue: my-queue". */ + singular?: string; + /** + * Entity name taken from the URL, used verbatim as the label. For detail pages whose header + * title is composed JSX (task/agent pages render an icon + slug), so no plain-text title + * reaches the star. The icon already conveys the type, so no prefix is added. + */ + entityName?: string; +}; + +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" }, + prompts: { icon: "prompts", name: "Prompts", singular: "Prompt" }, + models: { icon: "models", name: "Models", singular: "Model" }, + logs: { icon: "logs", name: "Logs" }, + errors: { icon: "errors", name: "Errors", singular: "Error" }, + query: { icon: "query", name: "Query" }, + queues: { icon: "queues", name: "Queues", singular: "Queue" }, + dashboards: { icon: "dashboards", name: "Dashboards", singular: "Dashboard" }, + deployments: { icon: "deployments", name: "Deploys", singular: "Deploy" }, + "environment-variables": { icon: "environment-variables", name: "Environment variables" }, + branches: { icon: "branches", name: "Preview branches", singular: "Branch" }, + regions: { icon: "regions", name: "Regions" }, + waitpoints: { icon: "waitpoints", name: "Waitpoint tokens", singular: "Waitpoint" }, + batches: { icon: "batches", name: "Batches", singular: "Batch" }, + "bulk-actions": { icon: "bulk-actions", name: "Bulk actions", singular: "Bulk action" }, + apikeys: { icon: "apikeys", name: "API keys" }, + alerts: { icon: "alerts", name: "Alerts", singular: "Alert" }, + concurrency: { icon: "concurrency", name: "Concurrency" }, + limits: { icon: "limits", name: "Limits" }, + schedules: { icon: "schedules", name: "Schedules", singular: "Schedule" }, + test: { icon: "test", name: "Test", singular: "Test" }, + // The playground route is the Test page too (its header reads "Test") + playground: { icon: "test", name: "Test", singular: "Test" }, +}; + +const ORG_SETTINGS_PAGE_META: Record = { + "": { icon: "org-settings", name: "Organization settings" }, + team: { icon: "team", name: "Team" }, + billing: { icon: "billing", name: "Billing" }, + "billing-limits": { icon: "alerts", name: "Billing alerts" }, + usage: { icon: "usage", name: "Usage" }, + roles: { icon: "roles", name: "Roles" }, + sso: { icon: "sso", name: "SSO" }, + "private-connections": { icon: "private-connections", name: "Private connections" }, + integrations: { icon: "integrations", name: "Integrations" }, + danger: { icon: "org-settings", name: "Danger zone" }, +}; + +const ACCOUNT_PAGE_META: Record = { + "": { icon: "account", name: "Profile" }, + tokens: { icon: "tokens", name: "Personal Access Tokens" }, + security: { icon: "security", name: "Security" }, +}; + +/** Best-effort icon + name for any dashboard page, derived from its URL shape. */ +export function resolvePageMeta(pathname: string): PageMeta { + const envMatch = pathname.match(/^\/orgs\/[^/]+\/projects\/[^/]+\/env\/[^/]+(?:\/([^?]*))?$/); + if (envMatch) { + const segments = (envMatch[1] ?? "").split("/").filter(Boolean); + const first = segments[0] ?? ""; + if (first === "settings") { + return segments[1] === "integrations" + ? { icon: "integrations", name: "Integrations" } + : { icon: "project-settings", name: "Project settings" }; + } + if (first === "dashboards") { + // The built-in metric dashboards and custom dashboards have their own identities (and icons) + if (segments[1] === "overview") return { icon: "run-metrics", name: "Run metrics" }; + if (segments[1] === "llm") return { icon: "ai-metrics", name: "AI metrics" }; + if (segments[1] === "custom") { + return { icon: "custom-dashboard", name: "Dashboards", singular: "Dashboard" }; + } + } + + // Task detail: /tasks/{standard|scheduled}/{slug}. The slug is the only place the task name + // exists (the page header renders it as JSX), so it becomes the label. + if (first === "tasks" && segments[2]) { + const slug = decodeURIComponent(segments[2]); + return segments[1] === "scheduled" + ? { icon: "task-scheduled", name: "Scheduled task", entityName: slug } + : { icon: "task-standard", name: "Standard task", entityName: slug }; + } + + // Agent tasks live outside /tasks: /agents/{slug} + if (first === "agents") { + return segments[1] + ? { + icon: "task-agent", + name: "Agent task", + entityName: decodeURIComponent(segments[1]), + } + : { icon: "task-agent", name: "Agents" }; + } + + return ENV_PAGE_META[first] ?? { icon: "page", name: "Page" }; + } + + const orgSettingsMatch = pathname.match(/^\/orgs\/[^/]+\/settings(?:\/([^?]*))?$/); + if (orgSettingsMatch) { + const segments = (orgSettingsMatch[1] ?? "").split("/").filter(Boolean); + if (segments[0] === "integrations" && segments[1] === "slack") { + return { icon: "slack", name: "Slack integration" }; + } + if (segments[0] === "integrations" && segments[1] === "vercel") { + return { icon: "vercel", name: "Vercel integration" }; + } + return ORG_SETTINGS_PAGE_META[segments[0] ?? ""] ?? { icon: "org-settings", name: "Settings" }; + } + + if (/^\/orgs\/[^/]+\/projects\/[^/]+/.test(pathname)) { + return { icon: "project", name: "Project" }; + } + + if (/^\/orgs\/[^/]+/.test(pathname)) { + return { icon: "project", name: "Projects" }; + } + + const accountMatch = pathname.match(/^\/account(?:\/([^?]*))?$/); + if (accountMatch) { + const segments = (accountMatch[1] ?? "").split("/").filter(Boolean); + return ACCOUNT_PAGE_META[segments[0] ?? ""] ?? { icon: "account", name: "Account" }; + } + + return { icon: "page", name: "Page" }; +} + +const MAX_LABEL_LENGTH = 50; + +function truncateLabel(label: string): string { + return label.length > MAX_LABEL_LENGTH ? `${label.slice(0, MAX_LABEL_LENGTH - 1)}…` : label; +} + +/** + * Short id for a detail page whose last URL segment is a friendly id ("run_cmryyza…05hrqq9n"). + * Uses the same 8-character tail the dashboard tables display, so the label matches what the + * user sees elsewhere. + */ +function detailIdFromPath(pathname: string): string | undefined { + const segments = pathname.split("/").filter(Boolean); + const last = segments[segments.length - 1]; + if (last && /^(run|batch|session|deployment|schedule|waitpoint)_[a-z0-9]{8,}$/i.test(last)) { + return last.slice(-8); + } + return undefined; +} + +/** Task type filter on the Tasks page (?types=…) becomes the whole favorite name. */ +const TASK_TYPE_LABELS: Record = { + AGENT: "Agent tasks", + STANDARD: "Standard tasks", + SCHEDULED: "Scheduled tasks", +}; + +/** "COMPLETED_SUCCESSFULLY" -> "Completed successfully", "history" -> "History". */ +function humanizeValue(value: string): string { + const lowered = value.toLowerCase().replaceAll("_", " "); + return lowered.charAt(0).toUpperCase() + lowered.slice(1); +} + +/** Pagination/UI-state params that never describe what the user filtered. */ +const NON_FILTER_PARAMS = [FAVORITE_SEARCH_PARAM, ...PAGINATION_PARAMS, "span"]; + +/** + * Summarize a filtered view's search params into a short, selective descriptor for the favorite + * label ("Completed successfully, last 7d +2"). The best-known filters are named (at most two); + * everything else only counts toward a "+N" so heavily filtered views stay readable. + */ +function describeFilters(search: string): string | undefined { + const params = new URLSearchParams(search); + for (const param of NON_FILTER_PARAMS) { + params.delete(param); + } + + const parts: string[] = []; + const consumed = new Set(); + + const take = (key: string, describe: (values: string[]) => string | undefined) => { + const values = params.getAll(key).filter((value) => value.length > 0); + if (values.length === 0) return; + consumed.add(key); + const described = describe(values); + if (described) parts.push(described); + }; + + // Priority order: the filters most likely to identify the view come first + take("statuses", (v) => (v.length === 1 ? humanizeValue(v[0]) : `${v.length} statuses`)); + take("levels", (v) => (v.length === 1 ? humanizeValue(v[0]) : `${v.length} levels`)); + take("tasks", (v) => (v.length === 1 ? v[0] : `${v.length} tasks`)); + take("queues", (v) => (v.length === 1 ? v[0].replace(/^task\//, "") : `${v.length} queues`)); + take("tags", (v) => (v.length === 1 ? v[0] : `${v.length} tags`)); + take("period", (v) => `last ${v[0]}`); + if (params.has("from") || params.has("to")) { + consumed.add("from"); + consumed.add("to"); + parts.push("custom range"); + } + take("versions", (v) => (v.length === 1 ? v[0] : `${v.length} versions`)); + take("machines", (v) => (v.length === 1 ? v[0] : `${v.length} machines`)); + take("tab", (v) => humanizeValue(v[0])); + // The runs list appends rootOnly=false by default; only the non-default value is a filter + take("rootOnly", (v) => (v[0] === "true" ? "root only" : undefined)); + + const remaining = new Set([...params.keys()].filter((key) => !consumed.has(key))).size; + + const MAX_NAMED_PARTS = 2; + const shown = parts.slice(0, MAX_NAMED_PARTS); + const extra = parts.length - shown.length + remaining; + + if (shown.length === 0) { + return extra > 0 ? `${extra} filter${extra === 1 ? "" : "s"}` : undefined; + } + return shown.join(", ") + (extra > 0 ? ` +${extra}` : ""); +} + +/** + * Compose the default side menu label for a favorited page. Plain list pages keep their nav + * name ("Queues"); detail pages get an identifying prefix ("Queue: email-queue", or the short + * id for friendly-id pages: "Run: 05hrqq9n"); filtered views summarize their filters ("Runs: + * Completed successfully, last 7d"). Users can always rename. + */ +export function buildFavoriteLabel( + pathname: string, + search: string, + pageTitle: string | undefined +): string { + const meta = resolvePageMeta(pathname); + const title = pageTitle?.trim(); + const prefix = meta.singular ?? meta.name; + + // Generic titles ("Runs", "Run") identify nothing on their own; prefer ids/filters from the URL + const isGenericTitle = + !title || + title.toLowerCase() === meta.name.toLowerCase() || + title.toLowerCase() === prefix.toLowerCase(); + + if (isGenericTitle) { + // Named entity from the URL (task/agent slug) is the label on its own; its icon carries the type + if (meta.entityName) return truncateLabel(meta.entityName); + + // The Tasks page filtered to a single task type takes that type as the whole name + if (meta.icon === "tasks") { + const types = new URLSearchParams(search).getAll("types"); + if (types.length === 1 && TASK_TYPE_LABELS[types[0]]) { + return TASK_TYPE_LABELS[types[0]]; + } + } + + const detailId = detailIdFromPath(pathname); + if (detailId) return `${prefix}: ${detailId}`; + + const filters = describeFilters(search); + return truncateLabel(filters ? `${meta.name}: ${filters}` : meta.name); + } + + const label = title.toLowerCase().startsWith(prefix.toLowerCase()) + ? title + : `${prefix}: ${title}`; + return truncateLabel(label); +} + +/** + * The user's favorited pages with any in-flight mutations applied, so the star button and the + * side menu section update instantly and stay in sync while the server round-trip completes. + */ +export function useFavorites(): FavoritePage[] { + const user = useOptionalUser(); + const fetchers = useFetchers(); + + let favorites = user?.dashboardPreferences.sideMenu?.favorites ?? []; + + for (const fetcher of fetchers) { + if (fetcher.formAction !== FAVORITES_ACTION_PATH || !fetcher.formData) continue; + + const intent = fetcher.formData.get("intent"); + const id = fetcher.formData.get("id"); + if (typeof id !== "string") continue; + + switch (intent) { + case "add": { + const url = fetcher.formData.get("url"); + const label = fetcher.formData.get("label"); + const icon = fetcher.formData.get("icon"); + if (typeof url !== "string" || typeof label !== "string") break; + if (!favorites.some((f) => f.url === url)) { + // Newest favorites go to the top of the section (matches addFavorite server-side) + favorites = [ + { id, url, label, icon: typeof icon === "string" ? icon : undefined }, + ...favorites, + ]; + } + break; + } + case "remove": { + favorites = favorites.filter((f) => f.id !== id); + break; + } + case "rename": { + const label = fetcher.formData.get("label"); + if (typeof label !== "string") break; + favorites = favorites.map((f) => (f.id === id ? { ...f, label } : f)); + break; + } + } + } + + return favorites; +} diff --git a/apps/webapp/app/components/navigation/sideMenuTypes.ts b/apps/webapp/app/components/navigation/sideMenuTypes.ts index bb245f4bcb3..508c4121175 100644 --- a/apps/webapp/app/components/navigation/sideMenuTypes.ts +++ b/apps/webapp/app/components/navigation/sideMenuTypes.ts @@ -2,6 +2,7 @@ import { z } from "zod"; // Valid section IDs that can have their collapsed state toggled export const SideMenuSectionIdSchema = z.enum([ + "favorites", "ai", "manage", "metrics", @@ -12,3 +13,58 @@ export const SideMenuSectionIdSchema = z.enum([ // Inferred type from the schema export type SideMenuSectionId = z.infer; + +// Size popover items to match the side-menu items, overriding the smaller small-menu-item +// defaults via tailwind-merge; icon carries the default dimmed color. +export const SIDE_MENU_POPOVER_ITEM_ICON = "h-5 w-5 text-text-dimmed"; +export const SIDE_MENU_POPOVER_ITEM_LABEL = "text-[0.90625rem] font-medium tracking-[-0.01em]"; + +/** Default top-to-bottom order of the customizable side menu sections. */ +export const DEFAULT_SECTION_ORDER: SideMenuSectionId[] = [ + "favorites", + "ai", + "metrics", + "deployments", + "manage", +]; + +/** + * Order entries by a saved preference. Entries missing from the saved order (e.g. a section or + * item that shipped after the user customized) are inserted at their default position relative + * to the entries around them, not dumped at the end — so "Favorites" still lands above "AI" for + * users who saved an order before favorites existed. + */ +export function orderByPreference( + entries: T[], + savedOrder: string[] | undefined +): T[] { + if (!savedOrder || savedOrder.length === 0) return entries; + + const defaultIndex = new Map(entries.map((entry, index) => [entry.id, index])); + // Set-dedupe: a corrupted saved order with duplicate ids must not render an entry twice + const orderedIds = [...new Set(savedOrder.filter((id) => defaultIndex.has(id)))]; + const missingIds = entries.map((entry) => entry.id).filter((id) => !orderedIds.includes(id)); + + for (const id of missingIds) { + const idDefault = defaultIndex.get(id) ?? 0; + let insertAt = orderedIds.length; + for (let i = 0; i < orderedIds.length; i++) { + if ((defaultIndex.get(orderedIds[i]) ?? 0) > idDefault) { + insertAt = i; + break; + } + } + orderedIds.splice(insertAt, 0, id); + } + + const byId = new Map(entries.map((entry) => [entry.id, entry])); + return orderedIds.map((id) => byId.get(id)!); +} + +/** Effective hidden state for a menu item: the user's override wins, else the item's default. */ +export function isItemHidden( + item: { id: string; defaultHidden?: boolean }, + hiddenItems: Record | undefined +): boolean { + return hiddenItems?.[item.id] ?? item.defaultHidden ?? false; +} diff --git a/apps/webapp/app/components/primitives/Buttons.tsx b/apps/webapp/app/components/primitives/Buttons.tsx index 323f3743826..6075d52f1bf 100644 --- a/apps/webapp/app/components/primitives/Buttons.tsx +++ b/apps/webapp/app/components/primitives/Buttons.tsx @@ -89,6 +89,13 @@ const theme = { shortcut: "border-text-bright text-text-bright group-hover/button:border-text-bright/60", icon: "text-text-bright", }, + warning: { + textColor: "text-warning transition group-disabled/button:text-warning/60", + button: + "bg-warning/10 border border-warning/20 group-hover/button:bg-warning/20 group-hover/button:border-warning/40 group-disabled/button:opacity-60 group-disabled/button:pointer-events-none", + shortcut: "border-warning/40 text-warning group-hover/button:border-warning/60", + icon: "text-warning", + }, docs: { textColor: "text-blue-200/70 transition group-disabled/button:text-text-dimmed/80", button: @@ -133,6 +140,10 @@ const variant = { "danger/medium": createVariant("medium", "danger"), "danger/large": createVariant("large", "danger"), "danger/extra-large": createVariant("extra-large", "danger"), + "warning/small": createVariant("small", "warning"), + "warning/medium": createVariant("medium", "warning"), + "warning/large": createVariant("large", "warning"), + "warning/extra-large": createVariant("extra-large", "warning"), "docs/small": createVariant("small", "docs"), "docs/medium": createVariant("medium", "docs"), "docs/large": createVariant("large", "docs"), diff --git a/apps/webapp/app/components/primitives/ClipboardField.tsx b/apps/webapp/app/components/primitives/ClipboardField.tsx index 0304e5bca6b..7774f301218 100644 --- a/apps/webapp/app/components/primitives/ClipboardField.tsx +++ b/apps/webapp/app/components/primitives/ClipboardField.tsx @@ -60,9 +60,43 @@ const variants = { }, }; +const SECURE_MASK = "••••••••••••••••"; + +/** + * Builds the masked display string, optionally revealing the first/last few + * characters in cleartext so users can confirm a copied value. A custom mask + * string (when `secure` is a string) is always shown as-is. + */ +function maskValue( + value: string, + secure: boolean | string, + revealStart: number, + revealEnd: number +) { + if (typeof secure === "string") { + return secure; + } + + const start = Math.max(0, revealStart); + const end = Math.max(0, revealEnd); + + // Nothing to reveal, or revealing would leak the whole value: fully mask. + if ((start === 0 && end === 0) || start + end >= value.length) { + return SECURE_MASK; + } + + const revealedStart = start > 0 ? value.slice(0, start) : ""; + const revealedEnd = end > 0 ? value.slice(-end) : ""; + return `${revealedStart}${SECURE_MASK}${revealedEnd}`; +} + type ClipboardFieldProps = { value: string; secure?: boolean | string; + /** When masked, reveal this many of the first characters in cleartext. */ + secureRevealStart?: number; + /** When masked, reveal this many of the last characters in cleartext. */ + secureRevealEnd?: number; variant: keyof typeof variants; className?: string; icon?: React.ReactNode; @@ -73,6 +107,8 @@ type ClipboardFieldProps = { export function ClipboardField({ value, secure = false, + secureRevealStart = 0, + secureRevealEnd = 0, variant, className, icon, @@ -87,6 +123,8 @@ export function ClipboardField({ setIsSecure(secure !== undefined && secure); }, [secure]); + const maskedValue = maskValue(value, secure, secureRevealStart, secureRevealEnd); + return ( {icon && ( @@ -100,7 +138,7 @@ export function ClipboardField({ +
{backButton && (
)} {title} - {accessory !== undefined && - (typeof accessory === "string" ? ( - } - content={accessory} - className="max-w-xs" - disableHoverableContent - /> - ) : ( - accessory - ))} + {accessory !== undefined && ( + // ml-px optically evens the accessory against the title's tight text edge + + {typeof accessory === "string" ? ( + } + content={accessory} + className="max-w-xs" + disableHoverableContent + /> + ) : ( + accessory + )} + + )} + {/* -ml-1 pulls the star's button box near-flush: its inner padding then provides the + visual gap, matching the title-to-accessory spacing while hovered */} +
); } diff --git a/apps/webapp/app/components/primitives/Select.tsx b/apps/webapp/app/components/primitives/Select.tsx index 66b291b7524..3c4c8faad0c 100644 --- a/apps/webapp/app/components/primitives/Select.tsx +++ b/apps/webapp/app/components/primitives/Select.tsx @@ -38,6 +38,9 @@ const variants = { "secondary/small": { button: cn(sizes.small.button, style.secondary.button), }, + "secondary/medium": { + button: cn(sizes.medium.button, style.secondary.button), + }, "tertiary/small": { button: cn(sizes.small.button, style.tertiary.button), }, @@ -692,11 +695,11 @@ export function ComboBox({ ...props }: ComboBoxProps) { return ( -
+
} - className="flex-1 bg-transparent text-xs text-text-dimmed outline-hidden" + className="flex-1 border-0 bg-transparent text-xs text-text-dimmed outline-hidden focus:border-0 focus:ring-0" {...props} /> {shortcut && ( diff --git a/apps/webapp/app/components/primitives/SettingsLayout.tsx b/apps/webapp/app/components/primitives/SettingsLayout.tsx index 7d6484fb467..dae340e9835 100644 --- a/apps/webapp/app/components/primitives/SettingsLayout.tsx +++ b/apps/webapp/app/components/primitives/SettingsLayout.tsx @@ -1,3 +1,4 @@ +import { ExclamationCircleIcon, ExclamationTriangleIcon } from "@heroicons/react/20/solid"; import { type ReactNode } from "react"; import { MainHorizontallyCenteredContainer } from "~/components/layout/AppLayout"; import { cn } from "~/utils/cn"; @@ -100,7 +101,10 @@ export function SettingsRowTitle({ htmlFor?: string; className?: string; }) { - const classes = cn("font-sans text-sm font-semibold leading-tight text-text-bright", className); + const classes = cn( + "block font-sans text-sm font-semibold leading-tight text-text-bright", + className + ); return htmlFor ? (