@@ -486,25 +522,19 @@ function ClipsEmbedConfigurator({
const isPublic = visibility === "public";
const canManage =
data?.role === "owner" || data?.role === "admin" || !data?.role;
- const setVisibility = useActionMutation("set-resource-visibility");
- const makePublic = () =>
- setVisibility.mutate(
- {
- resourceType: "recording",
- resourceId: recordingId,
- visibility: "public",
- },
- { onSuccess: () => sharesQuery.refetch() },
- );
+ const { setRecordingVisibility, isPending } = useRecordingVisibilityMutation(
+ recordingId,
+ sharesQuery,
+ );
+ const makePublic = () => setRecordingVisibility("public");
- const origin = typeof window !== "undefined" ? window.location.origin : "";
const src = useMemo(() => {
const params: string[] = [];
if (autoplay) params.push("autoplay=1");
if (startMs > 0) params.push(`t=${Math.round(startMs / 1000)}`);
const qs = params.length ? `?${params.join("&")}` : "";
- return `${origin}/embed/${recordingId}${qs}`;
- }, [origin, recordingId, autoplay, startMs]);
+ return absoluteAppUrl(`/embed/${recordingId}${qs}`);
+ }, [recordingId, autoplay, startMs]);
const code =
mode === "responsive"
@@ -529,9 +559,9 @@ function ClipsEmbedConfigurator({
size="sm"
className="mt-2 h-7"
onClick={makePublic}
- disabled={setVisibility.isPending}
+ disabled={isPending}
>
- {setVisibility.isPending ? "Making public…" : "Make public"}
+ {isPending ? "Making public…" : "Make public"}
) : (
@@ -612,10 +642,19 @@ function ClipsEmbedConfigurator({
// Primitives
// ---------------------------------------------------------------------------
-function CopyField({ label, value }: { label: string; value: string }) {
+function CopyField({
+ label,
+ value,
+ disabled,
+}: {
+ label: string;
+ value: string;
+ disabled?: boolean;
+}) {
const [copied, setCopied] = useState(false);
const copy = () => {
- navigator.clipboard.writeText(value).catch(() => {});
+ if (disabled) return;
+ copyToClipboard(value);
setCopied(true);
setTimeout(() => setCopied(false), 1400);
};
@@ -636,6 +675,7 @@ function CopyField({ label, value }: { label: string; value: string }) {
size="icon"
onClick={copy}
aria-label="Copy"
+ disabled={disabled}
className="h-9 w-9"
>
{copied ? : }
@@ -645,6 +685,54 @@ function CopyField({ label, value }: { label: string; value: string }) {
);
}
+function useRecordingVisibilityMutation(
+ recordingId: string,
+ sharesQuery: ReturnType>,
+) {
+ const queryClient = useQueryClient();
+ const setVisibility = useActionMutation("set-resource-visibility");
+ const shareQueryKey = useMemo(
+ () =>
+ [
+ "action",
+ "list-resource-shares",
+ { resourceType: "recording", resourceId: recordingId },
+ ] as const,
+ [recordingId],
+ );
+
+ const setRecordingVisibility = (
+ next: Visibility,
+ options?: { onSuccess?: () => void },
+ ) => {
+ const previous = queryClient.getQueryData(shareQueryKey);
+ queryClient.setQueryData(shareQueryKey, (current) =>
+ current ? { ...current, visibility: next } : current,
+ );
+ setVisibility.mutate(
+ {
+ resourceType: "recording",
+ resourceId: recordingId,
+ visibility: next,
+ } as any,
+ {
+ onSuccess: () => {
+ void sharesQuery.refetch().finally(() => options?.onSuccess?.());
+ },
+ onError: () => {
+ if (previous) {
+ queryClient.setQueryData(shareQueryKey, previous);
+ } else {
+ queryClient.invalidateQueries({ queryKey: shareQueryKey });
+ }
+ },
+ },
+ );
+ };
+
+ return { setRecordingVisibility, isPending: setVisibility.isPending };
+}
+
function Avatar({ label, org }: { label: string; org?: boolean }) {
return (
();
const navigate = useNavigate();
const { session } = useSession();
diff --git a/templates/content/server/lib/public-documents.ts b/templates/content/server/lib/public-documents.ts
index 1c6fa0e1c9..3c24c972df 100644
--- a/templates/content/server/lib/public-documents.ts
+++ b/templates/content/server/lib/public-documents.ts
@@ -4,12 +4,28 @@ import { randomUUID } from "node:crypto";
const PUBLIC_VIEWER_COOKIE = "content_public_viewer";
const PUBLIC_VIEWER_COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
+function getAppOrigin(event: H3Event): string | null {
+ const proto =
+ getHeader(event, "x-forwarded-proto") ??
+ (getHeader(event, "origin")?.startsWith("https://") ? "https" : "http");
+ const host = getHeader(event, "x-forwarded-host") ?? getHeader(event, "host");
+ if (!host) return null;
+ return `${proto}://${host}`;
+}
+
function publicDocumentIdFromEvent(event: H3Event): string | null {
const referrer = getHeader(event, "referer");
if (!referrer) return null;
try {
const url = new URL(referrer);
+ // Reject off-origin referers — without this an attacker hosting a
+ // page at evil.com/p/ could trick same-site requests into
+ // minting an anonymous-viewer identity scoped to a doc the user
+ // never opened. The lax-cookie protections we rely on assume the
+ // referer-derived doc context is same-origin.
+ const appOrigin = getAppOrigin(event);
+ if (appOrigin && url.origin !== appOrigin) return null;
const match = url.pathname.match(/(?:^|\/)p\/([^/?#]+)/);
return match?.[1] ? decodeURIComponent(match[1]) : null;
} catch {
@@ -47,12 +63,18 @@ export async function resolvePublicViewerOwner(
let viewerId = getCookie(event, PUBLIC_VIEWER_COOKIE);
if (!doc) {
- const path = event.node?.req?.url ?? event.path ?? "";
- if (
- path.includes("/_agent-native/builder/callback") &&
- viewerId &&
- /^[0-9a-f-]{36}$/i.test(viewerId)
- ) {
+ // OAuth callbacks return with Referer set to the OAuth provider, not
+ // /p/. To still resolve an anonymous owner for the callback we
+ // accept the viewer cookie when the request path is exactly the
+ // builder callback. The pending-connect row written by /builder/connect
+ // (which DID require a /p/ Referer) is the gate that prevents
+ // arbitrary callback hits from completing.
+ const rawPath = event.node?.req?.url ?? event.path ?? "";
+ const pathOnly = rawPath.split("?")[0]?.split("#")[0] ?? "";
+ const isBuilderCallback =
+ pathOnly === "/_agent-native/builder/callback" ||
+ pathOnly.endsWith("/_agent-native/builder/callback");
+ if (isBuilderCallback && viewerId && /^[0-9a-f-]{36}$/i.test(viewerId)) {
return `public-${viewerId}@agent-native.local`;
}
return null;
diff --git a/templates/mail/app/components/layout/AppLayout.tsx b/templates/mail/app/components/layout/AppLayout.tsx
index fd1fce765a..2ca7e5025f 100644
--- a/templates/mail/app/components/layout/AppLayout.tsx
+++ b/templates/mail/app/components/layout/AppLayout.tsx
@@ -321,7 +321,15 @@ function AppLayoutInner({ children }: AppLayoutProps) {
}
}
const threadRows = [...threadState.values()];
- counts["inbox"] = threadRows.filter((row) => row.hasUnread).length;
+ const hasPinnedFilters = pinnedLabels.some(
+ (id) => !collapsibleViews.some((v) => v.id === id),
+ );
+ counts["inbox"] = threadRows.filter(
+ ({ latest, hasUnread }) =>
+ hasUnread &&
+ (!hasPinnedFilters ||
+ !latest.labelIds.some((lid) => pinnedShorts.includes(lid))),
+ ).length;
// Count threads per pinned label: latest message must have that label
// For "important", exclude threads that belong to any other pinned tab
for (let i = 0; i < pinnedLabels.length; i++) {
@@ -346,8 +354,12 @@ function AppLayoutInner({ children }: AppLayoutProps) {
return counts;
}, [inboxEmails, pinnedLabels, activeAccounts]);
- // Full pinned navigation used by the left sidebar. The top bar intentionally
- // renders only primary/system views so nested labels do not crowd it.
+ // Tabs to show in the bar: pinned triage filters first, then the inbox
+ // remainder as "Other". Without pinned filters, the inbox is just "Inbox".
+ const hasPinnedFilters = pinnedLabels.some(
+ (id) => !collapsibleViews.some((v) => v.id === id),
+ );
+
const visibleTabs = useMemo(() => {
const tabs: {
id: string;
@@ -360,13 +372,15 @@ function AppLayoutInner({ children }: AppLayoutProps) {
type: "system" | "label";
}[] = [];
- tabs.push({
- id: "inbox",
- label: "Inbox",
- href: "/inbox",
- isActive: view === "inbox" && !activeLabel,
- type: "system",
- });
+ if (!hasPinnedFilters) {
+ tabs.push({
+ id: "inbox",
+ label: "Inbox",
+ href: "/inbox",
+ isActive: view === "inbox" && !activeLabel,
+ type: "system",
+ });
+ }
const seenLabels = new Set(["inbox"]);
for (const id of pinnedLabels) {
@@ -417,25 +431,39 @@ function AppLayoutInner({ children }: AppLayoutProps) {
}
}
+ if (hasPinnedFilters) {
+ tabs.push({
+ id: "inbox",
+ label: "Other",
+ href: "/inbox",
+ isActive: view === "inbox" && !activeLabel,
+ type: "system",
+ });
+ }
+
return tabs;
- }, [labels, pinnedLabels, labelAliases, view, activeLabel]);
+ }, [labels, pinnedLabels, labelAliases, view, activeLabel, hasPinnedFilters]);
const topBarTabs = useMemo(() => {
- const primaryIds = new Set(["inbox", ...collapsibleViews.map((v) => v.id)]);
- const tabs = visibleTabs.filter(
- (tab) => tab.type === "system" && primaryIds.has(tab.id),
- );
- if (activeLabel) {
- const active = visibleTabs.find((tab) => tab.id === activeLabel);
+ const tabs = [...visibleTabs];
+ if (activeLabel && !tabs.some((tab) => tab.id === activeLabel)) {
+ const active = labels.find((label) => label.id === activeLabel);
if (active) {
+ const aliasedName =
+ labelAliases[active.id] || shortLabelName(active.name);
tabs.push({
- ...active,
- label: shortLabelName(active.fullLabel ?? active.label),
+ id: active.id,
+ label: aliasedName,
+ fullLabel: active.name,
+ href: `/inbox?label=${encodeURIComponent(active.id)}`,
+ isActive: true,
+ color: active.color,
+ type: "label",
});
}
}
return tabs;
- }, [activeLabel, visibleTabs]);
+ }, [activeLabel, labels, labelAliases, visibleTabs]);
// System views NOT pinned (go in the "more" dropdown)
const hiddenViews = useMemo(
diff --git a/templates/mail/app/pages/InboxPage.tsx b/templates/mail/app/pages/InboxPage.tsx
index ce724ef0c0..a9f8057c50 100644
--- a/templates/mail/app/pages/InboxPage.tsx
+++ b/templates/mail/app/pages/InboxPage.tsx
@@ -312,6 +312,25 @@ export function InboxPage() {
);
return filtered.filter((e) => qualifiedThreadIds.has(e.threadId || e.id));
}
+ if (!searchQuery && view === "inbox" && pinnedUserLabels.length > 0) {
+ // "Other" is the inbox remainder: messages that do not belong to one of
+ // the pinned triage labels.
+ const pinnedShortNames = pinnedUserLabels.map((l) =>
+ l.includes("/")
+ ? l
+ .slice(l.lastIndexOf("/") + 1)
+ .replace(/_/g, " ")
+ .toLowerCase()
+ : l.toLowerCase(),
+ );
+ return filtered.filter(
+ (e) =>
+ !e.labelIds.some(
+ (lid) =>
+ pinnedUserLabels.includes(lid) || pinnedShortNames.includes(lid),
+ ),
+ );
+ }
return filtered;
}, [
rawEmails,
diff --git a/templates/mail/app/routes/_index.tsx b/templates/mail/app/routes/_index.tsx
index 306a1a1e2b..358236d50e 100644
--- a/templates/mail/app/routes/_index.tsx
+++ b/templates/mail/app/routes/_index.tsx
@@ -18,14 +18,14 @@ export function meta() {
* `No routes matched location "/inbox"` because the navigation fired during
* hydration, before the route tree was fully attached. A `loader` runs as
* part of the server response and the navigation completes before the app
- * hydrates.
+ * hydrates. The app opens to the Important triage tab by default.
*/
export function loader() {
- throw redirect("/inbox");
+ throw redirect("/inbox?label=important");
}
export function clientLoader() {
- throw redirect("/inbox");
+ throw redirect("/inbox?label=important");
}
export function HydrateFallback() {
@@ -37,6 +37,6 @@ export function HydrateFallback() {
}
export default function IndexRoute() {
- // Should never render — both loader and clientLoader redirect to /inbox.
+ // Should never render — both loaders redirect to the default triage tab.
return null;
}