diff --git a/templates/content/app/components/editor/database/sidebar.test.tsx b/templates/content/app/components/editor/database/sidebar.test.tsx
index 4e48347927..f76877c6ac 100644
--- a/templates/content/app/components/editor/database/sidebar.test.tsx
+++ b/templates/content/app/components/editor/database/sidebar.test.tsx
@@ -840,4 +840,121 @@ describe("DatabaseSidebarView", () => {
expect(markup).toContain("group-hover:pointer-events-auto");
expect(markup).not.toContain("shadow-sm");
});
+
+ it("keeps the viewer add-child slot disabled beside the personal pin action", async () => {
+ const container = document.createElement("div");
+ document.body.append(container);
+ const root = createRoot(container);
+ const onToggleFavorite = vi.fn();
+ const onCreateChildPage = vi.fn();
+ const onCreateChildDatabase = vi.fn();
+
+ await act(async () => {
+ root.render(
+
+
+ {}}
+ onPreview={() => {}}
+ onCreateChildPage={onCreateChildPage}
+ onCreateChildDatabase={onCreateChildDatabase}
+ onDeleteItem={() => {}}
+ onToggleFavorite={onToggleFavorite}
+ />
+
+ ,
+ );
+ });
+
+ const trigger = container.querySelector(
+ 'button[aria-label="More actions for Shared page"]',
+ );
+ expect(trigger).toBeTruthy();
+ expect(
+ container.querySelectorAll('button[aria-haspopup="menu"]'),
+ ).toHaveLength(1);
+ const addChild = container.querySelector(
+ 'button[aria-label="Add child to Shared page"]',
+ );
+ if (!trigger || !addChild) {
+ throw new Error("Expected aligned viewer sidebar controls");
+ }
+ expect(addChild.disabled).toBe(true);
+ expect(addChild.className).toContain("size-6");
+ expect(addChild.className).toContain("text-muted-foreground/50");
+ expect(trigger.compareDocumentPosition(addChild)).toBe(
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ );
+
+ addChild.focus();
+ addChild.click();
+ addChild.dispatchEvent(
+ new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }),
+ );
+ addChild.dispatchEvent(
+ new KeyboardEvent("keydown", { bubbles: true, key: " " }),
+ );
+ expect(document.activeElement).not.toBe(addChild);
+ expect(onCreateChildPage).not.toHaveBeenCalled();
+ expect(onCreateChildDatabase).not.toHaveBeenCalled();
+
+ await act(async () => {
+ trigger.dispatchEvent(
+ new PointerEvent("pointerdown", {
+ bubbles: true,
+ button: 0,
+ pointerType: "mouse",
+ }),
+ );
+ await Promise.resolve();
+ });
+
+ const menuItems = Array.from(
+ document.querySelectorAll("[role=menuitem]"),
+ );
+ expect(menuItems.map((menuItem) => menuItem.textContent?.trim())).toEqual([
+ "Pin to sidebar",
+ ]);
+
+ await act(async () => {
+ menuItems[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ await Promise.resolve();
+ });
+ expect(onToggleFavorite).toHaveBeenCalledOnce();
+ expect(onToggleFavorite).toHaveBeenCalledWith(
+ expect.objectContaining({
+ document: expect.objectContaining({ id: "shared" }),
+ }),
+ );
+
+ act(() => root.unmount());
+ container.remove();
+ });
});
diff --git a/templates/content/app/components/editor/database/sidebar.tsx b/templates/content/app/components/editor/database/sidebar.tsx
index 7632325553..097495a24b 100644
--- a/templates/content/app/components/editor/database/sidebar.tsx
+++ b/templates/content/app/components/editor/database/sidebar.tsx
@@ -21,6 +21,7 @@ import {
import { useEffect, useState, type MouseEvent, type ReactNode } from "react";
import { Link } from "react-router";
+import { documentSidebarActionAvailability } from "@/components/sidebar/document-sidebar-actions";
import { Button } from "@/components/ui/button";
import {
Collapsible,
@@ -717,14 +718,12 @@ function DatabaseSidebarRow({
};
}) {
const t = useT();
- const canEdit = item.document.canEdit !== false;
- const canManage =
- item.document.canManage === true ||
- item.document.accessRole === "owner" ||
- item.document.accessRole === "admin";
+ const { canEdit, canManage, canFavorite, hasMenuActions } =
+ documentSidebarActionAvailability(item.document, {
+ favoriteAvailable: Boolean(onToggleFavorite),
+ manageAvailable: Boolean(onDeleteItem),
+ });
const canCreateChild = canEdit && Boolean(onCreateChildPage);
- const hasMenuActions =
- Boolean(onToggleFavorite) || (canManage && Boolean(onDeleteItem));
function handleClick(event: MouseEvent) {
if (
event.defaultPrevented ||
@@ -828,7 +827,7 @@ function DatabaseSidebarRow({
- {onToggleFavorite ? (
+ {canFavorite && onToggleFavorite ? (
onToggleFavorite(item)}>
) : null}
- {onToggleFavorite && canManage && onDeleteItem ? (
+ {canFavorite &&
+ onToggleFavorite &&
+ canManage &&
+ onDeleteItem ? (
) : null}
{canManage && onDeleteItem ? (
@@ -857,7 +859,7 @@ function DatabaseSidebarRow({
)}
- {canCreateChild && (
+ {canCreateChild ? (
@@ -866,6 +868,7 @@ function DatabaseSidebarRow({
type="button"
className="flex size-6 items-center justify-center rounded text-foreground hover:bg-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={t("sidebar.addChildTo", { title })}
+ data-sidebar-add-child
>
@@ -888,6 +891,16 @@ function DatabaseSidebarRow({
) : null}
+ ) : (
+
)}
)}
diff --git a/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts b/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts
index 48e93c472f..a4705859bb 100644
--- a/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts
+++ b/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts
@@ -74,9 +74,9 @@ describe("document sidebar layout", () => {
it("gates page tree actions by document capabilities", () => {
const treeItem = readSidebarSource("./DocumentTreeItem.tsx");
- expect(treeItem).toContain("const canEdit = node.canEdit !== false");
- expect(treeItem).toContain("const canManage =");
- expect(treeItem).toContain("{canEdit && (");
+ expect(treeItem).toContain("favoriteAvailable: true");
+ expect(treeItem).toContain("{canFavorite && (");
+ expect(treeItem).toContain("const canCreateChild = canEdit");
expect(treeItem).toContain("{canManage && (");
});
diff --git a/templates/content/app/components/sidebar/DocumentTreeItem.test.tsx b/templates/content/app/components/sidebar/DocumentTreeItem.test.tsx
new file mode 100644
index 0000000000..f5c1b6f9fb
--- /dev/null
+++ b/templates/content/app/components/sidebar/DocumentTreeItem.test.tsx
@@ -0,0 +1,247 @@
+// @vitest-environment happy-dom
+
+import { AgentNativeI18nProvider } from "@agent-native/core/client/i18n";
+import type {
+ Document,
+ DocumentAccessRole,
+ DocumentTreeNode,
+} from "@shared/api";
+import { act, type ReactNode } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { MemoryRouter } from "react-router";
+import { describe, expect, it, vi } from "vitest";
+
+import { TooltipProvider } from "@/components/ui/tooltip";
+
+import { DocumentTreeItem } from "./DocumentTreeItem";
+
+const { useSortableMock } = vi.hoisted(() => ({
+ useSortableMock: vi.fn(() => ({
+ attributes: {},
+ listeners: {},
+ setNodeRef: vi.fn(),
+ transform: null,
+ transition: undefined,
+ isDragging: false,
+ })),
+}));
+
+vi.mock("@dnd-kit/sortable", async (importOriginal) => ({
+ ...(await importOriginal()),
+ useSortable: useSortableMock,
+}));
+
+vi.mock("@agent-native/creative-context/client", () => ({
+ CreativeContextShareSheet: () => null,
+}));
+
+function documentForRole(
+ accessRole: DocumentAccessRole,
+ isFavorite = false,
+): Document {
+ return {
+ id: "shared",
+ parentId: null,
+ title: "Shared page",
+ content: "",
+ icon: null,
+ position: 0,
+ isFavorite,
+ hideFromSearch: false,
+ accessRole,
+ canEdit: accessRole !== "viewer",
+ canManage: accessRole === "owner" || accessRole === "admin",
+ createdAt: "2026-01-01T00:00:00.000Z",
+ updatedAt: "2026-01-01T00:00:00.000Z",
+ };
+}
+
+async function render(node: ReactNode) {
+ const container = document.createElement("div");
+ document.body.append(container);
+ const root = createRoot(container);
+ (
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
+ ).IS_REACT_ACT_ENVIRONMENT = true;
+
+ await act(async () => {
+ root.render(
+
+
+ {node}
+
+ ,
+ );
+ });
+
+ return { container, root };
+}
+
+function cleanup(root: Root, container: HTMLElement) {
+ act(() => root.unmount());
+ container.remove();
+ document.querySelectorAll("[role=menu]").forEach((menu) => menu.remove());
+ (
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
+ ).IS_REACT_ACT_ENVIRONMENT = false;
+}
+
+async function openActions(container: HTMLElement) {
+ const trigger = container.querySelector(
+ 'button[aria-label="More actions for Shared page"]',
+ );
+ expect(trigger).toBeTruthy();
+
+ await act(async () => {
+ trigger?.dispatchEvent(
+ new PointerEvent("pointerdown", {
+ bubbles: true,
+ button: 0,
+ pointerType: "mouse",
+ }),
+ );
+ await Promise.resolve();
+ });
+
+ return Array.from(document.querySelectorAll("[role=menuitem]"));
+}
+
+function treeItem(
+ document: Document,
+ onToggleFavorite: (id: string, isFavorite: boolean) => void = () => {},
+ onCreateChildPage: (id: string) => void = () => {},
+ onCreateChildDatabase: (id: string) => void = () => {},
+) {
+ return (
+ {}}
+ onSelect={() => {}}
+ onCreateChildPage={onCreateChildPage}
+ onCreateChildDatabase={onCreateChildDatabase}
+ onDelete={() => {}}
+ onToggleFavorite={onToggleFavorite}
+ />
+ );
+}
+
+describe("sidebar document permission menus", () => {
+ it("keeps the tree-row add-child slot disabled beside Pin", async () => {
+ const onToggleFavorite = vi.fn();
+ const onCreateChildPage = vi.fn();
+ const onCreateChildDatabase = vi.fn();
+ const { container, root } = await render(
+ treeItem(
+ documentForRole("viewer"),
+ onToggleFavorite,
+ onCreateChildPage,
+ onCreateChildDatabase,
+ ),
+ );
+
+ const moreActions = container.querySelector(
+ 'button[aria-label="More actions for Shared page"]',
+ );
+ const addChild = container.querySelector(
+ 'button[aria-label="Add child to Shared page"]',
+ );
+ if (!moreActions || !addChild) {
+ throw new Error("Expected aligned viewer sidebar controls");
+ }
+ expect(addChild.disabled).toBe(true);
+ expect(addChild.className).toContain("h-7 w-7");
+ expect(addChild.className).toContain("text-muted-foreground/50");
+ expect(moreActions.compareDocumentPosition(addChild)).toBe(
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ );
+ expect(
+ container.querySelectorAll('button[aria-haspopup="menu"]'),
+ ).toHaveLength(1);
+ expect(
+ container.querySelector('[aria-label="Shared page"]')?.className,
+ ).not.toContain("cursor-grab");
+ expect(useSortableMock).toHaveBeenLastCalledWith({
+ id: "shared",
+ disabled: true,
+ });
+ addChild.focus();
+ addChild.click();
+ addChild.dispatchEvent(
+ new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }),
+ );
+ addChild.dispatchEvent(
+ new KeyboardEvent("keydown", { bubbles: true, key: " " }),
+ );
+ expect(document.activeElement).not.toBe(addChild);
+ expect(onCreateChildPage).not.toHaveBeenCalled();
+ expect(onCreateChildDatabase).not.toHaveBeenCalled();
+
+ const menuItems = await openActions(container);
+ expect(menuItems.map((item) => item.textContent?.trim())).toEqual([
+ "Pin to sidebar",
+ ]);
+
+ await act(async () => {
+ menuItems[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ await Promise.resolve();
+ });
+ expect(onToggleFavorite).toHaveBeenCalledOnce();
+ expect(onToggleFavorite).toHaveBeenCalledWith("shared", true);
+
+ cleanup(root, container);
+ });
+
+ it.each([
+ ["editor", ["Pin to sidebar", "Add to context"], false],
+ ["admin", ["Pin to sidebar", "Add to context", "Delete"], true],
+ ["owner", ["Pin to sidebar", "Add to context", "Delete"], true],
+ ] as const)(
+ "preserves the existing %s tree actions",
+ async (role, expectedMenuItems, canManage) => {
+ const { container, root } = await render(treeItem(documentForRole(role)));
+
+ expect(
+ container.querySelectorAll('button[aria-haspopup="menu"]'),
+ ).toHaveLength(2);
+ expect(
+ container.querySelector('[aria-label="Shared page"]')?.className,
+ ).toContain("cursor-grab");
+ expect(useSortableMock).toHaveBeenLastCalledWith({
+ id: "shared",
+ disabled: false,
+ });
+
+ const menuItems = await openActions(container);
+ expect(
+ menuItems.map((item) => item.textContent?.trim().replace(/[.…]+$/, "")),
+ ).toEqual(expectedMenuItems);
+ expect(menuItems.some((item) => item.textContent === "Delete")).toBe(
+ canManage,
+ );
+
+ cleanup(root, container);
+ },
+ );
+});
diff --git a/templates/content/app/components/sidebar/DocumentTreeItem.tsx b/templates/content/app/components/sidebar/DocumentTreeItem.tsx
index 5f803ddcc7..f175f2a0b3 100644
--- a/templates/content/app/components/sidebar/DocumentTreeItem.tsx
+++ b/templates/content/app/components/sidebar/DocumentTreeItem.tsx
@@ -33,6 +33,8 @@ import {
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
+import { documentSidebarActionAvailability } from "./document-sidebar-actions";
+
interface DocumentTreeItemProps {
node: DocumentTreeNode;
depth: number;
@@ -97,12 +99,8 @@ export function DocumentTreeItem({
const isActive = node.id === activeId;
const isLocalFileNode = node.source?.mode === "local-files";
const isLocalFolder = isLocalFileNode && node.source?.kind === "folder";
- const canEdit = node.canEdit !== false;
- const canManage =
- node.canManage === true ||
- node.accessRole === "owner" ||
- node.accessRole === "admin";
- const hasMenuActions = canEdit || canManage;
+ const { canEdit, canManage, canFavorite, hasMenuActions } =
+ documentSidebarActionAvailability(node, { favoriteAvailable: true });
const canCreateChild = canEdit && !isLocalFileNode;
const [contextSheetOpen, setContextSheetOpen] = useState(false);
const indent = depth * 12 + 12;
@@ -224,7 +222,7 @@ export function DocumentTreeItem({
- {canEdit && (
+ {canFavorite && (
{
e.stopPropagation();
@@ -240,7 +238,7 @@ export function DocumentTreeItem({
: t("sidebar.pinToSidebar")}
)}
- {canEdit && canManage && }
+ {canFavorite && canManage && }
{canEdit && !isLocalFileNode && (
{
@@ -269,7 +267,7 @@ export function DocumentTreeItem({
)}
- {canCreateChild && (
+ {canCreateChild ? (
@@ -280,6 +278,7 @@ export function DocumentTreeItem({
aria-label={t("sidebar.addChildTo", {
title: node.title || t("sidebar.untitled"),
})}
+ data-sidebar-add-child
onClick={(e) => e.stopPropagation()}
>
@@ -309,6 +308,18 @@ export function DocumentTreeItem({
+ ) : (
+
)}
diff --git a/templates/content/app/components/sidebar/document-sidebar-actions.test.ts b/templates/content/app/components/sidebar/document-sidebar-actions.test.ts
new file mode 100644
index 0000000000..7ee7376b40
--- /dev/null
+++ b/templates/content/app/components/sidebar/document-sidebar-actions.test.ts
@@ -0,0 +1,68 @@
+import type { Document } from "@shared/api";
+import { describe, expect, it } from "vitest";
+
+import { documentSidebarActionAvailability } from "./document-sidebar-actions";
+
+function access(
+ accessRole: NonNullable,
+): Pick {
+ return {
+ accessRole,
+ canEdit: accessRole !== "viewer",
+ canManage: accessRole === "owner" || accessRole === "admin",
+ };
+}
+
+describe("document sidebar action availability", () => {
+ it("gives viewers only the personal Favorite menu action", () => {
+ expect(
+ documentSidebarActionAvailability(access("viewer"), {
+ favoriteAvailable: true,
+ }),
+ ).toEqual({
+ canEdit: false,
+ canManage: false,
+ canFavorite: true,
+ hasMenuActions: true,
+ });
+ });
+
+ it.each(["editor", "admin", "owner"] as const)(
+ "preserves the existing %s capabilities",
+ (role) => {
+ expect(
+ documentSidebarActionAvailability(access(role), {
+ favoriteAvailable: true,
+ }),
+ ).toEqual({
+ canEdit: true,
+ canManage: role === "admin" || role === "owner",
+ canFavorite: true,
+ hasMenuActions: true,
+ });
+ },
+ );
+
+ it("does not invent a menu when no Favorite callback or shared action exists", () => {
+ expect(
+ documentSidebarActionAvailability(access("viewer"), {
+ favoriteAvailable: false,
+ }),
+ ).toMatchObject({
+ canFavorite: false,
+ hasMenuActions: false,
+ });
+ });
+
+ it("does not show an empty management menu without a delete callback", () => {
+ expect(
+ documentSidebarActionAvailability(access("owner"), {
+ favoriteAvailable: false,
+ manageAvailable: false,
+ }),
+ ).toMatchObject({
+ canManage: true,
+ hasMenuActions: false,
+ });
+ });
+});
diff --git a/templates/content/app/components/sidebar/document-sidebar-actions.ts b/templates/content/app/components/sidebar/document-sidebar-actions.ts
new file mode 100644
index 0000000000..5433ac0e8c
--- /dev/null
+++ b/templates/content/app/components/sidebar/document-sidebar-actions.ts
@@ -0,0 +1,31 @@
+import type { Document } from "@shared/api";
+
+type SidebarDocumentAccess = Pick<
+ Document,
+ "accessRole" | "canEdit" | "canManage"
+>;
+
+export function documentSidebarActionAvailability(
+ document: SidebarDocumentAccess,
+ {
+ favoriteAvailable,
+ manageAvailable = true,
+ }: {
+ favoriteAvailable: boolean;
+ manageAvailable?: boolean;
+ },
+) {
+ const canEdit = document.canEdit !== false;
+ const canManage =
+ document.canManage === true ||
+ document.accessRole === "owner" ||
+ document.accessRole === "admin";
+ const canFavorite = favoriteAvailable;
+
+ return {
+ canEdit,
+ canManage,
+ canFavorite,
+ hasMenuActions: canFavorite || (canManage && manageAvailable),
+ };
+}
diff --git a/templates/content/changelog/2026-07-26-viewers-can-favorite-shared-pages.md b/templates/content/changelog/2026-07-26-viewers-can-favorite-shared-pages.md
new file mode 100644
index 0000000000..d54f47232f
--- /dev/null
+++ b/templates/content/changelog/2026-07-26-viewers-can-favorite-shared-pages.md
@@ -0,0 +1,6 @@
+---
+type: fixed
+date: 2026-07-26
+---
+
+View-only collaborators can pin or unpin shared pages while the unavailable add-child control stays aligned and visibly disabled.