Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
4c381f9
Add personal sidebar sections and exact View recency
3mdistal Sep 9, 2026
27987ab
fix(content): preserve personal navigation during sidebar reordering
3mdistal Sep 9, 2026
09b9d59
fix(content): resolve default recent views and retain reorder focus
3mdistal Sep 9, 2026
c1c1cbe
docs: record sidebar navigation verification and remaining gates
3mdistal Sep 9, 2026
ffb7af8
fix(content): satisfy sidebar lint and parity checks
3mdistal Sep 9, 2026
223d290
test(content): check pin glyphs on command components
3mdistal Sep 10, 2026
d398f29
Improve Content sidebar navigation and scaling
3mdistal Sep 14, 2026
d31e805
Scope Content sidebar navigation to the selected space
3mdistal Sep 18, 2026
666ce31
Refine Content workspace navigation
3mdistal Sep 18, 2026
86bb050
Merge main into Content sidebar navigation
3mdistal Sep 18, 2026
f1fd518
Polish Content sidebar hierarchy
3mdistal Sep 20, 2026
76808e3
Merge remote-tracking branch 'origin/main' into codex/content-sidebar…
3mdistal Sep 20, 2026
b920a29
Merge remote-tracking branch 'origin/main' into codex/content-sidebar…
3mdistal Sep 20, 2026
3741658
fix(content): finish sidebar navigation integration
3mdistal Sep 20, 2026
1293391
Merge remote-tracking branch 'origin/main' into codex/content-sidebar…
3mdistal Sep 20, 2026
3fa8720
fix(content): keep sidebar routes preview-compatible
3mdistal Sep 20, 2026
4e64d1b
Merge remote-tracking branch 'origin/main' into codex/content-sidebar…
3mdistal Sep 21, 2026
fa67df7
fix(content): validate Files navigation parents
3mdistal Sep 21, 2026
030999a
Merge remote-tracking branch 'origin/main' into codex/content-sidebar…
3mdistal Sep 21, 2026
51c3c3f
refactor(content): keep navigation transactions app-owned
3mdistal Sep 21, 2026
99f883f
Merge remote-tracking branch 'origin/main' into codex/content-sidebar…
3mdistal Sep 21, 2026
38e4fe9
Merge remote-tracking branch 'origin/main' into codex/content-sidebar…
3mdistal Sep 21, 2026
bea3cf2
Merge remote-tracking branch 'origin/main' into codex/content-sidebar…
3mdistal Sep 21, 2026
35070dd
Merge remote-tracking branch 'origin/main' into codex/content-sidebar…
3mdistal Sep 21, 2026
2dd58e7
fix(content): isolate sidebar state by workspace
3mdistal Sep 21, 2026
a449d08
Merge remote-tracking branch 'origin/main' into codex/content-sidebar…
3mdistal Sep 21, 2026
ecb0e00
fix(content): scope pending sidebar writes
3mdistal Sep 21, 2026
b0a7eff
fix(content): preserve sidebar cache scope
3mdistal Sep 21, 2026
1424e5c
fix(content): keep sidebar writes ordered
3mdistal Sep 21, 2026
1f5f683
Merge remote-tracking branch 'origin/main' into codex/content-sidebar…
3mdistal Sep 21, 2026
31b57db
fix(content): serialize workspace sidebar state
3mdistal Sep 21, 2026
eb4352f
Merge remote-tracking branch 'origin/main' into codex/content-sidebar…
3mdistal Sep 21, 2026
608bc03
Merge remote-tracking branch 'origin/main' into codex/content-sidebar…
3mdistal Sep 21, 2026
2194858
Merge remote-tracking branch 'origin/main' into codex/content-sidebar…
3mdistal Sep 21, 2026
6b0a3cb
Merge remote-tracking branch 'origin/main' into codex/content-sidebar…
3mdistal Sep 21, 2026
57af728
Merge remote-tracking branch 'origin/main' into codex/content-sidebar…
3mdistal Sep 21, 2026
5bd3a15
fix(content): reuse shared welcome pages
3mdistal Sep 21, 2026
2ac160c
Merge remote-tracking branch 'origin/main' into codex/content-sidebar…
3mdistal Sep 21, 2026
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
23 changes: 11 additions & 12 deletions templates/content/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,16 +51,14 @@ Read the relevant skill before deeper work:
and respect `contextMode: "off"` without silently restoring a pack.
- `content-last-location-v1` — the last successfully loaded Page. The UI and
landing resolver own this state; do not write it from agent workflows.
- `content-trash` — bounded Trash filters, up to 100 selected Page IDs, the
preview Page ID, and the current purge operation ID. Treat selection and
filters as UI context, not deletion authority; plan the exact scope before
permanent deletion.
- `content-trash` — Trash filters, selected/preview Page IDs, and purge operation
ID. This is UI context, not deletion authority.
- Use actions for full document content and comment context.

