Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions apps/host-selfhost/web/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { fetchNeedsSetup } from "../setup-status";
// ---------------------------------------------------------------------------

export const Route = createRootRoute({
notFoundComponent: NotFoundPage,
component: RootComponent,
});

Expand All @@ -50,6 +51,26 @@ const signOut = async () => {
window.location.href = "/";
};

function NotFoundPage() {
return (
<main className="flex min-h-screen items-center justify-center bg-background px-6 py-10">
<section className="w-full max-w-md text-center">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">404</p>
<h1 className="mt-2 text-xl font-semibold text-foreground">Page not found</h1>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
There&apos;s nothing at this address.
</p>
<a
href="/"
className="mt-6 inline-flex h-9 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90"
>
Go home
</a>
</section>
</main>
);
}

const Loading = () => (
<div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground">
Loading…
Expand Down
2 changes: 2 additions & 0 deletions apps/host-selfhost/web/routes/app/admin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { CopyButton } from "@executor-js/react/components/copy-button";
import { Input } from "@executor-js/react/components/input";
import { Label } from "@executor-js/react/components/label";
import { NativeSelect, NativeSelectOption } from "@executor-js/react/components/native-select";
import { useExecutorDocumentTitle } from "@executor-js/react/lib/document-title";
import {
orgMembersAtom,
removeMember,
Expand All @@ -29,6 +30,7 @@ const ROLES = ["member", "admin"] as const;
// are the self-host join mechanism. The API gates to owner/admin, so a
// non-admin who opens this just sees load failures.
function AdminPage() {
useExecutorDocumentTitle("Admin");
return (
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto flex max-w-3xl flex-col gap-10 px-6 py-10 lg:px-8 lg:py-14">
Expand Down
21 changes: 21 additions & 0 deletions packages/app/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,30 @@ import { plugins as clientPlugins } from "virtual:executor/plugins-client";
import { Shell } from "../web/shell";

export const Route = createRootRoute({
notFoundComponent: NotFoundPage,
component: RootComponent,
});

function NotFoundPage() {
return (
<main className="flex min-h-screen items-center justify-center bg-background px-6 py-10">
<section className="w-full max-w-md text-center">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">404</p>
<h1 className="mt-2 text-xl font-semibold text-foreground">Page not found</h1>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
There&apos;s nothing at this address.
</p>
<a
href="/"
className="mt-6 inline-flex h-9 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90"
>
Go home
</a>
</section>
Comment on lines +10 to +29

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 NotFoundPage is duplicated verbatim across two root files

The identical component exists in both packages/app/src/routes/__root.tsx and apps/host-selfhost/web/routes/__root.tsx. Any future copy/style change will need to be made in both places. AGENTS.md recommends keeping package boundaries clear and extracting shared logic when the behavior is genuinely shared — this is a good candidate. A single export from packages/react (e.g. alongside the other page components) would keep both roots in sync automatically.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

</main>
);
}

function RootComponent() {
return (
<ExecutorProvider>
Expand Down
1 change: 1 addition & 0 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"./components/*": "./src/components/*.tsx",
"./hooks/*": "./src/hooks/*.ts",
"./lib/auth-placements": "./src/lib/auth-placements.tsx",
"./lib/document-title": "./src/lib/document-title.tsx",
"./lib/integration-add": "./src/lib/integration-add.tsx",
"./lib/*": "./src/lib/*.ts",
"./globals.css": "./src/styles/globals.css",
Expand Down
14 changes: 14 additions & 0 deletions packages/react/src/lib/document-title.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { useEffect } from "react";

const APP_NAME = "Executor";

export function executorDocumentTitle(page: string): string {
return `${page} · ${APP_NAME}`;
}

export function useExecutorDocumentTitle(page: string | null | undefined): void {
useEffect(() => {
if (!page || typeof document === "undefined") return;
document.title = executorDocumentTitle(page);
}, [page]);
Comment on lines +9 to +13

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 document.title is never reset on unmount, so navigating to the 404 page shows the previous page's title

The hook sets document.title on mount but has no cleanup. The NotFoundPage components in both roots don't call useExecutorDocumentTitle, so any user who navigates from a real page to a 404 URL will see the previous page's title persisting in the browser tab (e.g. "Policies · Executor" while the 404 content is shown). Adding a return function that resets to APP_NAME (or calling the hook with a fixed string like "404" inside NotFoundPage) would close this gap.

}
Comment on lines +1 to +14

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 File extension should be .ts, not .tsx

The file contains no JSX — it only imports useEffect and returns void. The .tsx extension enables the JSX transform unnecessarily. Renaming to document-title.ts would keep the extension honest and slightly shorten the generated output.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

35 changes: 24 additions & 11 deletions packages/react/src/multiplayer/org-slug-gate.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,31 @@
import { useEffect, type ReactNode } from "react";
import { useNavigate, useParams } from "@tanstack/react-router";
import { useNavigate, useParams, useRouterState } from "@tanstack/react-router";

// ---------------------------------------------------------------------------
// Org-slug URL canonicalization for org-scoped hosts (cloud, self-host,
// cloudflare). Console routes live under an optional `{-$orgSlug}` segment, so
// the same tree serves `/policies` and `/acme/policies`; this gate, mounted
// inside the authenticated shell, pins a BARE URL to the active org's slug:
// inside the authenticated shell, pins the URL to the active org's slug:
//
// - bare URL → replace with `/<active-slug>/…` (canonicalize)
// - slug already in URL → render
// - bare URL → replace with `/<active-slug>/…` (canonicalize)
// - active slug already in URL → render
// - any other slug in URL → replace with `/<active-slug>/…` (canonicalize)
//
// The URL slug is the request SCOPE, not just a label: every API call carries
// it (the `x-executor-organization` header), and the server re-checks live
// membership and resolves data for that org — same as the MCP URL-pinned org.
// So a foreign slug never reaches this gate as "active": the server returns no
// organization for an org the caller can't see, and the shell 404s upstream.
// That makes two browser tabs on different orgs fully independent — no shared
// "active org" to steal.
// So a foreign slug never reaches this gate as "active" on a multi-org host:
// the server returns no organization for an org the caller can't see, and the
// shell 404s upstream. That makes two browser tabs on different orgs fully
// independent — no shared "active org" to steal. On a single-org host (e.g.
// self-host) every slug resolves to the same org server-side, so a bogus slug
// (e.g. `/totally-bogus`) would otherwise fuzzy-match a route and render
// under the wrong URL forever; canonicalizing it here fixes the URL instead.
//
// A genuinely unmatched path is neither of the above: it has no `orgSlug`
// param (nothing below root matched) but isn't a bare URL either, so it must
// NOT canonicalize — doing so would silently rewrite a bad URL into a valid
// one instead of letting the not-found page render.
// ---------------------------------------------------------------------------

export interface OrgSlugGateProps {
Expand All @@ -31,9 +40,13 @@ export function OrgSlugGate(props: OrgSlugGateProps) {
const urlSlug = params.orgSlug ?? null;
const navigate = useNavigate();

// Only a BARE URL canonicalizes. A slug in the URL is the scope the whole
// request chain already ran with, so it always matches `activeSlug` here.
const needsCanonicalize = urlSlug === null;
// Skip canonicalization whenever the router is currently sitting on a
// not-found match — see the file header for why.
const isNotFound = useRouterState({
select: (state) => state.matches.some((match) => match.globalNotFound),
});

const needsCanonicalize = urlSlug !== activeSlug && !isNotFound;

useEffect(() => {
if (!needsCanonicalize) return;
Expand Down
2 changes: 2 additions & 0 deletions packages/react/src/pages/api-keys.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
} from "../components/dialog";
import { Input } from "../components/input";
import { Label } from "../components/label";
import { useExecutorDocumentTitle } from "../lib/document-title";

// ---------------------------------------------------------------------------
// Shared API-keys page. Reads/writes the provider-neutral `/account/api-keys`
Expand Down Expand Up @@ -58,6 +59,7 @@ const defaultApiKeyName = (): string =>
}).format(new Date())}`;

export function ApiKeysPage() {
useExecutorDocumentTitle("API keys");
const result = useAtomValue(apiKeysAtom);
const doCreate = useAtomSet(createApiKey, { mode: "promiseExit" });
const doRevoke = useAtomSet(revokeApiKey, { mode: "promiseExit" });
Expand Down
2 changes: 2 additions & 0 deletions packages/react/src/pages/integration-add.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Suspense } from "react";
import { Link, useNavigate } from "@tanstack/react-router";
import { useIntegrationPlugins } from "@executor-js/sdk/client";
import { trackEvent } from "../api/analytics";
import { useExecutorDocumentTitle } from "../lib/document-title";

// ---------------------------------------------------------------------------
// Page
Expand All @@ -13,6 +14,7 @@ export function AddIntegrationPage(props: {
preset?: string;
namespace?: string;
}) {
useExecutorDocumentTitle("Add integration");
const { pluginKey, url, preset, namespace } = props;
const navigate = useNavigate();
const integrationPlugins = useIntegrationPlugins();
Expand Down
2 changes: 2 additions & 0 deletions packages/react/src/pages/integration-detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { usePolicyActions } from "../hooks/use-policy-actions";
import { useIntegrationPlugins, type IntegrationAccountHandoff } from "@executor-js/sdk/client";
import { Button } from "../components/button";
import { Skeleton } from "../components/skeleton";
import { useExecutorDocumentTitle } from "../lib/document-title";

// v2: the route's `namespace` param is the integration slug. Tools belong to
// the integration's per-owner connections; a tool's policy id is
Expand Down Expand Up @@ -97,6 +98,7 @@ export function IntegrationDetailPage(props: { namespace: string }) {
}, [namespace]);

const integrationData = AsyncResult.isSuccess(integration) ? integration.value : null;
useExecutorDocumentTitle(integrationData?.name || namespace);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 || coerces empty-string names to the namespace fallback

|| treats an empty string as falsy, so an integration whose name is "" would silently fall back to showing the namespace slug as the document title. Use ?? to only fall back when the name is null/undefined, which is the actual sentinel for "not yet loaded or not present".

Suggested change
useExecutorDocumentTitle(integrationData?.name || namespace);
useExecutorDocumentTitle(integrationData?.name ?? namespace);

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

const isBuiltInIntegration = namespace === "executor" || integrationData?.kind === "built-in";
const currentTab = isBuiltInIntegration ? "tools" : activeTab;
const canRefresh = integrationData?.canRefresh ?? false;
Expand Down
2 changes: 2 additions & 0 deletions packages/react/src/pages/integrations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
} from "../components/integration-favicon";
import { IntegrationIconWithAccount } from "../components/integration-icon-with-account";
import { Skeleton } from "../components/skeleton";
import { useExecutorDocumentTitle } from "../lib/document-title";

const KIND_TO_PLUGIN_KEY: Record<string, string> = {
openapi: "openapi",
Expand All @@ -64,6 +65,7 @@ const bestDetection = (
// ---------------------------------------------------------------------------

export function IntegrationsPage() {
useExecutorDocumentTitle("Integrations");
const integrations = useAtomValue(integrationsOptimisticAtom);
const [connectOpen, setConnectOpen] = useState(false);

Expand Down
2 changes: 2 additions & 0 deletions packages/react/src/pages/org.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
} from "../api/account-atoms";
import { useAuth } from "../multiplayer/auth-context";
import { messageFromExit } from "../api/error-reporting";
import { useExecutorDocumentTitle } from "../lib/document-title";

// ---------------------------------------------------------------------------
// Shared organization page — members + roles + invites + org name, over the
Expand Down Expand Up @@ -138,6 +139,7 @@ export function OrgPage(props: {
// so it never reaches this and can omit it.
upgradeAction?: React.ReactNode;
}) {
useExecutorDocumentTitle("Organization");
const auth = useAuth();
const organizationName =
auth.status === "authenticated" ? (auth.organization?.name ?? "Organization") : "Organization";
Expand Down
2 changes: 2 additions & 0 deletions packages/react/src/pages/policies.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import {
SelectValue,
} from "../components/select";
import { Label } from "../components/label";
import { useExecutorDocumentTitle } from "../lib/document-title";

// Owner guardrail ordering: org rules are the outer guardrail (rank 0), user
// rules are inner (rank 1). Mirrors server-side resolution where the most
Expand Down Expand Up @@ -281,6 +282,7 @@ function PolicyRow(props: {
// ---------------------------------------------------------------------------

export function PoliciesPage() {
useExecutorDocumentTitle("Policies");
const policies = useAtomValue(policiesOptimisticAtom);
const doCreate = useAtomSet(createPolicyOptimistic, { mode: "promiseExit" });
const doUpdate = useAtomSet(updatePolicyOptimistic, { mode: "promiseExit" });
Expand Down
2 changes: 2 additions & 0 deletions packages/react/src/pages/secrets.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
} from "../components/card-stack";
import { Badge } from "../components/badge";
import { PageContainer, PageHeader } from "../components/page";
import { useExecutorDocumentTitle } from "../lib/document-title";

// ---------------------------------------------------------------------------
// Providers page (v2) — repurposed from the v1 Secrets page.
Expand All @@ -41,6 +42,7 @@ const PROVIDER_LABELS: Record<string, string> = {
const providerLabel = (key: string): string => PROVIDER_LABELS[key] ?? key;

export function SecretsPage(props: { showProviderInfo?: boolean }) {
useExecutorDocumentTitle("Providers");
const showProviderInfo = props.showProviderInfo ?? true;
const secretProviderPlugins = useSecretProviderPlugins();
const providers = useAtomValue(providersAtom);
Expand Down
2 changes: 2 additions & 0 deletions packages/react/src/pages/tools.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { ToolTree, type ToolSummary } from "../components/tool-tree";
import { ToolDetail, ToolDetailEmpty } from "../components/tool-detail";
import { Button } from "../components/button";
import { Skeleton } from "../components/skeleton";
import { useExecutorDocumentTitle } from "../lib/document-title";

// Dynamic tool policy patterns are derived from the connection-aware address.
// Static tools (for example Executor's own tools) use their address directly.
Expand All @@ -26,6 +27,7 @@ const policyId = (tool: ToolRow): string =>
tool.static ? String(tool.address) : `${tool.integration}.${tool.name}`;

export function ToolsPage() {
useExecutorDocumentTitle("Tools");
// Merged across BOTH owners (omit-owner read). The page dedupes to one row per
// `<integration>.<tool>` policy id, so owner is irrelevant here — the global
// Tools page is a flat policy tree, not an account-grouped view. Policy writes
Expand Down
2 changes: 2 additions & 0 deletions packages/react/src/routes/resume.$executionId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { createFileRoute } from "@tanstack/react-router";
import { ResumeApprovalPage, ResumeApprovalPageView } from "../pages/resume-approval";
import { getExecutorServerAuthorizationHeader } from "../api/server-connection";
import type { ElicitationAction } from "../components/elicitation-approval";
import { useExecutorDocumentTitle } from "../lib/document-title";

const SearchParams = Schema.toStandardSchemaV1(
Schema.Struct({
Expand Down Expand Up @@ -143,6 +144,7 @@ export const Route = createFileRoute("/{-$orgSlug}/resume/$executionId")({
});

function RouteComponent() {
useExecutorDocumentTitle("Resume");
const { executionId } = Route.useParams();
const { mcp_session_id: mcpSessionId } = Route.useSearch();
if (mcpSessionId) {
Expand Down
2 changes: 2 additions & 0 deletions packages/react/src/routes/toolkits.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { createFileRoute, useParams } from "@tanstack/react-router";

import { ToolkitsPluginRoute } from "./toolkits-route";
import { useExecutorDocumentTitle } from "../lib/document-title";

export const Route = createFileRoute("/{-$orgSlug}/toolkits")({
component: ToolkitsRouteComponent,
});

function ToolkitsRouteComponent() {
useExecutorDocumentTitle("Toolkits");
const { toolkitSlug } = useParams({ strict: false }) as { toolkitSlug?: string };
return <ToolkitsPluginRoute toolkitSlug={toolkitSlug} />;
}
Loading