## Actions

| Action | Purpose |
| --- | --- |
| ------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| `view-screen` | Re-read the current screen when `<current-screen>` is stale |
| `navigate` | Move the UI to a document, comments, media, or settings |
| `refresh-list` | Repaint the sidebar after an out-of-band mutation |
Expand All @@ -70,7 +68,8 @@ Read the relevant skill before deeper work:
| `pull-document` | Flush live collab state, then read (external edits) |
| `get-blocks-field-word-count` | Count one exact Blocks field; omit `propertyId` for the primary Content body |
| `create-document` | Create a page, optionally under a parent |
| `resolve-content-landing` | Restore the caller's last authorized page |
| `resolve-content-landing` | Restore the caller's last authorized page in a requested Content space |
| `get-content-recent` | List personal recent destinations with current access, optionally scoped to a Content space's Files membership |
| `edit-document` | Revisioned find/replace, or initialize an empty body |
| `update-document` | Metadata or browser-owned full rewrite |
| `delete-document` | Move a page and its children to Trash |
Expand All @@ -88,12 +87,12 @@ Every action carries its schema. Use `tool-search` for comments, sharing,
Collections, Notion, local sources such as `remove-local-file-source`, and the
rest of the registered surface.

For permanent deletion, always plan before executing and report blockers or
conflicts rather than claiming Trash is empty. Scope mode means all manageable
Trash in the chosen space scope and intentionally ignores text, kind, actor,
and location filters. A selected or matching Page includes only itself and its
currently trashed descendants, never its Trash root or siblings. New or changed
Trash after planning is not silently added.
Permanent deletion requires a frozen plan. Read `document-editing` and report
blockers or conflicts instead of claiming Trash is empty.

Sidebar order and active Views use the personal view `navigation` patch, never
parentage. Recent means foreground visits; paging, active-path lookup, and visit
recording are UI-owned.

## Source Changes

Expand Down
30 changes: 24 additions & 6 deletions templates/content/actions/_content-database-personal-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,10 @@ export const filterSchema = z.object({
parentFilterGroupId: z.string().optional(),
});

const sidebarOrderItemIdSchema = z.string().min(1).max(256);
export const sidebarOrderSchema = z.object({
mode: z.enum(["custom", "last_edited", "name", "created"]),
itemIds: z.array(z.string()),
itemIds: z.array(sidebarOrderItemIdSchema).max(5_000),
});

const personalViewOverridesFields = {
Expand Down Expand Up @@ -123,24 +124,41 @@ export async function readPersonalDatabaseViewOverrides(
userEmail,
personalDatabaseViewSettingKey(databaseId),
);
if (stored === null) return null;
const parsed = personalViewOverridesSchema.safeParse(stored);
if (parsed.success)
return normalizePersonalDatabaseViewOverrides(parsed.data);

const legacy = legacyPersonalViewOverridesSchema.safeParse(stored);
if (!legacy.success) return null;
legacyPersonalViewOverridesSchema.parse(stored);
const [database] = await getDb()
.select({ systemRole: schema.contentDatabases.systemRole })
.from(schema.contentDatabases)
.where(eq(schema.contentDatabases.id, databaseId));
return migratePersonalDatabaseViewOverrides(
stored,
databaseId,
database?.systemRole ?? null,
);
}

export function migratePersonalDatabaseViewOverrides(
stored: unknown,
databaseId: string,
systemRole: string | null,
) {
if (stored === null) return null;
const parsed = personalViewOverridesSchema.safeParse(stored);
if (parsed.success)
return normalizePersonalDatabaseViewOverrides(parsed.data);
const legacy = legacyPersonalViewOverridesSchema.parse(stored);
const legacyParentKey = filesParentPropertyId(databaseId);
return normalizePersonalDatabaseViewOverrides({
...legacy.data,
...legacy,
version: PERSONAL_DATABASE_VIEW_OVERRIDES_VERSION,
views: legacy.data.views.map((view) => ({
views: legacy.views.map((view) => ({
...view,
filters:
database?.systemRole === "files"
systemRole === "files"
? view.filters.filter(
(filter) =>
!(
Expand Down
48 changes: 38 additions & 10 deletions templates/content/actions/_content-favorites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ export function favoritesSystemIds(userEmail: string) {
);
}

export function favoriteMembershipId(userEmail: string, documentId: string) {
const favoritesDatabaseId = favoritesSystemIds(userEmail).databaseId;
return `content_database_item_${createHash("sha256")
.update(`${favoritesDatabaseId}:${documentId}`)
.digest("hex")
.slice(0, 32)}`;
}

export async function favoriteDocumentIds(
db: Db,
userEmail: string,
Expand Down Expand Up @@ -48,7 +56,12 @@ export async function setFavoriteMembership(args: {
const email = normalizeContentSpaceEmail(args.userEmail);
const favoritesDatabaseId = favoritesSystemIds(email).databaseId;
const [existing] = await args.db
.select({ id: schema.contentDatabaseItems.id })
.select({
id: schema.contentDatabaseItems.id,
position: schema.contentDatabaseItems.position,
createdAt: schema.contentDatabaseItems.createdAt,
updatedAt: schema.contentDatabaseItems.updatedAt,
})
.from(schema.contentDatabaseItems)
.where(
and(
Expand All @@ -63,19 +76,28 @@ export async function setFavoriteMembership(args: {
.delete(schema.contentDatabaseItems)
.where(eq(schema.contentDatabaseItems.id, existing.id));
}
return false;
return {
favorite: false,
changed: Boolean(existing),
membershipId:
existing?.id ?? favoriteMembershipId(email, args.documentId),
previous: existing ?? null,
};
}
if (existing) return true;
if (existing)
return {
favorite: true,
changed: false,
membershipId: existing.id,
previous: existing,
};

const [position] = await args.db
.select({ max: sql<unknown>`COALESCE(MAX(position), -1)` })
.from(schema.contentDatabaseItems)
.where(eq(schema.contentDatabaseItems.databaseId, favoritesDatabaseId));
const id = `content_database_item_${createHash("sha256")
.update(`${favoritesDatabaseId}:${args.documentId}`)
.digest("hex")
.slice(0, 32)}`;
await args.db
const id = favoriteMembershipId(email, args.documentId);
const inserted = await args.db
.insert(schema.contentDatabaseItems)
.values({
id,
Expand All @@ -87,6 +109,12 @@ export async function setFavoriteMembership(args: {
createdAt: args.now,
updatedAt: args.now,
})
.onConflictDoNothing();
return true;
.onConflictDoNothing()
.returning({ id: schema.contentDatabaseItems.id });
return {
favorite: true,
changed: inserted.length > 0,
membershipId: id,
previous: null,
};
}
194 changes: 194 additions & 0 deletions templates/content/actions/_content-recent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import { getRequestOrgId } from "@agent-native/core/server/request-context";
import { and, eq, inArray, isNull, or } from "drizzle-orm";
import { z } from "zod";

import { getDb, schema } from "../server/db/index.js";
import {
contentRecentTargetKey,
type ContentRecentEntry,
type ContentRecentResult,
} from "../shared/content-personal-navigation.js";
import { readPersonalDatabaseViewOverrides } from "./_content-database-personal-view.js";
import { resolveContentSpaceAccess } from "./_content-space-access.js";
import { documentDiscoveryWhere } from "./_document-discovery-query.js";
import { parseDatabaseViewConfig } from "./_property-utils.js";

export function contentRecentSettingKey() {
return `content-recent:${JSON.stringify(getRequestOrgId() ?? null)}`;
}

const viewIdentitySchema = z.object({
views: z
.array(z.object({ id: z.string().trim().min(1), name: z.string() }))
.optional(),
});

export async function resolveContentRecentEntries(
userEmail: string,
entries: ContentRecentEntry[],
spaceId?: string,
): Promise<ContentRecentResult[]> {
if (entries.length === 0) return [];
const db = getDb();
const orgId = getRequestOrgId();
let scopedEntries = entries;
if (spaceId) {
const access = await resolveContentSpaceAccess(spaceId, "viewer", { db });
if (access.space.orgId && access.space.orgId !== orgId) return [];
const memberships = await db
.select({ documentId: schema.contentDatabaseItems.documentId })
.from(schema.contentDatabaseItems)
.where(
and(
eq(
schema.contentDatabaseItems.databaseId,
access.space.filesDatabaseId,
),
inArray(
schema.contentDatabaseItems.documentId,
entries.map((entry) => entry.target.documentId),
),
),
);
const memberIds = new Set(memberships.map((row) => row.documentId));
scopedEntries = entries.filter((entry) =>
memberIds.has(entry.target.documentId),
);
}
if (scopedEntries.length === 0) return [];
const documents = await db
.select({
id: schema.documents.id,
title: schema.documents.title,
icon: schema.documents.icon,
})
.from(schema.documents)
.where(
documentDiscoveryWhere({
userEmail,
authorizedOrgIds: orgId ? [orgId] : [],
additional: inArray(
schema.documents.id,
scopedEntries.map((entry) => entry.target.documentId),
),
}),
);
const byId = new Map(documents.map((document) => [document.id, document]));
const databaseEntries = scopedEntries.filter(
(entry) => entry.target.databaseId,
);
const viewDocumentIds = databaseEntries
.filter(
(entry) => entry.target.databaseId && byId.has(entry.target.documentId),
)
.map((entry) => entry.target.documentId);
const databases =
viewDocumentIds.length === 0
? []
: await db
.select({
id: schema.contentDatabases.id,
documentId: schema.contentDatabases.documentId,
viewConfigJson: schema.contentDatabases.viewConfigJson,
})
.from(schema.contentDatabases)
.where(
and(
or(
inArray(schema.contentDatabases.documentId, viewDocumentIds),
inArray(
schema.contentDatabases.id,
databaseEntries.map((entry) => entry.target.databaseId!),
),
),
isNull(schema.contentDatabases.deletedAt),
),
);
const databasesById = new Map(
databases.map((database) => [database.id, database]),
);
const missingBackingDocumentIds = databases
.map((database) => database.documentId)
.filter((documentId) => !byId.has(documentId));
if (missingBackingDocumentIds.length > 0) {
const backingDocuments = await db
.select({
id: schema.documents.id,
title: schema.documents.title,
icon: schema.documents.icon,
})
.from(schema.documents)
.where(
documentDiscoveryWhere({
userEmail,
authorizedOrgIds: orgId ? [orgId] : [],
additional: inArray(schema.documents.id, missingBackingDocumentIds),
}),
);
for (const document of backingDocuments) byId.set(document.id, document);
}
const results: ContentRecentResult[] = [];
const seen = new Set<string>();
for (const entry of scopedEntries) {
const database = entry.target.databaseId
? databasesById.get(entry.target.databaseId)
: undefined;
const document = byId.get(database?.documentId ?? entry.target.documentId);
if (!document) continue;
let viewName: string | null = null;
let target = database
? { ...entry.target, documentId: database.documentId }
: entry.target;
let fallback: ContentRecentResult["fallback"];
if (entry.target.databaseId) {
if (!database || database.documentId !== document.id) continue;
if (entry.target.viewId) {
// Validate before the legacy parser, which otherwise coerces unreadable JSON to a default.
viewIdentitySchema.parse(JSON.parse(database.viewConfigJson));
const config = parseDatabaseViewConfig(database.viewConfigJson);
const view = config.views.find(
(candidate) => candidate.id === entry.target.viewId,
);
if (view) {
viewName = view.name;
} else {
const personal = await readPersonalDatabaseViewOverrides(
userEmail,
database.id,
);
const fallbackView =
config.views.find(
(candidate) => candidate.id === personal?.activeViewId,
) ??
config.views.find(
(candidate) => candidate.id === config.activeViewId,
) ??
config.views[0];
if (!fallbackView) continue;
target = {
documentId: document.id,
databaseId: database.id,
viewId: fallbackView.id,
};
viewName = fallbackView.name;
fallback = {
reason: "saved_view_unavailable",
requestedViewId: entry.target.viewId,
};
}
}
}
const key = contentRecentTargetKey(entry.target);
if (seen.has(key)) continue;
seen.add(key);
results.push({
...entry,
target,
title: document.title,
icon: document.icon,
viewName,
...(fallback ? { fallback } : {}),
});
}
return results;
}
Loading
Loading