From 4c381f99dd7664aa475c27e70c710983fbef0e62 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Wed, 9 Sep 2026 11:08:41 -0400 Subject: [PATCH 01/20] Add personal sidebar sections and exact View recency --- templates/content/actions/_content-recent.ts | 102 ++++++ .../content/actions/_content-sidebar-state.ts | 13 +- .../content/actions/content-recent.test.ts | 302 ++++++++++++++++ .../actions/content-sidebar-state.test.ts | 5 +- .../content/actions/get-content-recent.ts | 36 ++ .../content/actions/record-content-visit.ts | 36 ++ .../actions/update-content-sidebar-state.ts | 22 +- .../components/editor/DocumentDatabase.tsx | 6 + .../app/components/editor/DocumentEditor.tsx | 43 +++ .../database/DatabaseView.recent.test.ts | 37 ++ .../editor/database/DatabaseView.tsx | 105 +++++- .../components/sidebar/DocumentSidebar.tsx | 301 ++++++++-------- .../sidebar/PersonalSidebarSections.tsx | 334 ++++++++++++++++++ .../content/app/hooks/use-content-recent.ts | 78 ++++ templates/content/app/i18n-data.ts | 55 +++ .../content/app/routes/_app.page.$id.tsx | 2 + .../content-personal-navigation.test.ts | 72 ++++ .../shared/content-personal-navigation.ts | 105 ++++++ 18 files changed, 1482 insertions(+), 172 deletions(-) create mode 100644 templates/content/actions/_content-recent.ts create mode 100644 templates/content/actions/content-recent.test.ts create mode 100644 templates/content/actions/get-content-recent.ts create mode 100644 templates/content/actions/record-content-visit.ts create mode 100644 templates/content/app/components/editor/database/DatabaseView.recent.test.ts create mode 100644 templates/content/app/components/sidebar/PersonalSidebarSections.tsx create mode 100644 templates/content/app/hooks/use-content-recent.ts create mode 100644 templates/content/shared/content-personal-navigation.test.ts create mode 100644 templates/content/shared/content-personal-navigation.ts diff --git a/templates/content/actions/_content-recent.ts b/templates/content/actions/_content-recent.ts new file mode 100644 index 00000000000..f1e97b885ee --- /dev/null +++ b/templates/content/actions/_content-recent.ts @@ -0,0 +1,102 @@ +import { getRequestOrgId } from "@agent-native/core/server/request-context"; +import { and, inArray, isNull } 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 { documentDiscoveryWhere } from "./_document-discovery-query.js"; + +export function contentRecentSettingKey() { + return `content-recent:${JSON.stringify(getRequestOrgId() ?? null)}`; +} + +const viewIdentitySchema = z.object({ + views: z.array(z.object({ id: z.string(), name: z.string() })).optional(), +}); + +export async function resolveContentRecentEntries( + userEmail: string, + entries: ContentRecentEntry[], +): Promise { + if (entries.length === 0) return []; + const db = getDb(); + const orgId = getRequestOrgId(); + 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, + entries.map((entry) => entry.target.documentId), + ), + }), + ); + const byId = new Map(documents.map((document) => [document.id, document])); + const viewDocumentIds = entries + .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( + inArray(schema.contentDatabases.documentId, viewDocumentIds), + isNull(schema.contentDatabases.deletedAt), + ), + ); + const databasesById = new Map( + databases.map((database) => [database.id, database]), + ); + const results: ContentRecentResult[] = []; + const seen = new Set(); + for (const entry of entries) { + const document = byId.get(entry.target.documentId); + if (!document) continue; + let viewName: string | null = null; + if (entry.target.databaseId) { + const database = databasesById.get(entry.target.databaseId); + if (!database || database.documentId !== document.id) continue; + if (entry.target.viewId) { + // Do not normalize a missing exact View into the database's default View. + const config = viewIdentitySchema.parse( + JSON.parse(database.viewConfigJson), + ); + const view = config.views?.find( + (candidate) => candidate.id === entry.target.viewId, + ); + if (!view) continue; + viewName = view.name; + } + } + const key = contentRecentTargetKey(entry.target); + if (seen.has(key)) continue; + seen.add(key); + results.push({ + ...entry, + title: document.title, + icon: document.icon, + viewName, + }); + } + return results; +} diff --git a/templates/content/actions/_content-sidebar-state.ts b/templates/content/actions/_content-sidebar-state.ts index 7ec19b13595..a6b8a02e3af 100644 --- a/templates/content/actions/_content-sidebar-state.ts +++ b/templates/content/actions/_content-sidebar-state.ts @@ -1,5 +1,7 @@ import { z } from "zod"; +import { contentSidebarSectionsSchema } from "../shared/content-personal-navigation.js"; + export const CONTENT_SIDEBAR_STATE_VERSION = 1; export const CONTENT_SIDEBAR_STATE_SETTING_KEY = "content-sidebar-state"; @@ -9,16 +11,17 @@ export const contentSidebarStateSchema = z.object({ version: z.literal(CONTENT_SIDEBAR_STATE_VERSION), expandedWorkspaceIds: z.array(expandedIdSchema).max(1_000), expandedDocumentIds: z.array(expandedIdSchema).max(5_000), + sections: contentSidebarSectionsSchema.optional(), }); export type ContentSidebarState = z.infer; export function normalizeContentSidebarState(value: unknown) { - const parsed = contentSidebarStateSchema.safeParse(value); - if (!parsed.success) return null; + if (value === null) return null; + const data = contentSidebarStateSchema.parse(value); return { - ...parsed.data, - expandedWorkspaceIds: [...new Set(parsed.data.expandedWorkspaceIds)], - expandedDocumentIds: [...new Set(parsed.data.expandedDocumentIds)], + ...data, + expandedWorkspaceIds: [...new Set(data.expandedWorkspaceIds)], + expandedDocumentIds: [...new Set(data.expandedDocumentIds)], }; } diff --git a/templates/content/actions/content-recent.test.ts b/templates/content/actions/content-recent.test.ts new file mode 100644 index 00000000000..5f2b2d75b35 --- /dev/null +++ b/templates/content/actions/content-recent.test.ts @@ -0,0 +1,302 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const boundary = vi.hoisted(() => ({ + orgId: null as string | null, + discovery: vi.fn(), + select: vi.fn(), + getSetting: vi.fn(), + mutateSetting: vi.fn(), +})); + +vi.mock("@agent-native/core/server/request-context", () => ({ + getRequestOrgId: () => boundary.orgId, +})); +vi.mock("@agent-native/core/settings", () => ({ + getUserSetting: boundary.getSetting, + mutateUserSetting: boundary.mutateSetting, +})); +vi.mock("./_document-discovery-query.js", () => ({ + documentDiscoveryWhere: boundary.discovery, +})); +vi.mock("../server/db/index.js", () => ({ + getDb: () => ({ select: boundary.select }), + schema: { + documents: { id: "document-id", title: "title", icon: "icon" }, + contentDatabases: { + id: "database-id", + documentId: "database-document-id", + viewConfigJson: "view-config", + deletedAt: "deleted-at", + }, + }, +})); + +import { + defaultContentSidebarSections, + type ContentRecentEntry, +} from "../shared/content-personal-navigation.js"; +import { + contentRecentSettingKey, + resolveContentRecentEntries, +} from "./_content-recent.js"; +import getRecent from "./get-content-recent.js"; +import recordVisit from "./record-content-visit.js"; +import updateSidebar from "./update-content-sidebar-state.js"; + +const alice = { userEmail: "alice@example.test" }; +const bob = { userEmail: "bob@example.test" }; +const entry = (documentId: string, extra = {}): ContentRecentEntry => ({ + target: { documentId, ...extra }, + visitedAt: "2026-09-09T12:00:00.000Z", +}); +const stored = new Map(); +const settingId = (email: string, key: string) => JSON.stringify([email, key]); + +function rowsOnce(rows: unknown[]) { + boundary.select.mockReturnValueOnce({ + from: () => ({ where: async () => rows }), + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + boundary.select.mockReset(); + boundary.orgId = null; + stored.clear(); + boundary.getSetting.mockImplementation( + async (email: string, key: string) => + stored.get(settingId(email, key)) ?? null, + ); + // Model the settings mutation boundary's serialization, not its SQL/CAS implementation. + let queue = Promise.resolve(); + boundary.mutateSetting.mockImplementation( + (email: string, key: string, mutate: (current: unknown) => unknown) => { + const operation = queue.then(() => { + const id = settingId(email, key); + const next = mutate(stored.get(id) ?? null); + stored.set(id, next); + return next; + }); + queue = operation.then( + () => undefined, + () => undefined, + ); + return operation; + }, + ); +}); + +describe("Recent access resolution", () => { + it("uses only the current org context and the requesting user", async () => { + boundary.orgId = "current-org"; + rowsOnce([]); + await resolveContentRecentEntries(alice.userEmail, [entry("page")]); + expect(boundary.discovery).toHaveBeenLastCalledWith( + expect.objectContaining({ + userEmail: alice.userEmail, + authorizedOrgIds: ["current-org"], + }), + ); + boundary.orgId = null; + rowsOnce([]); + await resolveContentRecentEntries(alice.userEmail, [entry("page")]); + expect(boundary.discovery).toHaveBeenLastCalledWith( + expect.objectContaining({ + authorizedOrgIds: [], + }), + ); + }); + + it("resolves fresh labels and omits rows no longer returned by access discovery", async () => { + rowsOnce([{ id: "page", title: "Current title", icon: null }]); + const visits = [entry("page"), entry("revoked"), entry("trashed")]; + expect(await resolveContentRecentEntries(alice.userEmail, visits)).toEqual([ + { ...visits[0], title: "Current title", icon: null, viewName: null }, + ]); + rowsOnce([]); + expect(await resolveContentRecentEntries(alice.userEmail, visits)).toEqual( + [], + ); + expect(boundary.select).toHaveBeenCalledTimes(2); + }); + + it("never substitutes another View or Database for an unavailable exact target", async () => { + rowsOnce([{ id: "page", title: "Database page", icon: null }]); + rowsOnce([ + { + id: "db", + documentId: "page", + viewConfigJson: JSON.stringify({ + activeViewId: "default", + views: [{ id: "default", name: "Default" }], + }), + }, + ]); + expect( + await resolveContentRecentEntries(alice.userEmail, [ + entry("page", { databaseId: "db", viewId: "removed" }), + entry("page", { databaseId: "deleted-db", viewId: "default" }), + ]), + ).toEqual([]); + }); + + it("uses the exact View's current label and rejects corrupt View configuration", async () => { + const visit = entry("page", { databaseId: "db", viewId: "board" }); + rowsOnce([{ id: "page", title: "Current page", icon: null }]); + rowsOnce([ + { + id: "db", + documentId: "page", + viewConfigJson: JSON.stringify({ + views: [{ id: "board", name: "Current board" }], + }), + }, + ]); + expect(await resolveContentRecentEntries(alice.userEmail, [visit])).toEqual( + [ + { + ...visit, + title: "Current page", + icon: null, + viewName: "Current board", + }, + ], + ); + rowsOnce([{ id: "page", title: "Current page", icon: null }]); + rowsOnce([{ id: "db", documentId: "page", viewConfigJson: "broken" }]); + await expect( + resolveContentRecentEntries(alice.userEmail, [visit]), + ).rejects.toThrow(); + }); +}); + +describe("Recent action persistence", () => { + it("isolates both user and current-context settings", async () => { + boundary.orgId = "org-a"; + rowsOnce([{ id: "page", title: "Page", icon: null }]); + await recordVisit.run({ documentId: "page" }, alice); + expect(boundary.mutateSetting).toHaveBeenCalledWith( + alice.userEmail, + 'content-recent:"org-a"', + expect.any(Function), + ); + expect(await getRecent.run({}, bob)).toEqual({ + entries: [], + scopeKey: JSON.stringify([bob.userEmail, "org-a"]), + }); + boundary.orgId = "org-b"; + expect(await getRecent.run({}, alice)).toEqual({ + entries: [], + scopeKey: JSON.stringify([alice.userEmail, "org-b"]), + }); + expect(contentRecentSettingKey()).toBe('content-recent:"org-b"'); + }); + + it("rejects a stale user or org scope before reading any saved navigation", async () => { + boundary.orgId = "current-org"; + for (const scopeKey of [ + JSON.stringify([bob.userEmail, "current-org"]), + JSON.stringify([alice.userEmail, "previous-org"]), + ]) { + await expect(getRecent.run({ scopeKey }, alice)).rejects.toThrow( + "Navigation context changed", + ); + } + expect(boundary.getSetting).not.toHaveBeenCalled(); + expect(boundary.select).not.toHaveBeenCalled(); + }); + + it("accepts and returns the normalized identity scope", async () => { + const scopeKey = JSON.stringify([alice.userEmail, null]); + expect( + await getRecent.run({ scopeKey }, { userEmail: " Alice@Example.Test " }), + ).toEqual({ scopeKey, entries: [] }); + }); + + it("preserves concurrent visits using the mutation callback's current value", async () => { + rowsOnce([{ id: "first", title: "First", icon: null }]); + rowsOnce([{ id: "second", title: "Second", icon: null }]); + await Promise.all([ + recordVisit.run({ documentId: "first" }, alice), + recordVisit.run({ documentId: "second" }, alice), + ]); + expect(boundary.getSetting).not.toHaveBeenCalled(); + const state = stored.get( + settingId(alice.userEmail, contentRecentSettingKey()), + ); + expect(state).toMatchObject({ + entries: expect.arrayContaining([ + expect.objectContaining({ target: { documentId: "first" } }), + expect.objectContaining({ target: { documentId: "second" } }), + ]), + }); + }); + + it("refuses unavailable visits before writing", async () => { + rowsOnce([]); + await expect( + recordVisit.run({ documentId: "revoked" }, alice), + ).rejects.toThrow("unavailable"); + expect(boundary.mutateSetting).not.toHaveBeenCalled(); + }); + + it("does not replace corrupt Recent state with a successful new visit", async () => { + const id = settingId(alice.userEmail, contentRecentSettingKey()); + stored.set(id, { version: 99, entries: [] }); + rowsOnce([{ id: "page", title: "Page", icon: null }]); + await expect( + recordVisit.run({ documentId: "page" }, alice), + ).rejects.toThrow(); + expect(stored.get(id)).toEqual({ version: 99, entries: [] }); + }); + + it("propagates persisted corruption, setting read failures, and target read failures", async () => { + stored.set(settingId(alice.userEmail, contentRecentSettingKey()), { + version: 1, + entries: "bad", + }); + await expect(getRecent.run({}, alice)).rejects.toThrow(); + boundary.getSetting.mockRejectedValueOnce(new Error("settings offline")); + await expect(getRecent.run({}, alice)).rejects.toThrow("settings offline"); + boundary.select.mockImplementationOnce(() => { + throw new Error("database offline"); + }); + await expect( + recordVisit.run({ documentId: "page" }, alice), + ).rejects.toThrow("database offline"); + expect(boundary.mutateSetting).not.toHaveBeenCalled(); + }); +}); + +describe("sidebar partial state persistence", () => { + it("merges simultaneous expansion and section edits without overwriting either", async () => { + const sections = defaultContentSidebarSections(); + sections.recent.visible = false; + await Promise.all([ + updateSidebar.run({ version: 1, expandedDocumentIds: ["page"] }, alice), + updateSidebar.run({ version: 1, sections }, alice), + ]); + expect( + stored.get(settingId(alice.userEmail, "content-sidebar-state")), + ).toEqual({ + version: 1, + expandedDocumentIds: ["page"], + expandedWorkspaceIds: [], + sections, + }); + expect(stored.has(settingId(bob.userEmail, "content-sidebar-state"))).toBe( + false, + ); + }); + + it("does not overwrite corrupt persisted state or report a failed mutation as saved", async () => { + const id = settingId(alice.userEmail, "content-sidebar-state"); + stored.set(id, { version: 99 }); + await expect(updateSidebar.run({ version: 1 }, alice)).rejects.toThrow(); + expect(stored.get(id)).toEqual({ version: 99 }); + boundary.mutateSetting.mockRejectedValueOnce(new Error("write failed")); + await expect(updateSidebar.run({ version: 1 }, alice)).rejects.toThrow( + "write failed", + ); + }); +}); diff --git a/templates/content/actions/content-sidebar-state.test.ts b/templates/content/actions/content-sidebar-state.test.ts index 38f10160d17..16f0ae06413 100644 --- a/templates/content/actions/content-sidebar-state.test.ts +++ b/templates/content/actions/content-sidebar-state.test.ts @@ -20,7 +20,8 @@ describe("normalizeContentSidebarState", () => { }); }); - it("ignores unknown or stale stored values", () => { - expect(normalizeContentSidebarState({ version: 0 })).toBeNull(); + it("distinguishes absent state from unreadable saved state", () => { + expect(normalizeContentSidebarState(null)).toBeNull(); + expect(() => normalizeContentSidebarState({ version: 0 })).toThrow(); }); }); diff --git a/templates/content/actions/get-content-recent.ts b/templates/content/actions/get-content-recent.ts new file mode 100644 index 00000000000..77f42a15dab --- /dev/null +++ b/templates/content/actions/get-content-recent.ts @@ -0,0 +1,36 @@ +import { defineAction, fail } from "@agent-native/core/action"; +import { getRequestOrgId } from "@agent-native/core/server/request-context"; +import { getUserSetting } from "@agent-native/core/settings"; +import { z } from "zod"; + +import { readContentRecentState } from "../shared/content-personal-navigation.js"; +import { + contentRecentSettingKey, + resolveContentRecentEntries, +} from "./_content-recent.js"; + +export default defineAction({ + description: + "Read the current user's recently visited Pages and exact Views in the current context, resolving every target under current access.", + schema: z.object({ scopeKey: z.string().optional() }), + http: { method: "GET" }, + run: async (args, ctx) => { + if (!ctx?.userEmail) fail("Not authenticated.", { statusCode: 401 }); + const scopeKey = JSON.stringify([ + ctx.userEmail.trim().toLowerCase(), + getRequestOrgId() ?? null, + ]); + if (args.scopeKey && args.scopeKey !== scopeKey) + fail("Navigation context changed.", { + statusCode: 409, + errorCode: "context_changed", + }); + const state = readContentRecentState( + await getUserSetting(ctx.userEmail, contentRecentSettingKey()), + ); + return { + scopeKey, + entries: await resolveContentRecentEntries(ctx.userEmail, state.entries), + }; + }, +}); diff --git a/templates/content/actions/record-content-visit.ts b/templates/content/actions/record-content-visit.ts new file mode 100644 index 00000000000..c3f1936fad4 --- /dev/null +++ b/templates/content/actions/record-content-visit.ts @@ -0,0 +1,36 @@ +import { defineAction, fail } from "@agent-native/core/action"; +import { mutateUserSetting } from "@agent-native/core/settings"; + +import { + contentRecentTargetSchema, + readContentRecentState, + recordContentRecentVisit, +} from "../shared/content-personal-navigation.js"; +import { + contentRecentSettingKey, + resolveContentRecentEntries, +} from "./_content-recent.js"; + +export default defineAction({ + description: + "Record a successfully opened foreground Page or exact database View in the current user's Recent navigation. Does not edit or pin the target.", + schema: contentRecentTargetSchema, + agentTool: false, + run: async (target, ctx) => { + if (!ctx?.userEmail) fail("Not authenticated.", { statusCode: 401 }); + const entry = { target, visitedAt: new Date().toISOString() }; + const resolved = await resolveContentRecentEntries(ctx.userEmail, [entry]); + if (resolved.length !== 1) + fail("This location is unavailable.", { + statusCode: 404, + errorCode: "location_unavailable", + }); + await mutateUserSetting( + ctx.userEmail, + contentRecentSettingKey(), + (current) => + recordContentRecentVisit(readContentRecentState(current), entry), + ); + return { recorded: true }; + }, +}); diff --git a/templates/content/actions/update-content-sidebar-state.ts b/templates/content/actions/update-content-sidebar-state.ts index ed8d25603dd..07a65579c6b 100644 --- a/templates/content/actions/update-content-sidebar-state.ts +++ b/templates/content/actions/update-content-sidebar-state.ts @@ -1,27 +1,29 @@ import { defineAction } from "@agent-native/core/action"; -import { putUserSetting } from "@agent-native/core/settings"; +import { mutateUserSetting } from "@agent-native/core/settings"; import { CONTENT_SIDEBAR_STATE_SETTING_KEY, contentSidebarStateSchema, + normalizeContentSidebarState, } from "./_content-sidebar-state.js"; export default defineAction({ description: "Persist the current user's Content sidebar expansion state.", - schema: contentSidebarStateSchema, + schema: contentSidebarStateSchema.partial().required({ version: true }), agentTool: false, run: async (state, ctx) => { if (!ctx?.userEmail) throw new Error("Not authenticated."); - const normalized = { - ...state, - expandedWorkspaceIds: [...new Set(state.expandedWorkspaceIds)], - expandedDocumentIds: [...new Set(state.expandedDocumentIds)], - }; - await putUserSetting( + const saved = await mutateUserSetting( ctx.userEmail, CONTENT_SIDEBAR_STATE_SETTING_KEY, - normalized, + (current) => + normalizeContentSidebarState({ + expandedWorkspaceIds: [], + expandedDocumentIds: [], + ...normalizeContentSidebarState(current), + ...state, + })!, ); - return { state: normalized }; + return { state: normalizeContentSidebarState(saved) }; }, }); diff --git a/templates/content/app/components/editor/DocumentDatabase.tsx b/templates/content/app/components/editor/DocumentDatabase.tsx index f8cab5c04f4..c9d8975284a 100644 --- a/templates/content/app/components/editor/DocumentDatabase.tsx +++ b/templates/content/app/components/editor/DocumentDatabase.tsx @@ -8,12 +8,16 @@ export * from "./database/DatabaseView"; interface DocumentDatabaseProps { document: Document; canEdit: boolean; + requestedViewId?: string | null; + foreground?: boolean; onExportContextChange?: (context: DatabaseExportContext | null) => void; } export function DocumentDatabase({ document, canEdit, + requestedViewId, + foreground, onExportContextChange, }: DocumentDatabaseProps) { const databaseId = document.database?.id; @@ -24,6 +28,8 @@ export function DocumentDatabase({ databaseId={databaseId} databaseDocumentId={document.id} canEdit={canEdit} + requestedViewId={requestedViewId} + foreground={foreground} onExportContextChange={onExportContextChange} /> ); diff --git a/templates/content/app/components/editor/DocumentEditor.tsx b/templates/content/app/components/editor/DocumentEditor.tsx index b111c3d4615..e01847f2506 100644 --- a/templates/content/app/components/editor/DocumentEditor.tsx +++ b/templates/content/app/components/editor/DocumentEditor.tsx @@ -65,6 +65,7 @@ import { useDeleteContentDatabase, useProcessBuilderBodyHydration, } from "@/hooks/use-content-database"; +import { useRecordContentVisit } from "@/hooks/use-content-recent"; import { useContentSpaces, type ContentSpaceSummary, @@ -186,6 +187,8 @@ interface DocumentEditorProps { documentId: string; databaseId?: string | null; databaseDocumentId?: string | null; + requestedViewId?: string | null; + foreground?: boolean; } export interface PageEditorSession { @@ -344,12 +347,16 @@ export function DocumentEditor({ documentId, databaseId, databaseDocumentId, + requestedViewId, + foreground = false, }: DocumentEditorProps) { return ( ); @@ -370,6 +377,8 @@ export function PageEditorSurface({ documentId, databaseId, databaseDocumentId, + requestedViewId, + foreground = false, host, onSessionChange, onDelete, @@ -425,6 +434,18 @@ export function PageEditorSurface({ }); admittedDocumentIdRef.current = loadState.admittedDocumentId; + useRecordContentVisit( + { documentId }, + foreground && + host === "page" && + !requestedViewId && + !!document && + !document.database && + !isError && + isFetchedAfterMount && + loadState.view === "editor", + ); + async function retryDocumentQuery() { setManualRetryDocumentId(documentId); try { @@ -465,6 +486,18 @@ export function PageEditorSurface({ ); } + if ( + requestedViewId && + document && + (!document.database || document.database.id !== databaseId) + ) { + return ( + navigate("/home") : undefined} + /> + ); + } + // If we have a doc (real or optimistic from create) render the editor — // an `isError` blip during a just-fired create shouldn't flash "not found". // A database/list snapshot can optimistically seed the document cache with a @@ -487,6 +520,10 @@ export function PageEditorSurface({ })} documentId={documentId} document={document} + requestedViewId={requestedViewId} + foreground={ + foreground && host === "page" && !isError && isFetchedAfterMount + } databaseId={databaseId} databaseDocumentId={databaseDocumentId} host={host} @@ -703,6 +740,8 @@ interface DocumentEditorBodyProps { onDelete?: () => Promise; focusTitle: boolean; onTitleFocused?: () => void; + requestedViewId?: string | null; + foreground?: boolean; } type PendingDocumentSave = { @@ -1112,6 +1151,8 @@ function PageEditorSessionBody({ onDelete, focusTitle, onTitleFocused, + requestedViewId, + foreground = false, }: DocumentEditorBodyProps) { const acknowledgedDocumentRef = useRef(null); const resolvedDocument = resolveAcknowledgedDocumentSnapshot({ @@ -3902,6 +3943,8 @@ function PageEditorSessionBody({
diff --git a/templates/content/app/components/editor/database/DatabaseView.recent.test.ts b/templates/content/app/components/editor/database/DatabaseView.recent.test.ts new file mode 100644 index 00000000000..e4d62425f40 --- /dev/null +++ b/templates/content/app/components/editor/database/DatabaseView.recent.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; + +import { + createDatabaseView, + normalizeClientDatabaseViewConfig, + resolveRequestedDatabaseView, + databaseViewHasPersonalQueryChanges, + databaseViewConfigWithSavedQueryState, +} from "./DatabaseView"; + +describe("exact personal View navigation", () => { + const table = createDatabaseView("table"); + const board = createDatabaseView("Board", "board", {}, "board"); + const saved = normalizeClientDatabaseViewConfig({ + activeViewId: table.id, + views: [table, board], + }); + + it("opens the requested View without changing the shared default", () => { + const personal = resolveRequestedDatabaseView(saved, board.id)!; + expect(personal.activeViewId).toBe(board.id); + expect(saved.activeViewId).toBe(table.id); + expect(databaseViewHasPersonalQueryChanges(personal, saved)).toBe(true); + expect(databaseViewConfigWithSavedQueryState(personal, saved)).toEqual( + saved, + ); + }); + + it("keeps a missing exact View unavailable instead of opening the default", () => { + expect(resolveRequestedDatabaseView(saved, "missing-view")).toBeNull(); + }); + + it("preserves the effective personal View on an ordinary Page open", () => { + const personal = resolveRequestedDatabaseView(saved, board.id)!; + expect(resolveRequestedDatabaseView(personal, null)).toBe(personal); + }); +}); diff --git a/templates/content/app/components/editor/database/DatabaseView.tsx b/templates/content/app/components/editor/database/DatabaseView.tsx index 1c1a0873cd4..299e385a66c 100644 --- a/templates/content/app/components/editor/database/DatabaseView.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.tsx @@ -45,6 +45,7 @@ import { type DocumentPropertyType, type DocumentPropertyValue, } from "@shared/api"; +import { contentRecentHref } from "@shared/content-personal-navigation"; import { contentDatabaseFormQuestions } from "@shared/database-form"; import { applyContentDatabaseTableQuery } from "@shared/database-query"; import { @@ -202,6 +203,7 @@ import { useUpdateContentDatabaseView, writeBuilderAttachPreviewToCache, } from "@/hooks/use-content-database"; +import { useRecordContentVisit } from "@/hooks/use-content-recent"; import { useContentSpaces, useDeleteContentSpace, @@ -299,6 +301,8 @@ export interface DatabaseViewProps { renderMode?: "page" | "inline"; canEdit?: boolean; isActive?: boolean; + requestedViewId?: string | null; + foreground?: boolean; onExportContextChange?: (context: DatabaseExportContext | null) => void; } @@ -748,6 +752,8 @@ export function DatabaseView({ renderMode = "page", canEdit = true, isActive, + requestedViewId, + foreground = false, onExportContextChange, }: DatabaseViewProps) { const { data: document } = useDocument(databaseDocumentId); @@ -764,6 +770,8 @@ export function DatabaseView({ renderMode={renderMode} canEdit={effectiveCanEdit} isActive={isActive ?? renderMode === "page"} + requestedViewId={requestedViewId} + foreground={foreground} onExportContextChange={onExportContextChange} /> ); @@ -777,6 +785,8 @@ function DatabaseTable({ renderMode, canEdit, isActive, + requestedViewId: exactRequestedViewId, + foreground, onExportContextChange, }: { document: Document; @@ -786,6 +796,8 @@ function DatabaseTable({ renderMode: "page" | "inline"; canEdit: boolean; isActive: boolean; + requestedViewId?: string | null; + foreground: boolean; onExportContextChange?: (context: DatabaseExportContext | null) => void; }) { const t = useT(); @@ -798,6 +810,9 @@ function DatabaseTable({ null, ); const [searchQuery, setSearchQuery] = useState(""); + const [hydratedDatabaseId, setHydratedDatabaseId] = useState( + null, + ); const [viewConfig, setViewConfig] = useState( defaultDatabaseViewConfig(), ); @@ -879,7 +894,8 @@ function DatabaseTable({ ); const serializedSearchParams = searchParams.toString(); const requestedViewId = - searchParams.get(viewSelectionSearchParam)?.trim() || null; + exactRequestedViewId ?? + (searchParams.get(viewSelectionSearchParam)?.trim() || null); const personalViewDatabaseId = data?.database.id ?? null; const newDatabaseRowLabel = isWorkspaceCatalog ? t("sidebar.addWorkspace") @@ -1947,7 +1963,7 @@ function DatabaseTable({ (current) => databaseSearchParamsWithSelectedView( current, - viewSelectionSearchParam, + exactRequestedViewId ? "viewId" : viewSelectionSearchParam, normalized.activeViewId, ), { replace: true }, @@ -2568,13 +2584,18 @@ function DatabaseTable({ const nextSavedViewConfig = normalizeClientDatabaseViewConfig( data.database.viewConfig, ); - const nextViewConfig = applyPersonalDatabaseViewOverrides( + const personalViewConfig = applyPersonalDatabaseViewOverrides( nextSavedViewConfig, normalizePersonalDatabaseViewOverrides(personalView.data?.overrides), ); + if ( + exactRequestedViewId && + !resolveRequestedDatabaseView(personalViewConfig, exactRequestedViewId) + ) + return; const reconciled = reconcileDatabaseViewSelection({ savedViewConfig: nextSavedViewConfig, - viewConfig: nextViewConfig, + viewConfig: personalViewConfig, requestedViewId, }); if (!reconciled.requestedViewExists) { @@ -2592,6 +2613,7 @@ function DatabaseTable({ data.database.id, reconciled.viewConfig, ); + setHydratedDatabaseId(data.database.id); if (hydratedViewRef.current === nextKey) return; hydratedViewRef.current = nextKey; setSavedViewConfig(reconciled.savedViewConfig); @@ -2612,6 +2634,7 @@ function DatabaseTable({ personalView.data?.overrides, personalView.isLoading, requestedViewId, + exactRequestedViewId, renderMode, serializedSearchParams, setSearchParams, @@ -2699,6 +2722,58 @@ function DatabaseTable({ viewConfig, ]); + const exactViewUnavailable = + !!exactRequestedViewId && + (database.isError || + isContentDatabaseUnavailable(database.data) || + (!!data && + !resolveRequestedDatabaseView( + normalizeClientDatabaseViewConfig(data.database.viewConfig), + exactRequestedViewId, + ))); + useRecordContentVisit( + { documentId: document.id, databaseId, viewId: activeView.id }, + foreground && + renderMode === "page" && + isActive && + database.isSuccess && + database.isFetchedAfterMount && + !attachPreviewActive && + !personalView.isLoading && + !personalView.isError && + !!data && + hydratedDatabaseId === databaseId && + !exactViewUnavailable && + (!requestedViewId || activeView.id === requestedViewId) && + normalizeClientDatabaseViewConfig(data.database.viewConfig).views.some( + (view) => view.id === activeView.id, + ), + ); + + function selectPersonalView(viewId: string) { + const next = selectDatabaseView(viewConfig, viewId); + setPersonalQueryDirty( + databaseViewHasPersonalQueryChanges(next, savedViewConfig), + ); + setViewConfig(next); + schedulePersonalDatabaseViewOverrideWrite(databaseId, next); + if (foreground && renderMode === "page" && isActive) { + navigate( + contentRecentHref({ documentId: document.id, databaseId, viewId }), + ); + } else { + setSearchParams( + (current) => + databaseSearchParamsWithSelectedView( + current, + viewSelectionSearchParam, + viewId, + ), + { replace: true }, + ); + } + } + function resizeColumn( key: ColumnKey, defaultWidth: number, @@ -2732,6 +2807,14 @@ function DatabaseTable({ globalThis.document.addEventListener("pointerup", handlePointerUp); } + if (exactViewUnavailable) { + return ( +
+ {t("empty.documentUnavailable")} +
+ ); + } + return (
@@ -2739,6 +2822,7 @@ function DatabaseTable({ viewConfig={viewConfig} canEdit={effectiveCanEdit} onViewConfigChange={handleViewConfigChange} + onViewSelect={selectPersonalView} />
{searchOpen ? ( @@ -13374,6 +13458,15 @@ export function reconcileDatabaseViewSelection({ }; } +export function resolveRequestedDatabaseView( + config: ContentDatabaseViewConfig, + requestedViewId?: string | null, +): ContentDatabaseViewConfig | null { + if (!requestedViewId) return config; + if (!config.views.some((view) => view.id === requestedViewId)) return null; + return selectDatabaseView(config, requestedViewId); +} + export function addDatabaseView( config: ContentDatabaseViewConfig, name: string, @@ -14860,10 +14953,12 @@ function DatabaseViewTabs({ viewConfig, canEdit, onViewConfigChange, + onViewSelect, }: { viewConfig: ContentDatabaseViewConfig; canEdit: boolean; onViewConfigChange: (viewConfig: ContentDatabaseViewConfig) => void; + onViewSelect: (viewId: string) => void; }) { const normalized = normalizeClientDatabaseViewConfig(viewConfig); const [newViewName, setNewViewName] = useState(""); @@ -15058,7 +15153,7 @@ function DatabaseViewTabs({ return; } if (!active) { - onViewConfigChange(selectDatabaseView(normalized, view.id)); + onViewSelect(view.id); } }} onContextMenu={(event) => { diff --git a/templates/content/app/components/sidebar/DocumentSidebar.tsx b/templates/content/app/components/sidebar/DocumentSidebar.tsx index 3023078f160..e64d48dc533 100644 --- a/templates/content/app/components/sidebar/DocumentSidebar.tsx +++ b/templates/content/app/components/sidebar/DocumentSidebar.tsx @@ -42,6 +42,7 @@ import { toast } from "sonner"; import { ContentFilesSidebarView, + contentSidebarOrderedItems, type ContentFilesSidebarRenderReorder, } from "@/components/editor/database/sidebar"; import { QueryErrorState } from "@/components/QueryErrorState"; @@ -79,7 +80,6 @@ import { removeOptimisticItemFromContentDatabase, useContentDatabaseById, useContentDatabasePersonalView, - useMoveDatabaseItem, useUpdateContentDatabasePersonalView, useCreateContentDatabase, useDeleteContentDatabase, @@ -129,6 +129,7 @@ import { localSourceItemIdentity, projectLocalSourceHierarchy, } from "./local-source-hierarchy"; +import { PersonalSidebarSections } from "./PersonalSidebarSections"; import { contentSpaceAvailability, contentSpaceForStoredSelection, @@ -914,9 +915,16 @@ export function DocumentSidebar({ ); const updateWorkspaceCatalogPersonalView = useUpdateContentDatabasePersonalView(resolvedWorkspaceCatalogDatabaseId); - const movePinnedItem = useMoveDatabaseItem(favoritesDocumentId ?? ""); - const moveWorkspaceItem = useMoveDatabaseItem( - workspaceCatalogDocumentId ?? "", + const favoritesPersonalView = + useContentDatabasePersonalView(favoritesDatabaseId); + const updateFavoritesPersonalView = + useUpdateContentDatabasePersonalView(favoritesDatabaseId); + const favoritesData = isContentDatabaseUnavailable(favoritesDatabase.data) + ? undefined + : favoritesDatabase.data; + const favoritesOrder = personalSidebarOrderForDatabase( + favoritesData, + favoritesPersonalView.data?.overrides, ); const attemptedSpaceReconciliationKeyRef = useRef(null); const spaceReconciliationRetryTimerRef = useRef { - if (!favoritesDatabaseId) return; - movePinnedItem.mutate( + (itemIds: string[]) => { + if (!favoritesDatabaseId || !favoritesData) return; + const allIds = contentSidebarOrderedItems( + favoritesData.items, + favoritesOrder.order, + ).map((item) => item.id); + const order = { + mode: "custom" as const, + itemIds: [...itemIds, ...allIds.filter((id) => !itemIds.includes(id))], + }; + updateFavoritesPersonalView.mutate( { databaseId: favoritesDatabaseId, - itemId: moved.itemId, - position: moved.position, - }, - { - onError: (error) => { - toast.error(t("sidebar.failedSaveOrder"), { - description: - error instanceof Error - ? error.message - : t("empty.genericError"), - }); - }, + overrides: withPersonalSidebarOrder( + favoritesData, + favoritesPersonalView.data?.overrides, + favoritesOrder.activeViewId, + order, + ), }, + { onError: () => toast.error(t("sidebar.failedSaveOrder")) }, ); }, - [favoritesDatabaseId, movePinnedItem, t], + [ + favoritesDatabaseId, + favoritesData, + favoritesOrder, + favoritesPersonalView.data?.overrides, + updateFavoritesPersonalView, + t, + ], ); - const handleWorkspaceReorder = useCallback( - (_itemIds: string[], moved: { itemId: string; position: number }) => { - if (!workspaceCatalogDatabaseId) return; - moveWorkspaceItem.mutate( + (itemIds: string[]) => { + if (!workspaceCatalogDatabaseId || !workspaceCatalogDatabaseData) return; + const current = workspaceCatalogPersonalView.data?.overrides; + const selected = personalSidebarOrderForDatabase( + workspaceCatalogDatabaseData, + current, + ); + updateWorkspaceCatalogPersonalView.mutate( { databaseId: workspaceCatalogDatabaseId, - itemId: moved.itemId, - position: moved.position, - }, - { - onError: (error) => { - toast.error(t("sidebar.failedSaveOrder"), { - description: - error instanceof Error - ? error.message - : t("empty.genericError"), - }); - }, + overrides: withPersonalSidebarOrder( + workspaceCatalogDatabaseData, + current, + selected.activeViewId, + { mode: "custom", itemIds }, + ), }, + { onError: () => toast.error(t("sidebar.failedSaveOrder")) }, ); }, - [moveWorkspaceItem, t, workspaceCatalogDatabaseId], + [ + workspaceCatalogDatabaseId, + workspaceCatalogDatabaseData, + workspaceCatalogPersonalView.data?.overrides, + updateWorkspaceCatalogPersonalView, + t, + ], ); - const handleToggleFavorite = useCallback( (id: string, isFavorite: boolean) => { updateDocument.mutate( @@ -2075,6 +2097,12 @@ export function DocumentSidebar({ ) : ( <> - {/* Pinned */} - {showFavorites && ( -
-
- - + favoritesDatabase.isError || favoritesPersonalView.isError ? ( + { + void favoritesDatabase.refetch(); + void favoritesPersonalView.refetch(); + }} + /> + ) : ( + - {t("sidebar.pinned")} - -
- {!collapsedSections.favorites ? ( - favoritesDatabase.isError ? ( - void favoritesDatabase.refetch()} - retrying={favoritesDatabase.isFetching} - /> - ) : ( - { - const document = documents.find( - (candidate) => candidate.id === item.document.id, - ); - const space = document - ? contentSpaces.find( - (candidate) => - candidate.filesDocumentId === - document.databaseMembership - ?.databaseDocumentId, - ) - : undefined; - if (!space || selectedSpace?.id === space.id) { - onNavigate?.(); - return false; - } - void handleSelectContentSpace( - space, - item.document.id, - ); + overrides={favoritesPersonalView.data?.overrides} + sidebarOrder={favoritesOrder.order} + isLoading={ + favoritesDatabase.isLoading || + favoritesPersonalView.isLoading + } + activeDocumentId={activeDocumentId} + manualReorder={ + updateFavoritesPersonalView.isPending + ? undefined + : { + labels: sidebarReorderLabels, + onReorder: handlePinnedReorder, + } + } + onOpenItem={(item) => { + const document = documents.find( + (candidate) => candidate.id === item.document.id, + ); + const space = document + ? contentSpaces.find( + (candidate) => + candidate.filesDocumentId === + document.databaseMembership?.databaseDocumentId, + ) + : undefined; + if (!space || selectedSpace?.id === space.id) { onNavigate?.(); - return true; - }} - onCreateChildPage={(item) => - void handleCreatePage(item.document.id) + return false; } - onCreateChildDatabase={(item) => - void handleCreateDatabase(item.document.id) - } - onDeleteItem={(item) => - requestDelete( - item.document.id, - item.document.title || t("sidebar.untitled"), - ) - } - onToggleFavorite={(item) => - handleToggleFavorite(item.document.id, false) - } - scroll={false} - labels={{ - noMatchesLabel: t("database.noRowsMatchThisView"), - clearLabel: t("database.clearSearchAndFilters"), - navigationLabel: t("sidebar.pinned"), - untitledLabel: t("sidebar.untitled"), - }} - /> - ) - ) : null} -
- )} - - {renderWorkspaceNavigation()} + void handleSelectContentSpace(space, item.document.id); + onNavigate?.(); + return true; + }} + onCreateChildPage={(item) => + void handleCreatePage(item.document.id) + } + onCreateChildDatabase={(item) => + void handleCreateDatabase(item.document.id) + } + onDeleteItem={(item) => + requestDelete( + item.document.id, + item.document.title || t("sidebar.untitled"), + ) + } + onToggleFavorite={(item) => + handleToggleFavorite(item.document.id, false) + } + scroll={false} + labels={{ + noMatchesLabel: t("database.noRowsMatchThisView"), + clearLabel: t("database.clearSearchAndFilters"), + navigationLabel: t("sidebar.pinned"), + untitledLabel: t("sidebar.untitled"), + }} + /> + ) + } + /> {renderTrashSection()} )} diff --git a/templates/content/app/components/sidebar/PersonalSidebarSections.tsx b/templates/content/app/components/sidebar/PersonalSidebarSections.tsx new file mode 100644 index 00000000000..ef6c765a2fb --- /dev/null +++ b/templates/content/app/components/sidebar/PersonalSidebarSections.tsx @@ -0,0 +1,334 @@ +import { + useActionMutation, + useActionQuery, +} from "@agent-native/core/client/hooks"; +import { useT } from "@agent-native/core/client/i18n"; +import { + contentRecentHref, + contentRecentTargetKey, + defaultContentSidebarSections, + type ContentSidebarSections, + type ContentSidebarSectionId, +} from "@shared/content-personal-navigation"; +import { IconChevronRight, IconDots } from "@tabler/icons-react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useRef, useState, type ReactNode } from "react"; +import { Link } from "react-router"; +import { toast } from "sonner"; + +import { QueryErrorState } from "@/components/QueryErrorState"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useContentRecent } from "@/hooks/use-content-recent"; +import { cn } from "@/lib/utils"; + +import { + SidebarReorderProvider, + useSidebarReorderItem, + type SidebarReorderLabels, +} from "./sidebar-reorder"; + +const stateKey = ["action", "get-content-sidebar-state", {}]; + +export function PersonalSidebarSections({ + renderPinned, + pinnedCount, + renderWorkspaces, + onNavigate, + reorderLabels, +}: { + renderPinned: (limit: number) => ReactNode; + pinnedCount: number; + renderWorkspaces: () => ReactNode; + onNavigate?: () => void; + reorderLabels: SidebarReorderLabels; +}) { + const t = useT(); + const queryClient = useQueryClient(); + const state = useActionQuery("get-content-sidebar-state", {}); + const recent = useContentRecent(); + const update = useActionMutation("update-content-sidebar-state", { + skipActionQueryInvalidation: true, + }); + const [optimistic, setOptimistic] = useState( + null, + ); + const pending = useRef(0); + const queue = useRef>(Promise.resolve()); + const sections = + optimistic ?? + state.data?.state?.sections ?? + defaultContentSidebarSections(); + function save(next: ContentSidebarSections) { + setOptimistic(next); + pending.current++; + queue.current = queue.current + .catch(() => undefined) + .then(async () => { + try { + const saved = await update.mutateAsync({ + version: 1, + sections: next, + }); + queryClient.setQueryData(stateKey, saved); + } catch (error) { + toast.error(t("sidebar.failedSaveSidebarState")); + throw error; + } finally { + pending.current--; + if (pending.current === 0) setOptimistic(null); + } + }); + // The error is displayed above; keep the queue usable for a later explicit change. + void queue.current.catch(() => undefined); + } + function change( + id: "pinned" | "recent", + patch: Partial, + ) { + save({ ...sections, [id]: { ...sections[id], ...patch } }); + } + const labels = { + pinned: t("sidebar.pinned"), + recent: t("sidebar.recent"), + workspaces: t("sidebar.workspaces"), + }; + if (state.isError) + return ( + <> + void state.refetch()} /> + {renderWorkspaces()} + + ); + if (state.isLoading) + return ( + <> + + {renderWorkspaces()} + + ); + return ( + <> +
+ + + + + + + {(["pinned", "recent"] as const).map((id) => ( + change(id, { visible })} + > + {labels[id]} + + ))} + + + +
+ ({ + id, + label: labels[id], + parentId: null, + }))} + labels={reorderLabels} + onReorder={(ids) => + save({ ...sections, order: ids as ContentSidebarSectionId[] }) + } + > + {sections.order.map((id) => + id === "workspaces" ? ( + + {renderWorkspaces()} + + ) : sections[id].visible ? ( + change(id, { expanded: !sections[id].expanded })} + reorderLabels={reorderLabels} + > + {sections[id].expanded && ( + <> + {id === "pinned" ? ( + renderPinned(sections.pinned.limit) + ) : recent.isError ? ( + void recent.refetch()} + retrying={recent.isFetching} + /> + ) : recent.isLoading ? ( + + ) : recent.data?.entries.length ? ( + + ) : ( +

+ {t("sidebar.noRecentVisits")} +

+ )} + {(id === "pinned" + ? pinnedCount + : (recent.data?.entries.length ?? 0)) > + sections[id].limit && + sections[id].limit < 50 && ( + + )} + {sections[id].limit > 5 && ( + + )} + + )} +
+ ) : null, + )} +
+ + ); +} + +function PersonalSection({ + id, + label, + expanded, + onToggle, + reorderLabels, + children, +}: { + id: ContentSidebarSectionId; + label: string; + expanded?: boolean; + onToggle?: () => void; + reorderLabels: SidebarReorderLabels; + children: ReactNode; +}) { + const reorder = useSidebarReorderItem(id); + return ( +
+
+ {onToggle && ( + + )} + + + + + + + + + {reorderLabels.moveUp} + + + {reorderLabels.moveDown} + + + + +
+ {children} +
+ ); +} diff --git a/templates/content/app/hooks/use-content-recent.ts b/templates/content/app/hooks/use-content-recent.ts new file mode 100644 index 00000000000..7d2b8cbb0ef --- /dev/null +++ b/templates/content/app/hooks/use-content-recent.ts @@ -0,0 +1,78 @@ +import { + useActionMutation, + useActionQuery, +} from "@agent-native/core/client/hooks"; +import { useT } from "@agent-native/core/client/i18n"; +import { useOrg } from "@agent-native/core/client/org"; +import { + contentRecentTargetKey, + type ContentRecentTarget, +} from "@shared/content-personal-navigation"; +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback, useEffect, useRef } from "react"; +import { toast } from "sonner"; + +export function useContentRecent() { + const org = useOrg(); + const scopeKey = org.data + ? JSON.stringify([ + org.data.email.trim().toLowerCase(), + org.data.orgId ?? null, + ]) + : undefined; + const query = useActionQuery( + "get-content-recent", + { scopeKey }, + { + enabled: Boolean(scopeKey) && !org.isFetching, + placeholderData: undefined, + }, + ); + return { + ...query, + data: + !org.isFetching && query.data?.scopeKey === scopeKey + ? query.data + : undefined, + isLoading: org.isLoading || org.isFetching || query.isLoading, + isError: org.isError || query.isError, + }; +} + +export function useContentVisitRecorder() { + const queryClient = useQueryClient(); + const t = useT(); + const mutation = useActionMutation("record-content-visit", { + skipActionQueryInvalidation: true, + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: ["action", "get-content-recent"], + }); + }, + onError: () => { + toast.error(t("sidebar.failedSaveSidebarState")); + }, + }); + const mutationRef = useRef(mutation); + mutationRef.current = mutation; + return useCallback((target: ContentRecentTarget) => { + mutationRef.current.mutate(target); + }, []); +} + +export function useRecordContentVisit( + target: ContentRecentTarget | null, + enabled: boolean, +) { + const record = useContentVisitRecorder(); + const targetRef = useRef(target); + targetRef.current = target; + const key = target ? contentRecentTargetKey(target) : null; + const recordedKey = useRef(null); + useEffect(() => { + if (!enabled || !key) return; + if (recordedKey.current === key || !targetRef.current) return; + recordedKey.current = key; + record(targetRef.current); + }, [enabled, key, record]); +} diff --git a/templates/content/app/i18n-data.ts b/templates/content/app/i18n-data.ts index 1e67e25336d..d5b19e66ebe 100644 --- a/templates/content/app/i18n-data.ts +++ b/templates/content/app/i18n-data.ts @@ -3459,6 +3459,11 @@ const enUS = { sidebar: { cannotReorderPages: "Cannot reorder pages", pinned: "Pinned", + recent: "Recent", + customizeSidebar: "Customize sidebar", + noRecentVisits: "No recent visits", + showMore: "Show more", + showLess: "Show less", loadingPinned: "Loading pinned items…", dragToReorder: "Drag to reorder {{label}}", moveUp: "Move up", @@ -11821,6 +11826,11 @@ const sidebarPinnedMessagesByLocale: Partial< Record> > = { "zh-TW": { + recent: "最近瀏覽", + customizeSidebar: "自訂側邊欄", + noRecentVisits: "尚無最近瀏覽記錄", + showMore: "顯示更多", + showLess: "顯示更少", pinned: "已釘選", loadingPinned: "正在載入已釘選項目…", dragToReorder: "拖曳以重新排序 {{label}}", @@ -11844,6 +11854,11 @@ const sidebarPinnedMessagesByLocale: Partial< favorites: "已釘選", }, "zh-CN": { + recent: "最近访问", + customizeSidebar: "自定义侧边栏", + noRecentVisits: "暂无最近访问记录", + showMore: "显示更多", + showLess: "显示更少", pinned: "已固定", loadingPinned: "正在加载已固定项目…", dragToReorder: "拖动以重新排序 {{label}}", @@ -11867,6 +11882,11 @@ const sidebarPinnedMessagesByLocale: Partial< favorites: "已固定", }, "es-ES": { + recent: "Recientes", + customizeSidebar: "Personalizar barra lateral", + noRecentVisits: "No hay visitas recientes", + showMore: "Mostrar más", + showLess: "Mostrar menos", pinned: "Fijados", loadingPinned: "Cargando elementos fijados…", dragToReorder: "Arrastra para reordenar {{label}}", @@ -11890,6 +11910,11 @@ const sidebarPinnedMessagesByLocale: Partial< favorites: "Fijados", }, "fr-FR": { + recent: "Récents", + customizeSidebar: "Personnaliser la barre latérale", + noRecentVisits: "Aucune visite récente", + showMore: "Afficher plus", + showLess: "Afficher moins", pinned: "Épinglés", loadingPinned: "Chargement des éléments épinglés…", dragToReorder: "Faites glisser {{label}} pour le réorganiser", @@ -11913,6 +11938,11 @@ const sidebarPinnedMessagesByLocale: Partial< favorites: "Épinglés", }, "de-DE": { + recent: "Zuletzt besucht", + customizeSidebar: "Seitenleiste anpassen", + noRecentVisits: "Keine letzten Besuche", + showMore: "Mehr anzeigen", + showLess: "Weniger anzeigen", pinned: "Angeheftet", loadingPinned: "Angeheftete Elemente werden geladen…", dragToReorder: "{{label}} zum Neuordnen ziehen", @@ -11937,6 +11967,11 @@ const sidebarPinnedMessagesByLocale: Partial< favorites: "Angeheftet", }, "ja-JP": { + recent: "最近の閲覧", + customizeSidebar: "サイドバーをカスタマイズ", + noRecentVisits: "最近の閲覧はありません", + showMore: "もっと見る", + showLess: "表示を減らす", pinned: "固定済み", loadingPinned: "固定済みの項目を読み込み中…", dragToReorder: "{{label}} をドラッグして並べ替え", @@ -11960,6 +11995,11 @@ const sidebarPinnedMessagesByLocale: Partial< favorites: "固定済み", }, "ko-KR": { + recent: "최근 방문", + customizeSidebar: "사이드바 사용자 지정", + noRecentVisits: "최근 방문 기록 없음", + showMore: "더 보기", + showLess: "간략히 보기", pinned: "고정됨", loadingPinned: "고정된 항목 불러오는 중…", dragToReorder: "{{label}} 드래그하여 순서 변경", @@ -11983,6 +12023,11 @@ const sidebarPinnedMessagesByLocale: Partial< favorites: "고정됨", }, "pt-BR": { + recent: "Recentes", + customizeSidebar: "Personalizar barra lateral", + noRecentVisits: "Nenhuma visita recente", + showMore: "Mostrar mais", + showLess: "Mostrar menos", pinned: "Fixados", loadingPinned: "Carregando itens fixados…", dragToReorder: "Arraste para reordenar {{label}}", @@ -12006,6 +12051,11 @@ const sidebarPinnedMessagesByLocale: Partial< favorites: "Fixados", }, "hi-IN": { + recent: "हाल ही में देखे गए", + customizeSidebar: "साइडबार अनुकूलित करें", + noRecentVisits: "हाल ही में कोई पेज नहीं देखा", + showMore: "और दिखाएँ", + showLess: "कम दिखाएँ", pinned: "पिन किए गए", loadingPinned: "पिन किए गए आइटम लोड हो रहे हैं…", dragToReorder: "{{label}} को फिर से क्रमबद्ध करने के लिए खींचें", @@ -12029,6 +12079,11 @@ const sidebarPinnedMessagesByLocale: Partial< favorites: "पिन किए गए", }, "ar-SA": { + recent: "الأخيرة", + customizeSidebar: "تخصيص الشريط الجانبي", + noRecentVisits: "لا توجد زيارات حديثة", + showMore: "عرض المزيد", + showLess: "عرض أقل", pinned: "مثبتة", loadingPinned: "جارٍ تحميل العناصر المثبتة…", dragToReorder: "اسحب لإعادة ترتيب {{label}}", diff --git a/templates/content/app/routes/_app.page.$id.tsx b/templates/content/app/routes/_app.page.$id.tsx index d30dcbecdfa..6c9a6689791 100644 --- a/templates/content/app/routes/_app.page.$id.tsx +++ b/templates/content/app/routes/_app.page.$id.tsx @@ -26,6 +26,8 @@ export default function DocumentPage() { return id ? ( diff --git a/templates/content/shared/content-personal-navigation.test.ts b/templates/content/shared/content-personal-navigation.test.ts new file mode 100644 index 00000000000..80f6a6ab662 --- /dev/null +++ b/templates/content/shared/content-personal-navigation.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; + +import { + contentRecentHref, + readContentRecentState, + recordContentRecentVisit, +} from "./content-personal-navigation"; + +describe("personal Recent navigation", () => { + it("preserves a newer same-target visit when an earlier request completes last", () => { + const target = { documentId: "page", databaseId: "db", viewId: "board" }; + let state = recordContentRecentVisit(readContentRecentState(null), { + target, + visitedAt: "2026-09-09T12:00:00.000Z", + }); + state = recordContentRecentVisit(state, { + target: { documentId: "another-page" }, + visitedAt: "2026-09-09T11:00:00.000Z", + }); + state = recordContentRecentVisit(state, { + target, + visitedAt: "2026-09-09T10:00:00.000Z", + }); + expect(state.entries).toEqual([ + { target, visitedAt: "2026-09-09T12:00:00.000Z" }, + { + target: { documentId: "another-page" }, + visitedAt: "2026-09-09T11:00:00.000Z", + }, + ]); + }); + + it("retains separate Views and promotes only the explicitly visited identity", () => { + const table = { documentId: "page", databaseId: "db", viewId: "table" }; + const board = { ...table, viewId: "board" }; + let state = readContentRecentState(null); + state = recordContentRecentVisit(state, { + target: table, + visitedAt: "2026-09-09T10:00:00.000Z", + }); + state = recordContentRecentVisit(state, { + target: board, + visitedAt: "2026-09-09T11:00:00.000Z", + }); + state = recordContentRecentVisit(state, { + target: table, + visitedAt: "2026-09-09T12:00:00.000Z", + }); + expect(state.entries.map((entry) => entry.target.viewId)).toEqual([ + "table", + "board", + ]); + expect(contentRecentHref(table)).toBe( + "/page/page?databaseId=db&viewId=table", + ); + }); + + it("bounds navigation history without storing target metadata", () => { + let state = readContentRecentState(null); + for (let i = 0; i < 60; i++) + state = recordContentRecentVisit(state, { + target: { documentId: `page-${i}` }, + visitedAt: new Date(i * 1000).toISOString(), + }); + expect(state.entries).toHaveLength(50); + expect(state.entries[0].target.documentId).toBe("page-59"); + expect(state.entries[49].target.documentId).toBe("page-10"); + expect(() => + readContentRecentState({ version: 1, entries: "bad" }), + ).toThrow(); + }); +}); diff --git a/templates/content/shared/content-personal-navigation.ts b/templates/content/shared/content-personal-navigation.ts new file mode 100644 index 00000000000..4bb37986254 --- /dev/null +++ b/templates/content/shared/content-personal-navigation.ts @@ -0,0 +1,105 @@ +import { z } from "zod"; + +export const CONTENT_RECENT_LIMIT = 50; +export const contentSidebarSectionIdSchema = z.enum([ + "pinned", + "recent", + "workspaces", +]); +export type ContentSidebarSectionId = z.infer< + typeof contentSidebarSectionIdSchema +>; +export const contentSidebarSectionsSchema = z.object({ + order: z + .array(contentSidebarSectionIdSchema) + .length(3) + .refine((ids) => new Set(ids).size === 3), + pinned: z.object({ + visible: z.boolean(), + expanded: z.boolean(), + limit: z.number().int().min(5).max(50), + }), + recent: z.object({ + visible: z.boolean(), + expanded: z.boolean(), + limit: z.number().int().min(5).max(50), + }), +}); +export type ContentSidebarSections = z.infer< + typeof contentSidebarSectionsSchema +>; +export function defaultContentSidebarSections(): ContentSidebarSections { + return { + order: ["pinned", "recent", "workspaces"], + pinned: { visible: true, expanded: true, limit: 5 }, + recent: { visible: true, expanded: true, limit: 5 }, + }; +} + +const identitySchema = z.string().min(1).max(256); +export const contentRecentTargetSchema = z + .object({ + documentId: identitySchema, + databaseId: identitySchema.optional(), + viewId: identitySchema.optional(), + }) + .refine((target) => !target.viewId || Boolean(target.databaseId)); +export type ContentRecentTarget = z.infer; +export const contentRecentEntrySchema = z.object({ + target: contentRecentTargetSchema, + visitedAt: z.string().datetime(), +}); +export type ContentRecentEntry = z.infer; +export const contentRecentStateSchema = z.object({ + version: z.literal(1), + entries: z.array(contentRecentEntrySchema).max(CONTENT_RECENT_LIMIT), +}); +export type ContentRecentState = z.infer; +export type ContentRecentResult = ContentRecentEntry & { + title: string; + icon: string | null; + viewName: string | null; +}; + +export function contentRecentTargetKey(target: ContentRecentTarget) { + return JSON.stringify([ + target.documentId, + target.databaseId ?? null, + target.viewId ?? null, + ]); +} + +export function readContentRecentState(value: unknown): ContentRecentState { + if (value === null) return { version: 1, entries: [] }; + return contentRecentStateSchema.parse(value); +} + +export function recordContentRecentVisit( + state: ContentRecentState, + entry: ContentRecentEntry, +): ContentRecentState { + const key = contentRecentTargetKey(entry.target); + const existing = state.entries.find( + (candidate) => contentRecentTargetKey(candidate.target) === key, + ); + if (existing && existing.visitedAt > entry.visitedAt) return state; + return { + version: 1, + entries: [ + entry, + ...state.entries.filter( + (candidate) => contentRecentTargetKey(candidate.target) !== key, + ), + ] + .sort((a, b) => b.visitedAt.localeCompare(a.visitedAt)) + .slice(0, CONTENT_RECENT_LIMIT), + }; +} + +export function contentRecentHref(target: ContentRecentTarget) { + const params = new URLSearchParams(); + if (target.databaseId) params.set("databaseId", target.databaseId); + if (target.viewId) params.set("viewId", target.viewId); + const query = params.toString(); + return `/page/${encodeURIComponent(target.documentId)}${query ? `?${query}` : ""}`; +} From 27987ab2e2b2f2af228744f329e2365f48819beb Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Wed, 9 Sep 2026 11:43:07 -0400 Subject: [PATCH 02/20] fix(content): preserve personal navigation during sidebar reordering --- templates/content/AGENTS.md | 12 +- .../_content-database-personal-view.ts | 27 +- .../content/actions/_content-sidebar-state.ts | 12 +- .../content-personal-navigation-patch.test.ts | 301 ++++++++++++++++++ .../content/actions/content-recent.test.ts | 1 - .../actions/content-sidebar-state.test.ts | 50 ++- .../update-content-database-personal-view.ts | 111 ++++++- .../actions/update-content-sidebar-state.ts | 2 - .../editor/database/DatabaseView.tsx | 11 +- .../editor/database/sidebar.test.tsx | 77 ++++- .../components/editor/database/sidebar.tsx | 4 +- .../sidebar/DocumentSidebar.layout.test.ts | 33 +- .../components/sidebar/DocumentSidebar.tsx | 41 +-- .../sidebar/PersonalSidebarSections.tsx | 36 ++- .../sidebar/sidebar-reorder.test.ts | 99 +++++- .../components/sidebar/sidebar-reorder.tsx | 17 +- .../use-content-database.foreground.test.ts | 86 +++++ .../content/app/hooks/use-content-database.ts | 2 + .../hooks/use-content-personal-navigation.ts | 59 ++++ templates/content/app/i18n/zh-TW.ts | 5 + .../content.navigation.sidebar.md | 16 +- .../content-personal-navigation-patch.test.ts | 88 +++++ .../content-personal-navigation-patch.ts | 66 ++++ 23 files changed, 1074 insertions(+), 82 deletions(-) create mode 100644 templates/content/actions/content-personal-navigation-patch.test.ts create mode 100644 templates/content/app/hooks/use-content-database.foreground.test.ts create mode 100644 templates/content/app/hooks/use-content-personal-navigation.ts create mode 100644 templates/content/shared/content-personal-navigation-patch.test.ts create mode 100644 templates/content/shared/content-personal-navigation-patch.ts diff --git a/templates/content/AGENTS.md b/templates/content/AGENTS.md index 804719ec0ad..5cc44f75d12 100644 --- a/templates/content/AGENTS.md +++ b/templates/content/AGENTS.md @@ -78,6 +78,7 @@ Read the relevant skill before deeper work: | `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 or ensure their private Personal welcome page | +| `get-content-recent` | Current-context personal visits, resolved with current access and exact View identity | | `edit-document` | Find/replace edit — preferred for small changes | | `update-document` | Full rewrite of title, content, or description | | `delete-document` | Move a page and its children to Trash | @@ -90,11 +91,12 @@ Every action carries its own schema, and the rest of the app-specific surface `remove-local-file-source`) is registered too — use `tool-search` instead of scanning a table here. -Sidebar ordering has two meanings. Reordering Pinned or workspace roots moves -the exact `databaseId` + `itemId` membership, never the document. Files Custom -order is a per-user view preference written with -`update-content-database-personal-view`; shared row ordering still uses -`move-database-item`. +Pinned, workspace roots, and Files Custom order use personal View preferences, +without changing membership positions or parentage. Use the `navigation` patch +on `update-content-database-personal-view` for `activeViewId` or one View's +`sidebarOrder: { viewId, mode, itemIds }`; it atomically preserves other settings. +Shared database row ordering still uses `move-database-item`. Recent records +successful foreground visits; reading or editing a Page does not record a visit. ## Source Changes diff --git a/templates/content/actions/_content-database-personal-view.ts b/templates/content/actions/_content-database-personal-view.ts index 9ca1b260e07..a1c83f03032 100644 --- a/templates/content/actions/_content-database-personal-view.ts +++ b/templates/content/actions/_content-database-personal-view.ts @@ -123,24 +123,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) => !( diff --git a/templates/content/actions/_content-sidebar-state.ts b/templates/content/actions/_content-sidebar-state.ts index a6b8a02e3af..a525bd343bf 100644 --- a/templates/content/actions/_content-sidebar-state.ts +++ b/templates/content/actions/_content-sidebar-state.ts @@ -9,8 +9,8 @@ const expandedIdSchema = z.string().min(1).max(256); export const contentSidebarStateSchema = z.object({ version: z.literal(CONTENT_SIDEBAR_STATE_VERSION), - expandedWorkspaceIds: z.array(expandedIdSchema).max(1_000), - expandedDocumentIds: z.array(expandedIdSchema).max(5_000), + expandedWorkspaceIds: z.array(expandedIdSchema).max(1_000).optional(), + expandedDocumentIds: z.array(expandedIdSchema).max(5_000).optional(), sections: contentSidebarSectionsSchema.optional(), }); @@ -21,7 +21,11 @@ export function normalizeContentSidebarState(value: unknown) { const data = contentSidebarStateSchema.parse(value); return { ...data, - expandedWorkspaceIds: [...new Set(data.expandedWorkspaceIds)], - expandedDocumentIds: [...new Set(data.expandedDocumentIds)], + ...(data.expandedWorkspaceIds === undefined + ? {} + : { expandedWorkspaceIds: [...new Set(data.expandedWorkspaceIds)] }), + ...(data.expandedDocumentIds === undefined + ? {} + : { expandedDocumentIds: [...new Set(data.expandedDocumentIds)] }), }; } diff --git a/templates/content/actions/content-personal-navigation-patch.test.ts b/templates/content/actions/content-personal-navigation-patch.test.ts new file mode 100644 index 00000000000..ef269662277 --- /dev/null +++ b/templates/content/actions/content-personal-navigation-patch.test.ts @@ -0,0 +1,301 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const boundary = vi.hoisted(() => ({ + access: vi.fn(), + select: vi.fn(), + mutate: vi.fn(), + put: vi.fn(), + remove: vi.fn(), + read: vi.fn(), +})); + +vi.mock("@agent-native/core/settings", () => ({ + getUserSetting: boundary.read, + mutateUserSetting: boundary.mutate, + putUserSetting: boundary.put, + deleteUserSetting: boundary.remove, +})); +vi.mock("../server/db/index.js", () => ({ + getDb: () => ({ select: boundary.select }), + schema: { + contentDatabases: { id: "database-id", viewConfigJson: "view-config" }, + contentDatabaseItems: { id: "item-id", databaseId: "database-id" }, + }, +})); +vi.mock("./_content-database-personal-view.js", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("./_content-database-personal-view.js") + >()), + assertContentDatabaseViewerAccess: boundary.access, +})); + +import { + CONTENT_DATABASE_PERSONAL_VIEW_OVERRIDES_VERSION, + type ContentDatabasePersonalViewOverrides, +} from "../shared/api.js"; +import { readPersonalDatabaseViewOverrides } from "./_content-database-personal-view.js"; +import { filesParentPropertyId } from "./_files-system-properties.js"; +import action from "./update-content-database-personal-view.js"; + +const ctx = { userEmail: "navigation@example.test" }; +let saved: unknown; +let systemRole: string | null; + +function initialState(): ContentDatabasePersonalViewOverrides { + return { + version: CONTENT_DATABASE_PERSONAL_VIEW_OVERRIDES_VERSION, + activeViewId: "table", + views: [ + { + id: "table", + sorts: [{ key: "title", label: "Title", direction: "desc" }], + filters: [ + { key: "status", label: "Status", operator: "equals", value: "open" }, + ], + filterMode: "or", + sidebarOrder: { mode: "custom", itemIds: ["old-item"] }, + }, + { + id: "board", + sorts: [{ key: "priority", label: "Priority", direction: "asc" }], + filters: [], + filterMode: "and", + sidebarOrder: { mode: "name", itemIds: ["board-item"] }, + }, + ], + }; +} + +beforeEach(() => { + vi.resetAllMocks(); + saved = initialState(); + systemRole = null; + boundary.access.mockResolvedValue(undefined); + boundary.read.mockImplementation(async () => saved); + boundary.select.mockImplementation((projection: Record) => ({ + from: () => ({ + where: async () => + "viewConfigJson" in projection + ? [ + { + systemRole, + viewConfigJson: JSON.stringify({ + views: initialState().views, + }), + }, + ] + : "systemRole" in projection + ? [{ systemRole }] + : [{ id: "item-a" }, { id: "item-b" }], + }), + })); + // Serialize callbacks at the settings boundary; SQL/CAS belongs to its own tests. + let queue = Promise.resolve(); + boundary.mutate.mockImplementation( + (_email: string, _key: string, mutate: (current: unknown) => unknown) => { + const result = queue.then(() => { + saved = mutate(saved); + return saved; + }); + queue = result.then( + () => undefined, + () => undefined, + ); + return result; + }, + ); +}); + +describe("personal navigation patch action", () => { + it("distinguishes absent personal state from unreadable stored state on reads", async () => { + saved = null; + expect( + await readPersonalDatabaseViewOverrides(ctx.userEmail, "db"), + ).toBeNull(); + saved = { version: 99 }; + await expect( + readPersonalDatabaseViewOverrides(ctx.userEmail, "db"), + ).rejects.toThrow(); + }); + it("seeds the first sidebar override from the shared View's query", async () => { + saved = null; + await action.run( + { + databaseId: "db", + navigation: { + sidebarOrder: { + viewId: "table", + mode: "custom", + itemIds: ["item-b", "item-a"], + }, + }, + }, + ctx, + ); + expect(saved).toMatchObject({ + views: [ + { + ...initialState().views[0], + sidebarOrder: { mode: "custom", itemIds: ["item-b", "item-a"] }, + }, + ], + }); + }); + + it.each([null, "files"])( + "migrates v1 inside the atomic update (system role: %s)", + async (role) => { + systemRole = role; + const original = initialState(); + const legacyParentFilter = { + key: filesParentPropertyId("db"), + label: "Parent", + operator: "is_empty" as const, + value: "", + }; + saved = { + ...original, + version: 1, + views: [ + { + ...original.views[0], + filters: [...original.views[0].filters, legacyParentFilter], + }, + original.views[1], + ], + }; + const migratedRead = await readPersonalDatabaseViewOverrides( + ctx.userEmail, + "db", + ); + await action.run( + { databaseId: "db", navigation: { activeViewId: "board" } }, + ctx, + ); + expect(saved).toEqual({ ...migratedRead, activeViewId: "board" }); + expect(saved).toEqual({ + ...original, + activeViewId: "board", + views: [ + { + ...original.views[0], + filters: + role === "files" + ? original.views[0].filters + : [...original.views[0].filters, legacyParentFilter], + }, + original.views[1], + ], + }); + }, + ); + it.each([false, true])( + "preserves overlapping selection and sidebar edits (reverse dispatch: %s)", + async (reverse) => { + const original = initialState(); + const select = () => + action.run( + { databaseId: "db", navigation: { activeViewId: "board" } }, + ctx, + ); + const reorder = () => + action.run( + { + databaseId: "db", + navigation: { + sidebarOrder: { + viewId: "table", + mode: "custom", + itemIds: ["item-b", "foreign-item", "item-a", "item-b"], + }, + }, + }, + ctx, + ); + await Promise.all( + (reverse ? [reorder, select] : [select, reorder]).map((run) => run()), + ); + expect(saved).toEqual({ + ...original, + activeViewId: "board", + views: [ + { + ...original.views[0], + sidebarOrder: { mode: "custom", itemIds: ["item-b", "item-a"] }, + }, + original.views[1], + ], + }); + expect(boundary.access).toHaveBeenCalledTimes(2); + expect(boundary.mutate).toHaveBeenCalledTimes(2); + expect(boundary.mutate).toHaveBeenCalledWith( + ctx.userEmail, + "content-database-personal-view:db", + expect.any(Function), + ); + expect(boundary.put).not.toHaveBeenCalled(); + expect(boundary.remove).not.toHaveBeenCalled(); + }, + ); + + it.each([ + { activeViewId: "deleted-view" }, + { + sidebarOrder: { + viewId: "deleted-view", + mode: "custom" as const, + itemIds: [], + }, + }, + ])( + "rejects an unavailable exact View without changing personal state: %j", + async (navigation) => { + await expect( + action.run({ databaseId: "db", navigation }, ctx), + ).rejects.toThrow("View is unavailable"); + expect(saved).toEqual(initialState()); + expect(boundary.mutate).not.toHaveBeenCalled(); + }, + ); + + it("stops before loading View metadata when database access is denied", async () => { + boundary.access.mockRejectedValueOnce(new Error("Access denied")); + await expect( + action.run( + { databaseId: "db", navigation: { activeViewId: "board" } }, + ctx, + ), + ).rejects.toThrow("Access denied"); + expect(boundary.select).not.toHaveBeenCalled(); + expect(boundary.mutate).not.toHaveBeenCalled(); + }); + + it("requires exactly one update form", () => { + expect(() => action.schema.parse({ databaseId: "db" })).toThrow(); + expect(() => + action.schema.parse({ + databaseId: "db", + overrides: initialState(), + navigation: { activeViewId: "board" }, + }), + ).toThrow(); + expect(() => + action.schema.parse({ + databaseId: "db", + overrides: null, + navigation: { activeViewId: "board" }, + }), + ).toThrow(); + }); + + it("does not replace unreadable personal state during a navigation update", async () => { + saved = { version: 99 }; + await expect( + action.run( + { databaseId: "db", navigation: { activeViewId: "board" } }, + ctx, + ), + ).rejects.toThrow(); + expect(saved).toEqual({ version: 99 }); + }); +}); diff --git a/templates/content/actions/content-recent.test.ts b/templates/content/actions/content-recent.test.ts index 5f2b2d75b35..d1522db43ee 100644 --- a/templates/content/actions/content-recent.test.ts +++ b/templates/content/actions/content-recent.test.ts @@ -281,7 +281,6 @@ describe("sidebar partial state persistence", () => { ).toEqual({ version: 1, expandedDocumentIds: ["page"], - expandedWorkspaceIds: [], sections, }); expect(stored.has(settingId(bob.userEmail, "content-sidebar-state"))).toBe( diff --git a/templates/content/actions/content-sidebar-state.test.ts b/templates/content/actions/content-sidebar-state.test.ts index 16f0ae06413..4bff39c394f 100644 --- a/templates/content/actions/content-sidebar-state.test.ts +++ b/templates/content/actions/content-sidebar-state.test.ts @@ -1,4 +1,24 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const settings = vi.hoisted(() => ({ value: null as unknown })); +vi.mock("@agent-native/core/settings", () => ({ + getUserSetting: async () => settings.value, + mutateUserSetting: async ( + _email: string, + _key: string, + update: (value: unknown) => unknown, + ) => { + settings.value = update(settings.value); + return settings.value; + }, +})); +import { defaultContentSidebarSections } from "../shared/content-personal-navigation"; +import getSidebar from "./get-content-sidebar-state"; +import updateSidebar from "./update-content-sidebar-state"; + +beforeEach(() => { + settings.value = null; +}); import { CONTENT_SIDEBAR_STATE_VERSION, @@ -6,6 +26,34 @@ import { } from "./_content-sidebar-state"; describe("normalizeContentSidebarState", () => { + it("keeps expansion preferences absent through a first section-only save and read", async () => { + const sections = defaultContentSidebarSections(); + sections.recent.visible = false; + const state = { version: 1 as const, sections }; + const ctx = { userEmail: "sidebar@example.test" }; + expect(await updateSidebar.run(state, ctx)).toEqual({ state }); + expect(await getSidebar.run({}, ctx)).toEqual({ state }); + expect(settings.value).not.toHaveProperty("expandedWorkspaceIds"); + expect(settings.value).not.toHaveProperty("expandedDocumentIds"); + }); + + it("preserves explicit empty expansion preferences when sections change", async () => { + const ctx = { userEmail: "sidebar@example.test" }; + await updateSidebar.run( + { version: 1, expandedWorkspaceIds: [], expandedDocumentIds: [] }, + ctx, + ); + const sections = defaultContentSidebarSections(); + await updateSidebar.run({ version: 1, sections }, ctx); + expect(await getSidebar.run({}, ctx)).toEqual({ + state: { + version: 1, + expandedWorkspaceIds: [], + expandedDocumentIds: [], + sections, + }, + }); + }); it("deduplicates persisted expansion ids", () => { expect( normalizeContentSidebarState({ diff --git a/templates/content/actions/update-content-database-personal-view.ts b/templates/content/actions/update-content-database-personal-view.ts index 1ee97b9ba0a..b6aa147f7e6 100644 --- a/templates/content/actions/update-content-database-personal-view.ts +++ b/templates/content/actions/update-content-database-personal-view.ts @@ -1,12 +1,23 @@ -import { defineAction } from "@agent-native/core/action"; -import { deleteUserSetting, putUserSetting } from "@agent-native/core/settings"; +import { defineAction, fail } from "@agent-native/core/action"; +import { + deleteUserSetting, + mutateUserSetting, + putUserSetting, +} from "@agent-native/core/settings"; import { and, eq, inArray } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; +import { + applyContentPersonalNavigationPatch, + contentPersonalNavigationPatchSchema, +} from "../shared/content-personal-navigation-patch.js"; import { bulkChunkSizeForColumnCount, chunks } from "./_batch-utils.js"; import { assertContentDatabaseViewerAccess, + migratePersonalDatabaseViewOverrides, + filterSchema, + sortSchema, normalizePersonalDatabaseViewOverrides, personalDatabaseViewSettingKey, personalViewOverridesSchema, @@ -24,16 +35,100 @@ export function personalSidebarOrderItemIds( export default defineAction({ description: - "Update or clear the current user's personal saved filter, sort, and active view overrides for a content database.", - schema: z.object({ - databaseId: z.string().describe("Database ID"), - overrides: personalViewOverridesSchema.nullable(), - }), - run: async ({ databaseId, overrides }, ctx) => { + "Update or clear personal database View overrides. Use navigation to atomically select a View or reorder its sidebar references while preserving other personal settings.", + schema: z + .object({ + databaseId: z.string().describe("Database ID"), + overrides: personalViewOverridesSchema.nullable().optional(), + navigation: contentPersonalNavigationPatchSchema.optional(), + }) + .refine( + (args) => + (args.overrides !== undefined) !== (args.navigation !== undefined), + ), + run: async ({ databaseId, overrides, navigation }, ctx) => { if (!ctx?.userEmail) throw new Error("Not authenticated."); await assertContentDatabaseViewerAccess(databaseId); const key = personalDatabaseViewSettingKey(databaseId); + if (navigation) { + const [database] = await getDb() + .select({ + viewConfigJson: schema.contentDatabases.viewConfigJson, + systemRole: schema.contentDatabases.systemRole, + }) + .from(schema.contentDatabases) + .where(eq(schema.contentDatabases.id, databaseId)); + const config = z + .object({ + views: z + .array( + z.object({ + id: z.string(), + sorts: z.array(sortSchema), + filters: z.array(filterSchema), + filterMode: z.enum(["and", "or"]).optional(), + }), + ) + .optional(), + }) + .parse(JSON.parse(database.viewConfigJson)); + const viewIds = new Set( + config.views?.map((view) => view.id) ?? ["default"], + ); + if ( + (navigation.activeViewId && !viewIds.has(navigation.activeViewId)) || + (navigation.sidebarOrder && + !viewIds.has(navigation.sidebarOrder.viewId)) + ) + fail("This View is unavailable.", { + statusCode: 404, + errorCode: "view_unavailable", + }); + const requestedItemIds = navigation.sidebarOrder?.itemIds ?? []; + const validItemIds = new Set(); + for (const itemIds of chunks( + requestedItemIds, + Math.max(1, bulkChunkSizeForColumnCount(1) - 1), + )) { + const rows = await getDb() + .select({ id: schema.contentDatabaseItems.id }) + .from(schema.contentDatabaseItems) + .where( + and( + eq(schema.contentDatabaseItems.databaseId, databaseId), + inArray(schema.contentDatabaseItems.id, itemIds), + ), + ); + for (const row of rows) validItemIds.add(row.id); + } + const patch = navigation.sidebarOrder + ? { + ...navigation, + sidebarOrder: { + ...navigation.sidebarOrder, + itemIds: navigation.sidebarOrder.itemIds.filter((id) => + validItemIds.has(id), + ), + }, + } + : navigation; + const saved = await mutateUserSetting(ctx.userEmail, key, (current) => ({ + ...applyContentPersonalNavigationPatch( + migratePersonalDatabaseViewOverrides( + current, + databaseId, + database.systemRole, + ), + patch, + config.views, + ), + })); + return { + databaseId, + overrides: personalViewOverridesSchema.parse(saved), + }; + } if (overrides) { const requestedItemIds = personalSidebarOrderItemIds(overrides); const validItemIds = new Set(); diff --git a/templates/content/actions/update-content-sidebar-state.ts b/templates/content/actions/update-content-sidebar-state.ts index 07a65579c6b..2dfb1164516 100644 --- a/templates/content/actions/update-content-sidebar-state.ts +++ b/templates/content/actions/update-content-sidebar-state.ts @@ -18,8 +18,6 @@ export default defineAction({ CONTENT_SIDEBAR_STATE_SETTING_KEY, (current) => normalizeContentSidebarState({ - expandedWorkspaceIds: [], - expandedDocumentIds: [], ...normalizeContentSidebarState(current), ...state, })!, diff --git a/templates/content/app/components/editor/database/DatabaseView.tsx b/templates/content/app/components/editor/database/DatabaseView.tsx index 299e385a66c..150109cbfc8 100644 --- a/templates/content/app/components/editor/database/DatabaseView.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.tsx @@ -203,6 +203,7 @@ import { useUpdateContentDatabaseView, writeBuilderAttachPreviewToCache, } from "@/hooks/use-content-database"; +import { useUpdateContentPersonalNavigation } from "@/hooks/use-content-personal-navigation"; import { useRecordContentVisit } from "@/hooks/use-content-recent"; import { useContentSpaces, @@ -848,6 +849,9 @@ function DatabaseTable({ document.id, databaseRequestItemLimit, tableQuery, + foreground && renderMode === "page" && isActive + ? { refetchOnMount: "always" } + : undefined, ); // A deleted/missing database resolves to the unavailable union (no // `database` field) — treat it as no data; the inline-block wrapper owns @@ -2750,13 +2754,18 @@ function DatabaseTable({ ), ); + const updatePersonalNavigation = + useUpdateContentPersonalNavigation(databaseId); function selectPersonalView(viewId: string) { const next = selectDatabaseView(viewConfig, viewId); setPersonalQueryDirty( databaseViewHasPersonalQueryChanges(next, savedViewConfig), ); setViewConfig(next); - schedulePersonalDatabaseViewOverrideWrite(databaseId, next); + updatePersonalNavigation.mutate( + { databaseId, navigation: { activeViewId: viewId } }, + { onError: () => toast.error(dbText("failedToSaveView")) }, + ); if (foreground && renderMode === "page" && isActive) { navigate( contentRecentHref({ documentId: document.id, databaseId, viewId }), diff --git a/templates/content/app/components/editor/database/sidebar.test.tsx b/templates/content/app/components/editor/database/sidebar.test.tsx index 9e7df554097..695ba3cd11e 100644 --- a/templates/content/app/components/editor/database/sidebar.test.tsx +++ b/templates/content/app/components/editor/database/sidebar.test.tsx @@ -119,7 +119,7 @@ describe("DatabaseSidebarView", () => { ).toEqual(["canonical-parent-a", "canonical-parent-b"]); }); - it("disables manual reorder while the active saved view has sorts", () => { + it("disables legacy manual reorder without a personal order while the active saved view has sorts", () => { const markup = renderToStaticMarkup( @@ -154,7 +154,6 @@ describe("DatabaseSidebarView", () => { } overrides={null} isLoading={false} - sidebarOrder={{ mode: "custom", itemIds: ["item-first"] }} manualReorder={{ onReorder: () => {}, labels: { @@ -177,9 +176,83 @@ describe("DatabaseSidebarView", () => { ); expect(markup).not.toContain("Drag First"); + expect(markup).not.toContain('aria-roledescription="sortable"'); expect(markup).toContain('role="link"'); }); + it("allows personal custom reordering while retaining inherited filters and overriding inherited sorts", () => { + const data = { + database: { + viewConfig: { + version: 1, + activeViewId: "default", + views: [ + { + id: "default", + name: "Table", + type: "table", + filters: [ + { + key: "name", + label: "Name", + operator: "contains", + value: "Keep", + }, + ], + sorts: [{ key: "name", label: "Name", direction: "asc" }], + filterMode: "and", + }, + ], + }, + }, + items: [ + item("alpha", "Keep Alpha"), + item("beta", "Keep Beta"), + item("hidden", "Excluded"), + ], + properties: [], + } as unknown as ContentDatabaseResponse; + const markup = renderToStaticMarkup( + + + `Drag ${label}`, + moveUp: "Move up", + moveDown: "Move down", + moveTo: "Move to", + moveToPosition: (position) => `Position ${position}`, + }, + }} + labels={{ + noMatchesLabel: "No matches", + clearLabel: "Clear", + navigationLabel: "Files", + untitledLabel: "Untitled", + }} + /> + + , + ); + expect(markup.match(/aria-roledescription="sortable"/g)).toHaveLength(2); + expect(markup).toContain('data-sidebar-reorder-item-id="item-beta"'); + expect(markup).toContain('data-sidebar-reorder-item-id="item-alpha"'); + expect(markup.indexOf('href="/page/beta"')).toBeLessThan( + markup.indexOf('href="/page/alpha"'), + ); + expect(markup).not.toContain("Excluded"); + expect(markup).not.toContain("/page/hidden"); + }); + it("leaves the compact order control to the workspace header", () => { const markup = renderToStaticMarkup( diff --git a/templates/content/app/components/editor/database/sidebar.tsx b/templates/content/app/components/editor/database/sidebar.tsx index b63ef0044e5..4acf8012426 100644 --- a/templates/content/app/components/editor/database/sidebar.tsx +++ b/templates/content/app/components/editor/database/sidebar.tsx @@ -269,8 +269,8 @@ export function ContentFilesSidebarView({ Boolean(manualReorder) && !items.some((item) => item.document.source?.kind === "folder") && (sidebarOrder?.mode ?? "custom") === "custom" && - activeView.sorts.length === 0 && - activeView.filters.length === 0 && + (Boolean(sidebarOrder) || + (activeView.sorts.length === 0 && activeView.filters.length === 0)) && !databaseViewGroupingProperty(activeView, usableData?.properties ?? []); return (
diff --git a/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts b/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts index 8a0c8c1190c..f61bbdd064e 100644 --- a/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts +++ b/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts @@ -502,21 +502,28 @@ describe("document sidebar layout", () => { it("renders Pinned through exact database memberships with accessible reordering", () => { const sidebar = readSidebarSource("./DocumentSidebar.tsx"); - - expect(sidebar).toContain("{showFavorites && ("); - expect(sidebar).toContain('toggleSection("favorites")'); - expect(sidebar).toContain("!collapsedSections.favorites &&"); - expect(sidebar).toContain("aria-expanded={!collapsedSections.favorites}"); - expect(sidebar).toContain(""); + expect(sections).toContain("sections[id].visible"); + expect(sections).toContain("expanded={sections[id].expanded}"); + expect(sections).toContain( + "onToggle={() => change(id, { expanded: !sections[id].expanded })}", + ); + expect(sections).toContain("aria-expanded={expanded}"); + expect(sections).toContain('expanded && "rotate-90"'); + expect(sections).toContain("renderPinned(sections.pinned.limit)"); expect(sidebar).toContain("favoritesDocumentId"); - expect(sidebar).toContain("`/page/${favoritesDocumentId}`"); - expect(sidebar).toContain("data={favoritesDatabase.data}"); + expect(sidebar).toContain("favoritesData.items,"); + expect(sidebar).toContain(").slice(0, limit)"); + expect(sidebar).toContain( + "overrides={favoritesPersonalView.data?.overrides}", + ); + expect(sidebar).toContain("sidebarOrder={favoritesOrder.order}"); expect(sidebar).toContain("handlePinnedReorder"); - expect(sidebar).toContain("movePinnedItem.isPending"); + expect(sidebar).toContain("updateFavoritesPersonalView.isPending"); expect(sidebar).toContain("onReorder: handlePinnedReorder"); expect(sidebar).toContain( "flex h-7 w-full min-w-0 items-center rounded-md px-1", diff --git a/templates/content/app/components/sidebar/DocumentSidebar.tsx b/templates/content/app/components/sidebar/DocumentSidebar.tsx index e64d48dc533..4c0aa6b2093 100644 --- a/templates/content/app/components/sidebar/DocumentSidebar.tsx +++ b/templates/content/app/components/sidebar/DocumentSidebar.tsx @@ -86,6 +86,7 @@ import { useRestoreContentDatabase, useTrashedContentDatabases, } from "@/hooks/use-content-database"; +import { useUpdateContentPersonalNavigation } from "@/hooks/use-content-personal-navigation"; import { shouldAutoEnsureContentSpaces, useContentSpaces, @@ -216,8 +217,8 @@ const CONTENT_SIDEBAR_STATE_VERSION = 1 as const; interface ContentSidebarStateSnapshot { version: typeof CONTENT_SIDEBAR_STATE_VERSION; - expandedWorkspaceIds: string[]; - expandedDocumentIds: string[]; + expandedWorkspaceIds?: string[]; + expandedDocumentIds?: string[]; } const DEFAULT_COLLAPSED_SECTIONS: CollapsedSectionsState = { favorites: false, @@ -913,15 +914,21 @@ export function DocumentSidebar({ const workspaceCatalogPersonalView = useContentDatabasePersonalView( resolvedWorkspaceCatalogDatabaseId, ); + const updateWorkspaceNavigation = useUpdateContentPersonalNavigation( + resolvedWorkspaceCatalogDatabaseId, + workspaceCatalogDatabaseData?.database.viewConfig.views, + ); const updateWorkspaceCatalogPersonalView = useUpdateContentDatabasePersonalView(resolvedWorkspaceCatalogDatabaseId); const favoritesPersonalView = useContentDatabasePersonalView(favoritesDatabaseId); - const updateFavoritesPersonalView = - useUpdateContentDatabasePersonalView(favoritesDatabaseId); const favoritesData = isContentDatabaseUnavailable(favoritesDatabase.data) ? undefined : favoritesDatabase.data; + const updateFavoritesPersonalView = useUpdateContentPersonalNavigation( + favoritesDatabaseId, + favoritesData?.database.viewConfig.views, + ); const favoritesOrder = personalSidebarOrderForDatabase( favoritesData, favoritesPersonalView.data?.overrides, @@ -1723,12 +1730,9 @@ export function DocumentSidebar({ updateFavoritesPersonalView.mutate( { databaseId: favoritesDatabaseId, - overrides: withPersonalSidebarOrder( - favoritesData, - favoritesPersonalView.data?.overrides, - favoritesOrder.activeViewId, - order, - ), + navigation: { + sidebarOrder: { viewId: favoritesOrder.activeViewId, ...order }, + }, }, { onError: () => toast.error(t("sidebar.failedSaveOrder")) }, ); @@ -1750,15 +1754,16 @@ export function DocumentSidebar({ workspaceCatalogDatabaseData, current, ); - updateWorkspaceCatalogPersonalView.mutate( + updateWorkspaceNavigation.mutate( { databaseId: workspaceCatalogDatabaseId, - overrides: withPersonalSidebarOrder( - workspaceCatalogDatabaseData, - current, - selected.activeViewId, - { mode: "custom", itemIds }, - ), + navigation: { + sidebarOrder: { + viewId: selected.activeViewId, + mode: "custom", + itemIds, + }, + }, }, { onError: () => toast.error(t("sidebar.failedSaveOrder")) }, ); @@ -1767,7 +1772,7 @@ export function DocumentSidebar({ workspaceCatalogDatabaseId, workspaceCatalogDatabaseData, workspaceCatalogPersonalView.data?.overrides, - updateWorkspaceCatalogPersonalView, + updateWorkspaceNavigation, t, ], ); diff --git a/templates/content/app/components/sidebar/PersonalSidebarSections.tsx b/templates/content/app/components/sidebar/PersonalSidebarSections.tsx index ef6c765a2fb..99b1129e597 100644 --- a/templates/content/app/components/sidebar/PersonalSidebarSections.tsx +++ b/templates/content/app/components/sidebar/PersonalSidebarSections.tsx @@ -101,6 +101,12 @@ export function PersonalSidebarSections({ recent: t("sidebar.recent"), workspaces: t("sidebar.workspaces"), }; + function canShowMore(id: "pinned" | "recent") { + const count = + id === "pinned" ? pinnedCount : (recent.data?.entries.length ?? 0); + if (sections[id].limit >= 50) return false; + return count > sections[id].limit; + } if (state.isError) return ( <> @@ -218,23 +224,19 @@ export function PersonalSidebarSections({ {t("sidebar.noRecentVisits")}

)} - {(id === "pinned" - ? pinnedCount - : (recent.data?.entries.length ?? 0)) > - sections[id].limit && - sections[id].limit < 50 && ( - - )} + {canShowMore(id) && ( + + )} {sections[id].limit > 5 && ( +
+ ); + } + + const pageItems = data.items.filter( + (item) => !precedingDocumentIds.has(item.documentId), + ); + const pageDocumentIds = new Set([ + ...precedingDocumentIds, + ...pageItems.map((item) => item.documentId), + ]); + const revealedItems = props.activePathDocuments + .filter( + (document) => + document.parentId === props.parentId && + !pageDocumentIds.has(document.id), + ) + .map((document) => ({ + membershipId: `active-path-${document.id}`, + membershipPosition: document.position, + documentId: document.id, + parentId: document.parentId, + title: document.title, + icon: document.icon, + type: document.database ? ("database" as const) : ("page" as const), + hasChildren: props.activePathDocuments.some( + (candidate) => candidate.parentId === document.id, + ), + spaceId: null, + sourceKind: document.source?.kind ?? null, + isFavorite: document.isFavorite, + canEdit: document.canEdit !== false, + canManage: document.canManage === true, + createdAt: document.createdAt, + updatedAt: document.updatedAt, + })); + const items = [...pageItems, ...revealedItems]; + const composedDocumentIds = new Set([ + ...pageDocumentIds, + ...revealedItems.map((item) => item.documentId), + ]); + + return ( + <> + {items.map((navigationItem) => { + const metadata = props.documentMetadata.get(navigationItem.documentId); + const item = navigationItemAsDatabaseItem(navigationItem, metadata); + const expanded = props.expandedDocumentIds.has( + navigationItem.documentId, + ); + return ( +
+ {}} + onOpenItem={props.onOpenItem} + active={navigationItem.documentId === props.activeDocumentId} + onCreateChildPage={props.onCreateChildPage} + onCreateChildDatabase={props.onCreateChildDatabase} + onDeleteItem={props.onDeleteItem} + onToggleFavorite={props.onToggleFavorite} + untitledLabel={props.untitledLabel} + depth={props.depth} + hasChildren={navigationItem.hasChildren} + expanded={expanded} + onToggleExpanded={(open) => + props.onDocumentExpandedChange(navigationItem.documentId, open) + } + /> + {expanded && navigationItem.hasChildren ? ( + + ) : null} +
+ ); + })} + {data.pagination.hasMore && data.pagination.nextCursor ? ( + nextPageVisible ? ( + + ) : ( + + ) + ) : null} + + ); +} + export function databaseSidebarReorderItems( items: ContentDatabaseItem[], untitledLabel: string, diff --git a/templates/content/app/components/layout/Layout.layout.test.ts b/templates/content/app/components/layout/Layout.layout.test.ts index d14a7c796f3..8d6fabe3842 100644 --- a/templates/content/app/components/layout/Layout.layout.test.ts +++ b/templates/content/app/components/layout/Layout.layout.test.ts @@ -8,6 +8,10 @@ function readLayoutSource() { }); } +function readRootSource() { + return readFileSync(new URL("../../root.tsx", import.meta.url), "utf8"); +} + describe("app layout", () => { it("exposes the sidebar width to editor content for responsive surfaces", () => { const source = readLayoutSource(); @@ -62,6 +66,15 @@ describe("app layout", () => { expect(source).toContain("useCreatePage({ awaitPersist: false })"); }); + it("returns command-menu focus to the recorded visible launcher without a timer", () => { + const source = readRootSource(); + + expect(source).toContain("CONTENT_COMMAND_MENU_OPEN_EVENT"); + expect(source).toContain("commandMenuReturnFocusRef.current ="); + expect(source).toContain("target.focus()"); + expect(source).not.toContain("setTimeout(() => target.focus"); + }); + it("includes the current document revision in chat history restores", () => { const source = readLayoutSource(); diff --git a/templates/content/app/components/layout/Layout.tsx b/templates/content/app/components/layout/Layout.tsx index 0535fb0abee..76074ab9148 100644 --- a/templates/content/app/components/layout/Layout.tsx +++ b/templates/content/app/components/layout/Layout.tsx @@ -31,6 +31,7 @@ import { DocumentSidebar } from "@/components/sidebar/DocumentSidebar"; import { Button } from "@/components/ui/button"; import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; import { useCreatePage } from "@/hooks/use-create-page"; +import { openContentCommandMenu } from "@/lib/content-command-menu"; import { applyRegisteredDocumentHistoryRestore, prepareRegisteredDocumentHistoryRestore, @@ -168,6 +169,7 @@ export function Layout({ children }: LayoutProps) { }); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const sidebarTriggerRef = useRef(null); + const openSearchAfterSidebarCloseRef = useRef(false); const [sidebarWidth, setSidebarWidth] = useState(loadSidebarWidth); const handleSidebarResize = useCallback((width: number) => { @@ -241,6 +243,14 @@ export function Layout({ children }: LayoutProps) { showClose={false} className="w-[85vw] max-w-80 p-0" onCloseAutoFocus={(event) => { + if (openSearchAfterSidebarCloseRef.current) { + event.preventDefault(); + openSearchAfterSidebarCloseRef.current = false; + openContentCommandMenu( + sidebarTriggerRef.current ?? undefined, + ); + return; + } if (sidebarTriggerRef.current) { event.preventDefault(); sidebarTriggerRef.current.focus(); @@ -255,6 +265,10 @@ export function Layout({ children }: LayoutProps) { collapsed={false} onToggleCollapsed={() => setMobileSidebarOpen(false)} onNavigate={() => setMobileSidebarOpen(false)} + onOpenSearch={() => { + openSearchAfterSidebarCloseRef.current = true; + setMobileSidebarOpen(false); + }} /> diff --git a/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts b/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts index bca753a9632..928205b71bf 100644 --- a/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts +++ b/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts @@ -125,7 +125,7 @@ describe("document sidebar layout", () => { expect(iconSource.indexOf("if (document.database)")).toBeLessThan( iconSource.indexOf('return "page"'), ); - expect(sidebar).toContain(""); + expect(treeItem).toContain(""); }); it("uses the database icon as the default for database pages", () => { @@ -166,13 +166,36 @@ describe("document sidebar layout", () => { ); }); - it("settles search dismissal by clearing the hidden query", () => { + it("keeps hosted sidebar reads bounded while isolating exhaustive local inventory", () => { const sidebar = readSidebarSource("./DocumentSidebar.tsx"); + const documentsHook = readSidebarSource("../../hooks/use-documents.ts"); + + expect(sidebar).not.toContain("const documentsQuery = useDocuments();"); + expect(sidebar).toContain("useDocuments({ enabled: localFileMode })"); + expect(sidebar).toContain('"get-content-navigation-context"'); + expect(sidebar).toContain("limit: 50"); + expect(sidebar).toContain("limit: Math.max(contentSpaces.length, 1)"); + expect(documentsHook).toContain("enabled: options?.enabled !== false"); + }); + + it("keeps one command-menu search launcher above the navigation scroller", () => { + const sidebar = readSidebarSource("./DocumentSidebar.tsx"); + const layout = readSidebarSource("../layout/Layout.tsx"); - expect(sidebar).toContain("const closeSearch = useCallback"); - expect(sidebar).toContain('setSearchQuery("")'); - expect(sidebar).toContain("if (isSearching)"); - expect(sidebar).toContain("closeSearch();"); + expect(sidebar).toContain("openContentCommandMenu"); + expect(sidebar).toContain("openCommandMenuFrom(searchTriggerRef.current)"); + expect(sidebar).toContain('{isMac ? "⌘ K" : "Ctrl K"}'); + expect(sidebar.indexOf("{searchButton}")).toBeLessThan( + sidebar.indexOf(' { @@ -214,9 +237,6 @@ describe("document sidebar layout", () => { expect(sidebar).toContain("selectedSpace?.id"); expect(sidebar).toContain("spaceId: parentId ? undefined : rootSpaceId"); expect(sidebar).toContain("const handleCreatePageInSpace = useCallback"); - expect(sidebar).toContain( - "const renderNewButton = (space = selectedSpace) =>", - ); expect(sidebar).toContain("const renderCollapsedNewButton = () =>"); expect(sidebar).toContain('t("sidebar.newPage")'); expect(sidebar).not.toContain( @@ -267,7 +287,12 @@ describe("document sidebar layout", () => { expect(sidebar).toContain( "stored?.expandedWorkspaceIds ?? contentSpaces.map", ); - expect(sidebar).toContain("expandedDocumentIds={expandedDocumentIdSet}"); + expect(sidebar).toContain( + "expandedDocumentIds={visibleExpandedDocumentIds}", + ); + expect(sidebar).toContain( + "new Set([...expandedDocumentIds, ...activeAncestorIds])", + ); expect(sidebar).toContain("toggleExpandedWorkspaceIds(current, space.id)"); expect(sidebar).toContain("ensureWorkspaceExpanded(current, space.id)"); expect(sidebar).not.toContain( @@ -362,6 +387,7 @@ describe("document sidebar layout", () => { expect(sidebar).toContain("renderItem={(item, reorder) =>"); expect(sidebar).toContain("name: item.document.title || space.name"); expect(sidebar).toContain("scroll={false}"); + expect(sidebar.match(/scroll=\{false\}/g)).toHaveLength(3); }); it("never empties the Files tree while a deferred database read is paused", () => { @@ -448,6 +474,8 @@ describe("document sidebar layout", () => { expect(sidebar).toContain("database.documentId"); expect(sidebar).toContain("database.canPermanentlyDelete"); expect(sidebar).toContain("deletedDocument?.database"); + expect(sidebar).toContain("activeTargetDeleted"); + expect(sidebar).toContain("result.navigationPath"); expect(sidebar).toContain("deleteContentDatabase.mutateAsync"); expect(sidebar).toContain("databaseId: deletedDocument.database.id"); expect(sidebar).toContain('t("sidebar.restoreDatabase")'); @@ -515,7 +543,7 @@ describe("document sidebar layout", () => { expect(sections).toContain("aria-expanded={expanded}"); expect(sections).toContain('expanded && "rotate-90"'); expect(sections).toContain("renderPinned(sections.pinned.limit)"); - expect(sidebar).toContain("useContentDatabaseById(favoritesDatabaseId)"); + expect(sidebar).toContain("useContentDatabaseById(favoritesDatabaseId, {"); expect(sidebar).toContain("favoritesData.items,"); expect(sidebar).toContain(").slice(0, limit)"); expect(sidebar).toContain( @@ -532,6 +560,35 @@ describe("document sidebar layout", () => { expect(sidebar).not.toContain("!localFileMode && favorites.length > 0"); }); + it("keeps section visibility inside each section menu instead of a duplicate customize row", () => { + const sections = readSidebarSource("./PersonalSidebarSections.tsx"); + + // A standalone sidebar-level 3-dot row duplicated the section header menu + // and burned a whole row of vertical space. + expect(sections).not.toContain('className="flex justify-end px-3"'); + expect(sections).not.toContain(''); + + // The visibility toggles now live under Move up/Move down in every section + // menu, so a hidden section is always restorable. + expect(sections).toContain(""); + expect(sections).toContain("checked={sections[sectionId].visible}"); + expect(sections).toContain("onChangeVisible(sectionId, visible)"); + expect(sections).toContain("change(sectionId, { visible })"); + + // Both call sites, including the always-visible "workspaces" section, wire + // the visibility group. + expect( + sections.split("onChangeVisible={(sectionId, visible) =>").length - 1, + ).toBe(2); + + // The section menu trigger must not reuse the drag handle's label. + expect(sections).toContain('aria-label={t("sidebar.customizeSidebar")}'); + const menuTrigger = sections.slice( + sections.indexOf(" { const sidebar = readSidebarSource("./DocumentSidebar.tsx"); const treeItem = readSidebarSource("./DocumentTreeItem.tsx"); diff --git a/templates/content/app/components/sidebar/DocumentSidebar.tsx b/templates/content/app/components/sidebar/DocumentSidebar.tsx index dc307de7ec1..b70d2967cbc 100644 --- a/templates/content/app/components/sidebar/DocumentSidebar.tsx +++ b/templates/content/app/components/sidebar/DocumentSidebar.tsx @@ -15,6 +15,7 @@ import type { ContentDatabasePersonalViewOverrides, ContentDatabaseResponse, ContentSidebarViewOrder, + ContentNavigationContext, Document, } from "@shared/api"; import { CONTENT_DATABASE_PERSONAL_VIEW_OVERRIDES_VERSION } from "@shared/api"; @@ -41,6 +42,7 @@ import { toast } from "sonner"; import { ContentFilesSidebarView, + PagedContentFilesSidebarView, contentSidebarOrderedItems, type ContentFilesSidebarRenderReorder, } from "@/components/editor/database/sidebar"; @@ -56,6 +58,7 @@ import { AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, @@ -100,12 +103,12 @@ import { useRestoreDocument, useTrashedDocuments, useUpdateDocument, - filterDocumentTreeDocuments, documentQueryFilter, rollbackOptimisticCreatedDocument, restoreDeletedDocumentSnapshots, } from "@/hooks/use-documents"; import { useLocalStorage } from "@/hooks/use-local-storage"; +import { openContentCommandMenu } from "@/lib/content-command-menu"; import { getDesktopContentFiles, type DesktopContentFilesFolder, @@ -122,8 +125,6 @@ import { } from "@/lib/optimistic-document"; import { cn } from "@/lib/utils"; -import { getDocumentSidebarSections } from "./document-sidebar-sections"; -import { DocumentSidebarIcon } from "./DocumentTreeItem"; import { firstLocalSourceDocumentId, localSourceItemIdentity, @@ -158,10 +159,15 @@ interface DocumentSidebarProps { collapsed: boolean; onToggleCollapsed: () => void; onNavigate?: () => void; + onOpenSearch?: () => void; width?: number; onResize?: (width: number) => void; } +function openCommandMenuFrom(trigger: HTMLButtonElement | null) { + openContentCommandMenu(trigger ?? undefined); +} + const LIST_DOCUMENTS_QUERY_KEY = [ "action", "list-documents", @@ -379,11 +385,14 @@ function WorkspaceSidebarItem({ space, selected, expanded, + localFileMode, deferInitialReadUntilDocumentId, reorder, createDocumentPending, activeDocumentId, expandedDocumentIds, + documentMetadata, + activePathDocuments, onDocumentExpandedChange, onActivate, onToggleExpanded, @@ -396,11 +405,14 @@ function WorkspaceSidebarItem({ space: ContentSpaceSummary; selected: boolean; expanded: boolean; + localFileMode: boolean; deferInitialReadUntilDocumentId: string | null; reorder?: ContentFilesSidebarRenderReorder; createDocumentPending: boolean; activeDocumentId: string | null; expandedDocumentIds: ReadonlySet; + documentMetadata: ReadonlyMap; + activePathDocuments: readonly Document[]; onDocumentExpandedChange: (documentId: string, expanded: boolean) => void; onActivate: (space: ContentSpaceSummary, documentId?: string) => void; onToggleExpanded: () => void; @@ -474,7 +486,7 @@ function WorkspaceSidebarItem({ deferInitialReadUntilDocumentId, ); const filesDatabase = useContentDatabaseById( - deferredFilesDatabase.databaseId, + localFileMode ? deferredFilesDatabase.databaseId : null, { enabled: deferredFilesDatabase.enabled }, ); const filesDatabaseData = isContentDatabaseUnavailable(filesDatabase.data) @@ -563,7 +575,9 @@ function WorkspaceSidebarItem({ openedWorkingCopyIdRef.current = selectedWorkingCopy.id; onActivate(space, firstDocumentId); }, [onActivate, selectedWorkingCopy, space, visibleFilesDatabaseData]); - const resolvedFilesDatabaseId = filesDatabaseData?.database.id ?? null; + const resolvedFilesDatabaseId = localFileMode + ? (filesDatabaseData?.database.id ?? null) + : space.filesDatabaseId; const filesPersonalView = useContentDatabasePersonalView( resolvedFilesDatabaseId, ); @@ -571,10 +585,15 @@ function WorkspaceSidebarItem({ resolvedFilesDatabaseId, ); const failed = filesDatabase.isError || filesPersonalView.isError; - const { activeViewId, order: sidebarOrder } = personalSidebarOrderForDatabase( - filesDatabaseData, - filesPersonalView.data?.overrides, - ); + const pagedOverrides = filesPersonalView.data?.overrides; + const { activeViewId, order: sidebarOrder } = localFileMode + ? personalSidebarOrderForDatabase(filesDatabaseData, pagedOverrides) + : { + activeViewId: pagedOverrides?.activeViewId ?? "default", + order: pagedOverrides?.views.find( + (view) => view.id === (pagedOverrides.activeViewId ?? "default"), + )?.sidebarOrder ?? { mode: "custom" as const, itemIds: [] }, + }; const reorderLabels: SidebarReorderLabels = { drag: (label) => t("sidebar.dragToReorder", { label }), moveUp: t("sidebar.moveUp"), @@ -793,7 +812,7 @@ function WorkspaceSidebarItem({ filesDatabase.isFetching || filesPersonalView.isFetching } /> - ) : ( + ) : localFileMode ? ( + ) : ( + { + if (selected) return false; + onActivate(space, item.document.id); + return true; + }} + onCreateChildPage={(item) => onCreateChildPage(space, item)} + onCreateChildDatabase={(item) => + onCreateChildDatabase(space, item) + } + onDeleteItem={onDeleteItem} + onToggleFavorite={onToggleFavorite} + navigationLabel={`${space.name} ${t("sidebar.files")}`} + untitledLabel={t("sidebar.untitled")} + /> )}
) : null} @@ -854,6 +898,7 @@ export function DocumentSidebar({ collapsed, onToggleCollapsed, onNavigate, + onOpenSearch, width, onResize, }: DocumentSidebarProps) { @@ -861,6 +906,14 @@ export function DocumentSidebar({ const location = useLocation(); const queryClient = useQueryClient(); const t = useT(); + const searchTriggerRef = useRef(null); + const handleOpenSearch = useCallback(() => { + if (onOpenSearch) { + onOpenSearch(); + return; + } + openCommandMenuFrom(searchTriggerRef.current); + }, [onOpenSearch]); const sidebarReorderLabels = useMemo( () => ({ drag: (label) => t("sidebar.dragToReorder", { label }), @@ -871,7 +924,9 @@ export function DocumentSidebar({ }), [t], ); - const documentsQuery = useDocuments(); + const contentSpacesQuery = useContentSpaces(); + const localFileMode = contentSpacesQuery.data?.sourceMode === "local-files"; + const documentsQuery = useDocuments({ enabled: localFileMode }); const { data: documents = [] } = documentsQuery; const createDocument = useCreateDocument(); const createDatabase = useCreateContentDatabase(null, { @@ -887,7 +942,6 @@ export function DocumentSidebar({ const { data: trashedDatabases } = useTrashedContentDatabases(); const { isCodeMode } = useCodeMode(); const updateDocument = useUpdateDocument(); - const contentSpacesQuery = useContentSpaces(); const ensureContentSpaces = useEnsureContentSpaces(); const workspaceSelectionQueueRef = useRef(createContentSpaceSelectionQueue()); const contentSpaces = contentSpacesQuery.data?.spaces ?? []; @@ -895,9 +949,12 @@ export function DocumentSidebar({ contentSpacesQuery.data?.catalogDatabaseId ?? null; const favoritesDatabaseId = contentSpacesQuery.data?.favoritesDatabaseId ?? null; - const favoritesDatabase = useContentDatabaseById(favoritesDatabaseId); + const favoritesDatabase = useContentDatabaseById(favoritesDatabaseId, { + limit: 50, + }); const workspaceCatalogDatabase = useContentDatabaseById( workspaceCatalogDatabaseId, + { limit: Math.max(contentSpaces.length, 1) }, ); const workspaceCatalogDatabaseData = isContentDatabaseUnavailable( workspaceCatalogDatabase.data, @@ -1014,10 +1071,6 @@ export function DocumentSidebar({ [], ); const [expandedDocumentIds, setExpandedDocumentIds] = useState([]); - const expandedDocumentIdSet = useMemo( - () => new Set(expandedDocumentIds), - [expandedDocumentIds], - ); const sidebarStateHydratedRef = useRef(false); const expandedWorkspaceIdsRef = useRef([]); const expandedDocumentIdsRef = useRef([]); @@ -1232,15 +1285,10 @@ export function DocumentSidebar({ RemoveLocalFileSourceResult, { sourceRootPath?: string | null } >("remove-local-file-source"); - const [searchQuery, setSearchQuery] = useState(""); - const [isSearching, setIsSearching] = useState(false); - const closeSearch = useCallback(() => { - setIsSearching(false); - setSearchQuery(""); - }, []); + const [isMac, setIsMac] = useState(false); useEffect(() => { - closeSearch(); - }, [closeSearch, location.key]); + setIsMac(/Mac|iPhone|iPad|iPod/.test(navigator.platform)); + }, []); // Track user-expanded nodes only; active ancestors are derived below so they // do not stay open after navigation unless the user explicitly expanded them. const expandedIdsRef = useRef(new Set()); @@ -1278,13 +1326,13 @@ export function DocumentSidebar({ const settleOptimisticListRefresh = useCallback( (id: string) => { pendingOptimisticCreationIdsRef.current.delete(id); - if (pendingOptimisticCreationIdsRef.current.size === 0) { + if (localFileMode && pendingOptimisticCreationIdsRef.current.size === 0) { void queryClient.invalidateQueries({ queryKey: LIST_DOCUMENTS_QUERY_KEY, }); } }, - [queryClient], + [localFileMode, queryClient], ); const settingsActive = location.pathname.startsWith("/settings"); @@ -1317,33 +1365,71 @@ export function DocumentSidebar({ [onResize, width], ); - const treeDocuments = filterDocumentTreeDocuments(documents); - const { localFileMode, databaseDocuments } = getDocumentSidebarSections( - documents, - treeDocuments, + const navigationContextQuery = useActionQuery( + "get-content-navigation-context", + activeDocumentId ? { id: activeDocumentId } : undefined, + { enabled: Boolean(activeDocumentId) }, ); - - const activeDocument = activeDocumentId - ? documents.find((doc) => doc.id === activeDocumentId) - : null; + const activeDocument = navigationContextQuery.data?.document ?? null; const trashItems = trashedDatabases?.databases ?? []; const trashedPageItems = trashedDocuments?.documents ?? []; - const parentByDocumentId = useMemo( - () => new Map(documents.map((doc) => [doc.id, doc.parentId])), - [documents], + const activePathDocuments = useMemo( + () => + localFileMode + ? documents.filter((document) => + navigationContextQuery.data?.path.some( + (entry) => entry.id === document.id, + ), + ) + : (navigationContextQuery.data?.path.map( + (entry): Document => ({ + id: entry.id, + parentId: entry.parentId, + title: entry.title, + content: "", + description: "", + icon: entry.icon, + position: 0, + isFavorite: entry.isFavorite, + hideFromSearch: false, + visibility: entry.visibility, + accessRole: entry.accessRole, + canView: entry.canView, + canComment: entry.canComment, + canEdit: entry.canEdit, + canManage: entry.canManage, + source: entry.source, + database: undefined, + createdAt: entry.createdAt, + updatedAt: entry.updatedAt, + databaseMembership: entry.databaseId + ? { + databaseId: entry.databaseId, + databaseDocumentId: entry.databaseDocumentId, + databaseTitle: null, + position: null, + } + : undefined, + }), + ) ?? []), + [documents, localFileMode, navigationContextQuery.data?.path], + ); + const documentMetadata = useMemo( + () => new Map(activePathDocuments.map((doc) => [doc.id, doc])), + [activePathDocuments], ); const activeAncestorIds = useMemo(() => { const ids = new Set(); - let parentId = activeDocumentId - ? (parentByDocumentId.get(activeDocumentId) ?? null) - : null; - while (parentId && !ids.has(parentId)) { - ids.add(parentId); - parentId = parentByDocumentId.get(parentId) ?? null; + for (const entry of navigationContextQuery.data?.path ?? []) { + if (entry.id !== activeDocumentId) ids.add(entry.id); } return ids; - }, [activeDocumentId, parentByDocumentId]); + }, [activeDocumentId, navigationContextQuery.data?.path]); + const visibleExpandedDocumentIds = useMemo( + () => new Set([...expandedDocumentIds, ...activeAncestorIds]), + [activeAncestorIds, expandedDocumentIds], + ); const expandedIds = new Set(expandedIdsRef.current); for (const id of activeAncestorIds) expandedIds.add(id); @@ -1413,9 +1499,9 @@ export function DocumentSidebar({ createdAt: now, updatedAt: now, }); - const previousDocuments = queryClient.getQueryData( - LIST_DOCUMENTS_QUERY_KEY, - ); + const previousDocuments = localFileMode + ? queryClient.getQueryData(LIST_DOCUMENTS_QUERY_KEY) + : undefined; const previousPath = `${location.pathname}${location.search}${location.hash}`; pendingOptimisticCreationIdsRef.current.add(id); @@ -1536,17 +1622,19 @@ export function DocumentSidebar({ createdAt: now, updatedAt: now, }); - const previousDocuments = queryClient.getQueryData( - LIST_DOCUMENTS_QUERY_KEY, - ); + const previousDocuments = localFileMode + ? queryClient.getQueryData(LIST_DOCUMENTS_QUERY_KEY) + : undefined; const previousPath = `${location.pathname}${location.search}${location.hash}`; pendingOptimisticCreationIdsRef.current.add(id); - queryClient.setQueryData(LIST_DOCUMENTS_QUERY_KEY, (old: any) => { - const docs: Document[] = - old?.documents ?? (Array.isArray(old) ? old : documents); - return withDocumentsCacheShape(old, [...docs, tempDoc]); - }); + if (localFileMode) { + queryClient.setQueryData(LIST_DOCUMENTS_QUERY_KEY, (old: any) => { + const docs: Document[] = + old?.documents ?? (Array.isArray(old) ? old : documents); + return withDocumentsCacheShape(old, [...docs, tempDoc]); + }); + } queryClient.setQueryData(["action", "get-document", { id }], tempDoc); navigateToDocument(id); onNavigate?.(); @@ -1568,11 +1656,13 @@ export function DocumentSidebar({ settleParentExpansion(true); } catch (err) { settleParentExpansion(false); - rollbackOptimisticCreatedDocument( - queryClient, - id, - previousDocuments !== undefined, - ); + if (localFileMode) { + rollbackOptimisticCreatedDocument( + queryClient, + id, + previousDocuments !== undefined, + ); + } queryClient.removeQueries(documentQueryFilter(id)); settleOptimisticListRefresh(id); if (window.location.pathname === `/page/${id}`) { @@ -1617,9 +1707,11 @@ export function DocumentSidebar({ const handleDelete = useCallback( async (id: string) => { - const deletedDocument = documents.find((doc) => doc.id === id) ?? null; - const deletedIds = collectDocumentSubtreeIds(documents, id); - const activeDeleted = activeDocumentId + const deletedDocument = documentMetadata.get(id) ?? null; + const deletedIds = localFileMode + ? collectDocumentSubtreeIds(documents, id) + : new Set([id]); + let activeDeleted = activeDocumentId ? deletedIds.has(activeDocumentId) : false; const survivingDocuments = documents.filter( @@ -1640,15 +1732,17 @@ export function DocumentSidebar({ ); const previousPath = `${location.pathname}${location.search}${location.hash}`; - queryClient.setQueryData(LIST_DOCUMENTS_QUERY_KEY, (old: unknown) => { - const cachedDocs: Document[] = - (old as { documents?: Document[] })?.documents ?? - (Array.isArray(old) ? old : documents); - return withDocumentsCacheShape( - old, - cachedDocs.filter((doc) => !deletedIds.has(doc.id)), - ); - }); + if (localFileMode) { + queryClient.setQueryData(LIST_DOCUMENTS_QUERY_KEY, (old: unknown) => { + const cachedDocs: Document[] = + (old as { documents?: Document[] })?.documents ?? + (Array.isArray(old) ? old : documents); + return withDocumentsCacheShape( + old, + cachedDocs.filter((doc) => !deletedIds.has(doc.id)), + ); + }); + } for (const deletedId of deletedIds) { queryClient.removeQueries(documentQueryFilter(deletedId)); } @@ -1662,22 +1756,42 @@ export function DocumentSidebar({ try { if (deletedDocument?.database) { - await deleteContentDatabase.mutateAsync({ + const result = await deleteContentDatabase.mutateAsync({ databaseId: deletedDocument.database.id, + activeDocumentId: activeDocumentId ?? undefined, }); + activeDeleted = result.activeTargetDeleted; + if (result.navigationPath) { + void navigate(result.navigationPath, { + replace: true, + flushSync: true, + }); + } } else { - await deleteDocument.mutateAsync({ id }); + const result = await deleteDocument.mutateAsync({ + id, + activeDocumentId: activeDocumentId ?? undefined, + }); + activeDeleted = result.activeTargetDeleted ?? false; + if (result.navigationPath) { + void navigate(result.navigationPath, { + replace: true, + flushSync: true, + }); + } } void queryClient.invalidateQueries({ queryKey: ["action", "list-documents"], }); } catch (err) { - restoreDeletedDocumentSnapshots( - queryClient, - previousDocuments, - previousDocumentQueries, - deletedIds, - ); + if (localFileMode) { + restoreDeletedDocumentSnapshots( + queryClient, + previousDocuments, + previousDocumentQueries, + deletedIds, + ); + } void queryClient.invalidateQueries({ queryKey: ["action", "list-documents"], }); @@ -1878,25 +1992,6 @@ export function DocumentSidebar({ } }, [queryClient, removeLocalFileSource, t]); - const filteredDocuments = searchQuery - ? documents.filter((d) => - d.title.toLowerCase().includes(searchQuery.toLowerCase()), - ) - : null; - - const renderNewButton = (space = selectedSpace) => - space ? ( - - ) : null; - const renderCollapsedNewButton = () => selectedSpace ? ( @@ -1952,27 +2047,41 @@ export function DocumentSidebar({ ); - const searchButton = ( + const collapsedSearchButton = ( - + {t("sidebar.search")} ); + const searchButton = ( + + ); const feedbackButton = ( document.id === activeDocumentId) + navigationContextQuery.data?.workspaceFilesDatabaseId === + space.filesDatabaseId ? activeDocumentId : null } reorder={reorder} createDocumentPending={createDocument.isPending} activeDocumentId={activeDocumentId} - expandedDocumentIds={expandedDocumentIdSet} + expandedDocumentIds={visibleExpandedDocumentIds} + documentMetadata={documentMetadata} + activePathDocuments={ + activePathDocuments.some( + (document) => + document.databaseMembership?.databaseId === space.filesDatabaseId, + ) + ? activePathDocuments + : [] + } onDocumentExpandedChange={handleDocumentExpandedChange} onToggleExpanded={() => updateExpandedWorkspaceIds((current) => @@ -2396,11 +2516,11 @@ export function DocumentSidebar({ return (
{brandButton(true)} + {collapsedSearchButton} {renderCollapsedNewButton()} @@ -2437,155 +2557,89 @@ export function DocumentSidebar({
{brandButton(false)}
- - {/* Search */} - {isSearching && ( -
- setSearchQuery(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Escape") { - closeSearch(); - } - }} - className="w-full px-2 py-1.5 text-sm bg-background border border-border rounded-md outline-none focus:ring-1 focus:ring-ring" - /> -
- )} +
{searchButton}
- {/* Search results */} - {filteredDocuments ? ( - <> -
-
- {t("sidebar.results")} -
- {filteredDocuments.length === 0 ? ( -
- {t("sidebar.noPagesFound")} -
- ) : ( - filteredDocuments.map((doc) => ( - - )) - )} -
- {renderNewButton()} - - ) : ( - <> - - favoritesDatabase.isError || favoritesPersonalView.isError ? ( - { - void favoritesDatabase.refetch(); - void favoritesPersonalView.refetch(); - }} - /> - ) : ( - { - const document = documents.find( - (candidate) => candidate.id === item.document.id, - ); - const space = document - ? contentSpaces.find( - (candidate) => - candidate.filesDocumentId === - document.databaseMembership?.databaseDocumentId, - ) - : undefined; - if (!space || selectedSpace?.id === space.id) { - onNavigate?.(); - return false; + + favoritesDatabase.isError || favoritesPersonalView.isError ? ( + { + void favoritesDatabase.refetch(); + void favoritesPersonalView.refetch(); + }} + /> + ) : ( + - void handleCreatePage(item.document.id) - } - onCreateChildDatabase={(item) => - void handleCreateDatabase(item.document.id) - } - onDeleteItem={(item) => - requestDelete( - item.document.id, - item.document.title || t("sidebar.untitled"), - ) - } - onToggleFavorite={(item) => - handleToggleFavorite(item.document.id, false) - } - scroll={false} - labels={{ - noMatchesLabel: t("database.noRowsMatchThisView"), - clearLabel: t("database.clearSearchAndFilters"), - navigationLabel: t("sidebar.pinned"), - untitledLabel: t("sidebar.untitled"), - }} - /> - ) - } - /> - {renderTrashSection()} - - )} + : undefined + } + overrides={favoritesPersonalView.data?.overrides} + sidebarOrder={favoritesOrder.order} + isLoading={ + favoritesDatabase.isLoading || + favoritesPersonalView.isLoading + } + activeDocumentId={activeDocumentId} + manualReorder={{ + labels: sidebarReorderLabels, + onReorder: handlePinnedReorder, + }} + onOpenItem={(item) => { + const space = contentSpaces.find( + (candidate) => + candidate.filesDatabaseId === + item.workspaceFilesDatabaseId, + ); + if (!space || selectedSpace?.id === space.id) { + onNavigate?.(); + return false; + } + void handleSelectContentSpace(space, item.document.id); + onNavigate?.(); + return true; + }} + onCreateChildPage={(item) => + void handleCreatePage(item.document.id) + } + onCreateChildDatabase={(item) => + void handleCreateDatabase(item.document.id) + } + onDeleteItem={(item) => + requestDelete( + item.document.id, + item.document.title || t("sidebar.untitled"), + ) + } + onToggleFavorite={(item) => + handleToggleFavorite(item.document.id, false) + } + scroll={false} + labels={{ + noMatchesLabel: t("database.noRowsMatchThisView"), + clearLabel: t("database.clearSearchAndFilters"), + navigationLabel: t("sidebar.pinned"), + untitledLabel: t("sidebar.untitled"), + }} + /> + ) + } + /> + {renderTrashSection()}
@@ -2616,7 +2670,6 @@ export function DocumentSidebar({ {isCodeMode ? : null} diff --git a/templates/content/app/components/sidebar/PersonalSidebarSections.tsx b/templates/content/app/components/sidebar/PersonalSidebarSections.tsx index 99b1129e597..df6a332fe26 100644 --- a/templates/content/app/components/sidebar/PersonalSidebarSections.tsx +++ b/templates/content/app/components/sidebar/PersonalSidebarSections.tsx @@ -24,6 +24,7 @@ import { DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Skeleton } from "@/components/ui/skeleton"; @@ -123,33 +124,6 @@ export function PersonalSidebarSections({ ); return ( <> -
- - - - - - - {(["pinned", "recent"] as const).map((id) => ( - change(id, { visible })} - > - {labels[id]} - - ))} - - - -
({ id, @@ -168,6 +142,11 @@ export function PersonalSidebarSections({ id={id} label={labels[id]} reorderLabels={reorderLabels} + sections={sections} + labels={labels} + onChangeVisible={(sectionId, visible) => + change(sectionId, { visible }) + } > {renderWorkspaces()} @@ -179,6 +158,11 @@ export function PersonalSidebarSections({ expanded={sections[id].expanded} onToggle={() => change(id, { expanded: !sections[id].expanded })} reorderLabels={reorderLabels} + sections={sections} + labels={labels} + onChangeVisible={(sectionId, visible) => + change(sectionId, { visible }) + } > {sections[id].expanded && ( <> @@ -262,6 +246,9 @@ function PersonalSection({ expanded, onToggle, reorderLabels, + sections, + labels, + onChangeVisible, children, }: { id: ContentSidebarSectionId; @@ -269,8 +256,12 @@ function PersonalSection({ expanded?: boolean; onToggle?: () => void; reorderLabels: SidebarReorderLabels; + sections: ContentSidebarSections; + labels: Record; + onChangeVisible: (id: "pinned" | "recent", visible: boolean) => void; children: ReactNode; }) { + const t = useT(); const reorder = useSidebarReorderItem(id); return (
@@ -327,6 +318,22 @@ function PersonalSection({ {reorderLabels.moveDown} + + {/* Every section menu carries the visibility toggles, so a hidden + section stays restorable from the sections that remain. */} + + {(["pinned", "recent"] as const).map((sectionId) => ( + + onChangeVisible(sectionId, visible) + } + > + {labels[sectionId]} + + ))} +
diff --git a/templates/content/app/hooks/content-action-refresh.ts b/templates/content/app/hooks/content-action-refresh.ts index ce653ff83d3..2af781ee35c 100644 --- a/templates/content/app/hooks/content-action-refresh.ts +++ b/templates/content/app/hooks/content-action-refresh.ts @@ -135,6 +135,19 @@ function queryTargetsActiveDatabasePresentation(query: ActionQuery): boolean { ); } +function queryTargetsActiveNavigationOrRecent(query: ActionQuery): boolean { + if (query.queryKey[0] !== "action" || query.isActive?.() !== true) + return false; + if ( + query.queryKey[1] === "get-content-recent" || + query.queryKey[1] === "get-content-navigation-context" + ) + return true; + if (query.queryKey[1] !== "query-content-database-items") return false; + const args = query.queryKey[2]; + return !!args && typeof args === "object" && "navigation" in args; +} + export function contentDocumentIdFromPathname( pathname: string, ): string | undefined { @@ -147,6 +160,14 @@ export function contentActionInvalidatePredicate( ): (query: ActionQuery, events: readonly ActionEvent[]) => boolean { const documentId = contentDocumentIdFromPathname(pathname); return (query, events) => { + if ( + queryTargetsActiveNavigationOrRecent(query) && + events.some( + (event) => event.source === "action" && event.key === "update-document", + ) + ) { + return true; + } const args = query.queryKey[2]; const targetId = args && typeof args === "object" diff --git a/templates/content/app/hooks/use-content-database.test.ts b/templates/content/app/hooks/use-content-database.test.ts index 1c18ec12527..3c315b2bd5d 100644 --- a/templates/content/app/hooks/use-content-database.test.ts +++ b/templates/content/app/hooks/use-content-database.test.ts @@ -16,11 +16,13 @@ import { clearDeletedContentDatabaseFromCache, contentDatabaseResponseCanSeedQuery, contentDatabaseItemsPageQueryKey, + contentDatabaseItemsContainingDocumentFilter, contentDatabaseConstrainedQueryFilter, contentDatabaseQueryKey, fetchCompleteContentDatabaseList, invalidateBuilderBodyHydrationQueries, invalidateContentDatabaseSourceRefreshQueries, + invalidateContentDatabaseNavigationQueries, isContentDatabaseByIdQueryEnabled, moveOptimisticContentDatabaseItem, preserveScopedDatabasePlaceholder, @@ -165,6 +167,50 @@ describe("contentDatabaseConstrainedQueryFilter", () => { }); }); +describe("Content database navigation query invalidation", () => { + it("matches navigation rows safely and invalidates only the affected database", () => { + const queryClient = new QueryClient(); + const matchingKey = [ + "action", + "query-content-database-items", + { databaseId: "files", navigation: { parentId: null } }, + ] as const; + const otherKey = [ + "action", + "query-content-database-items", + { databaseId: "other", navigation: { parentId: null } }, + ] as const; + const tableKey = [ + "action", + "query-content-database-items", + { databaseId: "files", tableQuery: {} }, + ] as const; + queryClient.setQueryData(matchingKey, { + items: [{ documentId: "page" }], + }); + queryClient.setQueryData(otherKey, { items: [] }); + queryClient.setQueryData(tableKey, { + items: [{ document: { id: "page" } }], + }); + + expect(() => + queryClient.invalidateQueries( + contentDatabaseItemsContainingDocumentFilter("page"), + ), + ).not.toThrow(); + expect(queryClient.getQueryState(matchingKey)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(tableKey)?.isInvalidated).toBe(true); + + queryClient.resetQueries(); + invalidateContentDatabaseNavigationQueries(queryClient, { + databaseId: "files", + }); + expect(queryClient.getQueryState(matchingKey)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(otherKey)?.isInvalidated).toBe(false); + expect(queryClient.getQueryState(tableKey)?.isInvalidated).toBe(false); + }); +}); + describe("isContentDatabaseByIdQueryEnabled", () => { it("fetches when a databaseId is present and the caller doesn't pause it", () => { expect(isContentDatabaseByIdQueryEnabled("files-db")).toBe(true); diff --git a/templates/content/app/hooks/use-content-database.ts b/templates/content/app/hooks/use-content-database.ts index 71badf7c22c..22fe8701a5e 100644 --- a/templates/content/app/hooks/use-content-database.ts +++ b/templates/content/app/hooks/use-content-database.ts @@ -17,6 +17,7 @@ import type { ContentDatabaseSourceAttachmentAck, ContentDatabaseSourceAttachmentResult, ContentDatabaseItemsPageResponse, + ContentDatabaseNavigationPageResponse, ContentDatabaseTableQuery, ContentDatabaseItem, ContentDatabasePersonalViewResponse, @@ -65,8 +66,15 @@ export function contentDatabaseQueryKey(documentId: string) { return ["action", "get-content-database", { documentId }] as const; } -export function contentDatabaseByIdQueryKey(databaseId: string) { - return ["action", "get-content-database", { databaseId }] as const; +export function contentDatabaseByIdQueryKey( + databaseId: string, + limit?: number, +) { + return [ + "action", + "get-content-database", + { databaseId, ...(limit ? { limit } : {}) }, + ] as const; } export const contentDatabaseItemsPageQueryKey = [ @@ -215,14 +223,43 @@ export function contentDatabaseItemsContainingDocumentFilter( predicate: (query: Query) => { const data = query.state.data as | ContentDatabaseItemsPageResponse + | ContentDatabaseNavigationPageResponse | undefined; return ( - data?.items.some((item) => item.document.id === documentId) === true + data?.items.some((item) => + "document" in item + ? item.document.id === documentId + : item.documentId === documentId, + ) === true ); }, }; } +export function invalidateContentDatabaseNavigationQueries( + queryClient: Pick, + scope: { databaseId?: string; parentId?: string | null } = {}, +) { + void queryClient.invalidateQueries({ + queryKey: contentDatabaseItemsPageQueryKey, + predicate: (query) => { + const params = query.queryKey[2]; + if (!params || typeof params !== "object") return false; + const args = params as { + databaseId?: unknown; + navigation?: { parentId?: unknown }; + }; + if (!args.navigation) return false; + if (scope.databaseId && args.databaseId !== scope.databaseId) + return false; + return ( + scope.parentId === undefined || + args.navigation.parentId === scope.parentId + ); + }, + }); +} + export function writeContentDatabaseResponseToCache( queryClient: Pick, documentId: string, @@ -738,11 +775,13 @@ export function isContentDatabaseByIdQueryEnabled( export function useContentDatabaseById( databaseId: string | null, - options?: { enabled?: boolean }, + options?: { enabled?: boolean; limit?: number }, ) { return useActionQuery( "get-content-database", - databaseId ? { databaseId } : undefined, + databaseId + ? { databaseId, ...(options?.limit ? { limit: options.limit } : {}) } + : undefined, { enabled: isContentDatabaseByIdQueryEnabled(databaseId, options), retry: false, @@ -778,6 +817,7 @@ export function useCreateContentDatabase( queryKey: ["action", "list-documents"], }); } + invalidateContentDatabaseNavigationQueries(queryClient); }, }, ); @@ -802,6 +842,7 @@ export function useCreateInlineContentDatabase(hostDocumentId: string | null) { void queryClient.invalidateQueries({ queryKey: ["action", "list-documents"], }); + invalidateContentDatabaseNavigationQueries(queryClient); }, }); } @@ -814,14 +855,19 @@ export function useDeleteContentDatabase() { databaseId: string; documentId: string; deletedAt: string; + activeTargetDeleted: boolean; + navigationPath: string | null; }, - { databaseId: string } + { databaseId: string; activeDocumentId?: string } >("delete-content-database", { onSuccess: (data) => { clearDeletedContentDatabaseFromCache(queryClient, data.documentId); void queryClient.invalidateQueries({ queryKey: ["action", "list-trashed-documents"], }); + invalidateContentDatabaseNavigationQueries(queryClient, { + databaseId: data.databaseId, + }); }, }); } @@ -837,7 +883,7 @@ export function useRestoreContentDatabase() { }, { databaseId: string } >("restore-content-database", { - onSuccess: () => { + onSuccess: (data) => { void queryClient.invalidateQueries({ queryKey: ["action", "get-content-database"], }); @@ -856,6 +902,9 @@ export function useRestoreContentDatabase() { void queryClient.invalidateQueries({ queryKey: ["action", "list-content-databases"], }); + invalidateContentDatabaseNavigationQueries(queryClient, { + databaseId: data.databaseId, + }); }, }); } @@ -908,6 +957,9 @@ export function useAddDatabaseItem(documentId: string) { void queryClient.invalidateQueries({ queryKey: ["action", "list-documents"], }); + invalidateContentDatabaseNavigationQueries(queryClient, { + databaseId: data.receipt.target.databaseId, + }); }, }); } @@ -960,13 +1012,16 @@ export function useDuplicateDatabaseItems(documentId: string) { return useActionMutation( "duplicate-database-items", { - onSuccess: () => { + onSuccess: (data) => { void queryClient.invalidateQueries({ queryKey: contentDatabaseQueryKey(documentId), }); void queryClient.invalidateQueries({ queryKey: ["action", "list-documents"], }); + invalidateContentDatabaseNavigationQueries(queryClient, { + databaseId: data.database.id, + }); }, }, ); @@ -977,13 +1032,16 @@ export function useRemoveDatabaseItems(documentId: string) { return useActionMutation( "remove-database-items", { - onSuccess: () => { + onSuccess: (data) => { void queryClient.invalidateQueries({ queryKey: contentDatabaseQueryKey(documentId), }); void queryClient.invalidateQueries({ queryKey: ["action", "list-documents"], }); + invalidateContentDatabaseNavigationQueries(queryClient, { + databaseId: data.database.id, + }); }, }, ); @@ -1053,6 +1111,9 @@ export function useMoveDatabaseItem(documentId: string) { void queryClient.invalidateQueries({ queryKey: ["action", "list-documents"], }); + invalidateContentDatabaseNavigationQueries(queryClient, { + databaseId: variables.databaseId, + }); }, }, ); diff --git a/templates/content/app/hooks/use-content-recent.ts b/templates/content/app/hooks/use-content-recent.ts index 7d2b8cbb0ef..f35cc16e575 100644 --- a/templates/content/app/hooks/use-content-recent.ts +++ b/templates/content/app/hooks/use-content-recent.ts @@ -5,7 +5,7 @@ import { import { useT } from "@agent-native/core/client/i18n"; import { useOrg } from "@agent-native/core/client/org"; import { - contentRecentTargetKey, + contentRecentVisitKey, type ContentRecentTarget, } from "@shared/content-personal-navigation"; import { useQueryClient } from "@tanstack/react-query"; @@ -67,7 +67,7 @@ export function useRecordContentVisit( const record = useContentVisitRecorder(); const targetRef = useRef(target); targetRef.current = target; - const key = target ? contentRecentTargetKey(target) : null; + const key = target ? contentRecentVisitKey(target) : null; const recordedKey = useRef(null); useEffect(() => { if (!enabled || !key) return; diff --git a/templates/content/app/hooks/use-content-spaces.ts b/templates/content/app/hooks/use-content-spaces.ts index 3c3addc15a7..311a5211553 100644 --- a/templates/content/app/hooks/use-content-spaces.ts +++ b/templates/content/app/hooks/use-content-spaces.ts @@ -18,6 +18,7 @@ export type ContentSpaceSummary = { }; export type ListContentSpacesResponse = { + sourceMode: "database" | "local-files"; catalogDatabaseId: string; catalogDocumentId: string; favoritesDatabaseId: string | null; diff --git a/templates/content/app/hooks/use-db-sync.spec.ts b/templates/content/app/hooks/use-db-sync.spec.ts index fd2861992de..d6c0e1f5a12 100644 --- a/templates/content/app/hooks/use-db-sync.spec.ts +++ b/templates/content/app/hooks/use-db-sync.spec.ts @@ -284,6 +284,56 @@ describe("contentActionInvalidatePredicate", () => { ), ).toBe(false); }); + + it("refreshes active navigation and Recent after an external rename on any route", () => { + const predicate = contentActionInvalidatePredicate("/settings"); + const event = [{ source: "action", key: "update-document" }]; + + expect( + predicate( + { + queryKey: [ + "action", + "query-content-database-items", + { databaseId: "files", navigation: { parentId: null } }, + ], + isActive: () => true, + }, + event, + ), + ).toBe(true); + expect( + predicate( + { + queryKey: ["action", "get-content-recent", { scopeKey: "user" }], + isActive: () => true, + }, + event, + ), + ).toBe(true); + expect( + predicate( + { + queryKey: [ + "action", + "get-content-navigation-context", + { id: "document" }, + ], + isActive: () => true, + }, + event, + ), + ).toBe(true); + expect( + predicate( + { + queryKey: ["action", "get-content-recent", { scopeKey: "user" }], + isActive: () => false, + }, + event, + ), + ).toBe(false); + }); }); describe("contentDocumentIdFromPathname", () => { diff --git a/templates/content/app/hooks/use-document-title-mutation.test.ts b/templates/content/app/hooks/use-document-title-mutation.test.ts index f75ccb1e0cb..605c62e6944 100644 --- a/templates/content/app/hooks/use-document-title-mutation.test.ts +++ b/templates/content/app/hooks/use-document-title-mutation.test.ts @@ -3,12 +3,17 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const useActionMutation = vi.hoisted(() => vi.fn()); const useQueryClient = vi.hoisted(() => vi.fn()); +const toast = vi.hoisted(() => Object.assign(vi.fn(), { error: vi.fn() })); vi.mock("@agent-native/core/client/hooks", () => ({ useActionMutation, useActionQuery: vi.fn(), callAction: vi.fn(), })); +vi.mock("@agent-native/core/client/i18n", () => ({ + useT: () => (key: string) => key, +})); +vi.mock("sonner", () => ({ toast })); vi.mock("@tanstack/react-query", async () => ({ ...(await vi.importActual("@tanstack/react-query")), @@ -21,6 +26,8 @@ describe("title changes and database query membership", () => { beforeEach(() => { useActionMutation.mockReset(); useQueryClient.mockReset(); + toast.mockClear(); + toast.error.mockClear(); useActionMutation.mockImplementation((_name, options) => options); }); @@ -102,4 +109,147 @@ describe("title changes and database query membership", () => { } }, ); + + it("optimistically patches loaded navigation and Recent titles, then invalidates both", async () => { + const client = new QueryClient({ + defaultOptions: { queries: { staleTime: Infinity, retry: false } }, + }); + useQueryClient.mockReturnValue(client); + const navigationKey = [ + "action", + "query-content-database-items", + { databaseId: "files", navigation: { parentId: null } }, + ]; + const recentKey = ["action", "get-content-recent", { scopeKey: "user" }]; + const navigationContextKey = [ + "action", + "get-content-navigation-context", + { id: "row-1" }, + ]; + client.setQueryData(navigationKey, { + items: [{ documentId: "row-1", title: "Untitled" }], + }); + client.setQueryData(recentKey, { + scopeKey: "user", + entries: [ + { + target: { documentId: "row-1" }, + visitedAt: "2026-09-14T00:00:00.000Z", + title: "Untitled", + icon: null, + viewName: null, + }, + ], + }); + client.setQueryData(navigationContextKey, { + document: { id: "row-1", title: "Untitled" }, + path: [{ id: "row-1", title: "Untitled" }], + }); + + useUpdateDocument(); + const mutation = useActionMutation.mock.calls.find( + ([name]) => name === "update-document", + )![1]; + const context = await mutation.onMutate({ + id: "row-1", + title: "Renamed page", + }); + + expect(client.getQueryData(navigationKey).items[0].title).toBe( + "Renamed page", + ); + expect(client.getQueryData(recentKey).entries[0].title).toBe( + "Renamed page", + ); + expect(client.getQueryData(navigationContextKey).path[0].title).toBe( + "Renamed page", + ); + + mutation.onSuccess( + { + id: "row-1", + title: "Renamed page", + softDeletedDatabaseIds: [], + }, + { id: "row-1", title: "Renamed page" }, + context, + ); + + expect(client.getQueryState(navigationKey)?.isInvalidated).toBe(true); + expect(client.getQueryState(recentKey)?.isInvalidated).toBe(true); + expect(client.getQueryState(navigationContextKey)?.isInvalidated).toBe( + true, + ); + client.clear(); + }); + + it("restores hidden Pinned from click-time sidebar state and rolls back a failed save", async () => { + const client = new QueryClient(); + useQueryClient.mockReturnValue(client); + const saveSidebarState = vi.fn().mockRejectedValue(new Error("failed")); + useActionMutation.mockImplementation((name, options) => + name === "update-content-sidebar-state" + ? { mutateAsync: saveSidebarState } + : options, + ); + const sidebarKey = ["action", "get-content-sidebar-state", {}] as const; + const hiddenState = { + state: { + version: 1 as const, + sections: { + order: ["pinned", "recent", "workspaces"], + pinned: { visible: false, expanded: false, limit: 5 }, + recent: { visible: true, expanded: true, limit: 5 }, + }, + }, + }; + client.setQueryData(sidebarKey, hiddenState); + + useUpdateDocument(); + const mutation = useActionMutation.mock.calls.find( + ([name]) => name === "update-document", + )![1]; + const context = await mutation.onMutate({ id: "row-1", isFavorite: true }); + mutation.onSuccess( + { + id: "row-1", + isFavorite: true, + softDeletedDatabaseIds: [], + }, + { id: "row-1", isFavorite: true }, + context, + ); + const action = toast.mock.calls[0]![1].action; + const newerState = { + state: { + ...hiddenState.state, + sections: { + ...hiddenState.state.sections, + recent: { visible: false, expanded: false, limit: 10 }, + }, + }, + }; + client.setQueryData(sidebarKey, newerState); + + action.onClick(); + expect(client.getQueryData(sidebarKey).state.sections).toEqual({ + ...newerState.state.sections, + pinned: { visible: true, expanded: true, limit: 5 }, + }); + expect(saveSidebarState).toHaveBeenCalledWith({ + version: 1, + sections: { + ...newerState.state.sections, + pinned: { visible: true, expanded: true, limit: 5 }, + }, + }); + await vi.waitFor(() => { + expect(client.getQueryData(sidebarKey)).toEqual(newerState); + expect(toast.error).toHaveBeenCalledWith( + "sidebar.failedSaveSidebarState", + ); + }); + expect(action.label).toBe("editor.properties.show"); + client.clear(); + }); }); diff --git a/templates/content/app/hooks/use-documents.mutations.test.ts b/templates/content/app/hooks/use-documents.mutations.test.ts index 625fd3d1ea2..5b3246d62c5 100644 --- a/templates/content/app/hooks/use-documents.mutations.test.ts +++ b/templates/content/app/hooks/use-documents.mutations.test.ts @@ -2,11 +2,16 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const useActionMutation = vi.hoisted(() => vi.fn()); const useActionQuery = vi.hoisted(() => vi.fn()); +const invalidateQueries = vi.hoisted(() => vi.fn()); vi.mock("@agent-native/core/client/hooks", () => ({ useActionMutation, useActionQuery, })); +vi.mock("@tanstack/react-query", async (importOriginal) => ({ + ...(await importOriginal()), + useQueryClient: () => ({ invalidateQueries }), +})); import { useCreateDocument, @@ -17,6 +22,7 @@ describe("useUpdatePreviewDocumentDraft", () => { beforeEach(() => { useActionMutation.mockReset(); useActionQuery.mockReset(); + invalidateQueries.mockReset(); }); it("keeps originating-tab draft autosaves out of generic action invalidation", () => { @@ -50,5 +56,12 @@ describe("useCreateDocument", () => { skipActionQueryInvalidation: true, }), ); + const options = useActionMutation.mock.calls[0]?.[1]; + options.onSuccess(); + expect(invalidateQueries).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: ["action", "query-content-database-items"], + }), + ); }); }); diff --git a/templates/content/app/hooks/use-documents.test.ts b/templates/content/app/hooks/use-documents.test.ts index ac730a23a88..0738cb0c2a9 100644 --- a/templates/content/app/hooks/use-documents.test.ts +++ b/templates/content/app/hooks/use-documents.test.ts @@ -16,6 +16,7 @@ import { documentQueryKey, filterDocumentTreeDocuments, isDocumentUpdateConflict, + isFavoritesDatabaseCache, mergeDocumentIntoDocumentCache, mergeDocumentIntoListDocumentsCache, patchDocumentCaches, @@ -405,6 +406,11 @@ describe("mergeDocumentIntoListDocumentsCache", () => { }); describe("optimistic document favorites", () => { + it("does not treat an unavailable database cache entry as a Favorites response", () => { + expect( + isFavoritesDatabaseCache({ available: false, reason: "missing" }), + ).toBe(false); + }); it("updates array and object list caches without disturbing other pages", () => { const favorite = { ...doc("a", null), isFavorite: true }; expect( @@ -449,6 +455,39 @@ describe("optimistic document favorites", () => { expect(database.items[0].document.isFavorite).toBe(false); }); + it("updates flat navigation rows without treating them as database rows", () => { + const navigation = { + items: [ + { + membershipId: "item-a", + membershipPosition: 0, + documentId: "a", + title: "A", + icon: null, + isFavorite: false, + }, + { + membershipId: "item-b", + membershipPosition: 1, + documentId: "b", + title: "B", + icon: null, + isFavorite: false, + }, + ], + pagination: { hasMore: false, limit: 20, nextCursor: null }, + } as any; + + const updated = patchDocumentInDatabaseCache(navigation, "a", { + isFavorite: true, + })!; + expect(updated.items[0]).toMatchObject({ + documentId: "a", + isFavorite: true, + }); + expect(updated.items[1]).toBe(navigation.items[1]); + }); + it("removes unfavorited pages from a cached Favorites database", () => { const database = { database: { systemRole: "favorites" }, diff --git a/templates/content/app/hooks/use-documents.ts b/templates/content/app/hooks/use-documents.ts index 5c2709c6fad..310e5895c25 100644 --- a/templates/content/app/hooks/use-documents.ts +++ b/templates/content/app/hooks/use-documents.ts @@ -3,6 +3,7 @@ import { useActionQuery, useActionMutation, } from "@agent-native/core/client/hooks"; +import { useT } from "@agent-native/core/client/i18n"; import type { ContentDatabaseItemsPageResponse, ContentDatabaseResponse, @@ -17,6 +18,9 @@ import type { ListTrashedDocumentsResponse, DocumentTreeNode, } from "@shared/api"; +import type { ContentSidebarSections } from "@shared/content-personal-navigation"; +import type { ContentRecentResult } from "@shared/content-personal-navigation"; +import { applyContentPersonalNavigationPatch } from "@shared/content-personal-navigation-patch"; import type { QueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; @@ -29,6 +33,7 @@ import { import { contentDatabaseConstrainedQueryFilter, contentDatabaseItemsContainingDocumentFilter, + invalidateContentDatabaseNavigationQueries, removeOptimisticItemFromContentDatabase, useRestoreContentDatabase, } from "./use-content-database"; @@ -325,7 +330,10 @@ export function setDocumentFavoriteInListCache( } export function patchDocumentInDatabaseCache< - T extends ContentDatabaseResponse | ContentDatabaseItemsPageResponse, + T extends + | ContentDatabaseResponse + | ContentDatabaseItemsPageResponse + | import("@shared/api").ContentDatabaseNavigationPageResponse, >( current: T | undefined, documentId: string, @@ -334,6 +342,21 @@ export function patchDocumentInDatabaseCache< if (!current) return current; let changed = false; const items = current.items.map((item) => { + if (!("document" in item)) { + if (item.documentId !== documentId) return item; + changed = true; + return { + ...item, + ...(patch.title !== undefined ? { title: patch.title } : {}), + ...(patch.icon !== undefined ? { icon: patch.icon } : {}), + ...(patch.isFavorite !== undefined + ? { isFavorite: patch.isFavorite } + : {}), + ...(patch.updatedAt !== undefined + ? { updatedAt: patch.updatedAt } + : {}), + }; + } if (item.document.id !== documentId) return item; changed = true; return { @@ -355,6 +378,16 @@ export function setDocumentFavoriteInDatabaseCache( return patchDocumentInDatabaseCache(current, documentId, { isFavorite }); } +export function isFavoritesDatabaseCache( + current: unknown, +): current is ContentDatabaseResponse { + if (!current || typeof current !== "object") return false; + return ( + (current as Partial).database?.systemRole === + "favorites" + ); +} + function patchDocumentWithFavoriteMembershipInDatabaseCache( current: ContentDatabaseResponse | undefined, documentId: string, @@ -390,6 +423,38 @@ export function patchDocumentCaches( contentDatabaseItemsContainingDocumentFilter(documentId), (current) => patchDocumentInDatabaseCache(current, documentId, patch), ); + queryClient.setQueriesData<{ entries: ContentRecentResult[] }>( + { queryKey: ["action", "get-content-recent"] }, + (current) => { + if (!current || patch.title === undefined) return current; + let changed = false; + const entries = current.entries.map((entry) => { + if (entry.target.documentId !== documentId) return entry; + changed = true; + return { ...entry, title: patch.title! }; + }); + return changed ? { ...current, entries } : current; + }, + ); + queryClient.setQueriesData<{ + document?: Document; + path?: Array & { id: string }>; + }>({ queryKey: ["action", "get-content-navigation-context"] }, (current) => { + if (!current) return current; + const document = + current.document?.id === documentId + ? { ...current.document, ...patch } + : current.document; + let pathChanged = false; + const path = current.path?.map((entry) => { + if (entry.id !== documentId) return entry; + pathChanged = true; + return { ...entry, ...patch }; + }); + return document !== current.document || pathChanged + ? { ...current, document, path } + : current; + }); } type ContentSpaceNameCache = { @@ -491,7 +556,7 @@ export function seedDatabaseItemDocumentCaches( } } -export function useDocuments() { +export function useDocuments(options?: { enabled?: boolean }) { return useQuery({ queryKey: LIST_DOCUMENTS_QUERY_KEY, queryFn: async ({ signal }) => ({ @@ -505,6 +570,7 @@ export function useDocuments() { }), select: (data) => data.documents, retry: false, + enabled: options?.enabled !== false, }); } @@ -593,14 +659,20 @@ export function useUpdatePreviewDocumentDraft() { } export function useCreateDocument() { + const queryClient = useQueryClient(); return useActionMutation("create-document", { skipActionQueryInvalidation: true, + onSuccess: () => invalidateContentDatabaseNavigationQueries(queryClient), }); } export function useUpdateDocument() { const queryClient = useQueryClient(); + const t = useT(); const restoreContentDatabase = useRestoreContentDatabase(); + const updateSidebarState = useActionMutation("update-content-sidebar-state", { + skipActionQueryInvalidation: true, + }); return useActionMutation( "update-document", { @@ -625,12 +697,21 @@ export function useUpdateDocument() { const contentSpacesFilter = { queryKey: ["action", "list-content-spaces"], } as const; + const personalViewFilter = { + queryKey: ["action", "get-content-database-personal-view"], + } as const; + const sidebarStateKey = [ + "action", + "get-content-sidebar-state", + {}, + ] as const; await Promise.all([ queryClient.cancelQueries(documentFilter), queryClient.cancelQueries({ queryKey: LIST_DOCUMENTS_QUERY_KEY }), queryClient.cancelQueries(databaseFilter), queryClient.cancelQueries(databasePageFilter), queryClient.cancelQueries(contentSpacesFilter), + queryClient.cancelQueries(personalViewFilter), ]); const previous: Array<[readonly unknown[], unknown]> = [ @@ -646,8 +727,103 @@ export function useUpdateDocument() { databasePageFilter, ), ...queryClient.getQueriesData(contentSpacesFilter), + ...queryClient.getQueriesData(personalViewFilter), + [sidebarStateKey, queryClient.getQueryData(sidebarStateKey)], ]; + const sidebarState = queryClient.getQueryData<{ + state?: { version: 1; sections: ContentSidebarSections }; + }>(sidebarStateKey); + const nextSidebarState = + variables.isFavorite === true && + sidebarState?.state?.sections.pinned.visible && + !sidebarState.state.sections.pinned.expanded + ? { + version: 1 as const, + sections: { + ...sidebarState.state.sections, + pinned: { + ...sidebarState.state.sections.pinned, + expanded: true, + }, + }, + } + : undefined; + if (nextSidebarState) + queryClient.setQueryData(sidebarStateKey, { + state: nextSidebarState, + }); + + if (variables.isFavorite === true) { + const listSnapshot = queryClient.getQueryData( + LIST_DOCUMENTS_QUERY_KEY, + ); + const documents: Document[] = Array.isArray(listSnapshot) + ? listSnapshot + : ((listSnapshot as DocumentListResponse | undefined)?.documents ?? + []); + const document = documents.find( + (candidate) => candidate.id === variables.id, + ); + if (document) { + for (const [ + databaseKey, + database, + ] of queryClient.getQueriesData({ + queryKey: ["action", "get-content-database"], + })) { + if (!isFavoritesDatabaseCache(database)) continue; + if ( + database.items.some((item) => item.document.id === variables.id) + ) + continue; + const optimisticItemId = `optimistic-favorite:${variables.id}`; + queryClient.setQueryData(databaseKey, { + ...database, + items: [ + { + id: optimisticItemId, + databaseId: database.database.id, + position: -1, + properties: [], + document: { ...document, isFavorite: true }, + }, + ...database.items, + ], + }); + const personalKey = [ + "action", + "get-content-database-personal-view", + { databaseId: database.database.id }, + ] as const; + queryClient.setQueryData<{ + databaseId: string; + overrides: + | import("@shared/api").ContentDatabasePersonalViewOverrides + | null; + }>(personalKey, (current) => { + if (!current?.overrides) return current; + const activeViewId = + current.overrides.activeViewId ?? + database.database.viewConfig.activeViewId; + return { + ...current, + overrides: applyContentPersonalNavigationPatch( + current.overrides, + { + sidebarOrder: { + operation: "prepend", + viewId: activeViewId, + itemId: optimisticItemId, + }, + }, + ), + }; + }); + } + } + } + patchDocumentCaches(queryClient, variables.id, optimisticPatch); const renamedContentSpace = variables.title !== undefined @@ -658,7 +834,7 @@ export function useUpdateDocument() { ) : false; - return { previous, renamedContentSpace }; + return { previous, renamedContentSpace, nextSidebarState }; }, onError: (_error, variables, context) => { const rollback = context as @@ -670,6 +846,25 @@ export function useUpdateDocument() { const renamedContentSpace = ( context as { renamedContentSpace?: boolean } | undefined )?.renamedContentSpace; + const nextSidebarState = ( + context as + | { + nextSidebarState?: { + version: 1; + sections: ContentSidebarSections; + }; + } + | undefined + )?.nextSidebarState; + const previousSidebarState = ( + context as + | { previous?: Array<[readonly unknown[], unknown]> } + | undefined + )?.previous?.find( + ([key]) => key[1] === "get-content-sidebar-state", + )?.[1] as + | { state?: { version: 1; sections: ContentSidebarSections } } + | undefined; // A CAS conflict is a normal (non-thrown) result, not a successful // save — converge the caches to the returned server document (so the // UI immediately reflects the write that actually won) but skip the @@ -703,6 +898,7 @@ export function useUpdateDocument() { serverDocument, ), ); + patchDocumentCaches(queryClient, variables.id, serverDocument); if (renamedContentSpace) { patchContentSpaceNameCaches( queryClient, @@ -723,6 +919,15 @@ export function useUpdateDocument() { void queryClient.invalidateQueries( contentDatabaseConstrainedQueryFilter(), ); + if (variables.title !== undefined) { + invalidateContentDatabaseNavigationQueries(queryClient); + void queryClient.invalidateQueries({ + queryKey: ["action", "get-content-recent"], + }); + void queryClient.invalidateQueries({ + queryKey: ["action", "get-content-navigation-context"], + }); + } return; } @@ -735,6 +940,13 @@ export function useUpdateDocument() { void queryClient.invalidateQueries( contentDatabaseConstrainedQueryFilter(), ); + invalidateContentDatabaseNavigationQueries(queryClient); + void queryClient.invalidateQueries({ + queryKey: ["action", "get-content-recent"], + }); + void queryClient.invalidateQueries({ + queryKey: ["action", "get-content-navigation-context"], + }); } if (renamedContentSpace) { patchContentSpaceNameCaches(queryClient, variables.id, data.title); @@ -746,9 +958,71 @@ export function useUpdateDocument() { }); } if (variables.isFavorite !== undefined) { + invalidateContentDatabaseNavigationQueries(queryClient); void queryClient.invalidateQueries({ queryKey: ["action", "get-content-database"], }); + void queryClient.invalidateQueries({ + queryKey: ["action", "get-content-database-personal-view"], + }); + if (nextSidebarState) + void updateSidebarState.mutateAsync(nextSidebarState).then( + (saved) => + queryClient.setQueryData( + ["action", "get-content-sidebar-state", {}], + saved, + ), + () => + queryClient.invalidateQueries({ + queryKey: ["action", "get-content-sidebar-state"], + }), + ); + if ( + variables.isFavorite === true && + previousSidebarState?.state?.sections.pinned.visible === false + ) { + toast(t("sidebar.pinned"), { + action: { + label: t("editor.properties.show"), + onClick: () => { + const sidebarStateKey = [ + "action", + "get-content-sidebar-state", + {}, + ] as const; + const current = queryClient.getQueryData<{ + state?: { version: 1; sections: ContentSidebarSections }; + }>(sidebarStateKey); + if (!current?.state) { + toast.error(t("sidebar.failedSaveSidebarState")); + void queryClient.invalidateQueries({ + queryKey: ["action", "get-content-sidebar-state"], + }); + return; + } + const next = { + version: 1 as const, + sections: { + ...current.state.sections, + pinned: { + ...current.state.sections.pinned, + visible: true, + expanded: true, + }, + }, + }; + queryClient.setQueryData(sidebarStateKey, { state: next }); + void updateSidebarState.mutateAsync(next).then( + (saved) => queryClient.setQueryData(sidebarStateKey, saved), + () => { + queryClient.setQueryData(sidebarStateKey, current); + toast.error(t("sidebar.failedSaveSidebarState")); + }, + ); + }, + }, + }); + } } if (data.softDeletedDatabaseIds.length > 0) { @@ -787,8 +1061,14 @@ export function useUpdateDocument() { export function useDeleteDocument() { const queryClient = useQueryClient(); return useActionMutation< - { success: boolean; deleted: number; removed?: number }, - { id: string; databaseDocumentId?: string } + { + success: boolean; + deleted: number; + removed?: number; + activeTargetDeleted?: boolean; + navigationPath?: string | null; + }, + { id: string; databaseDocumentId?: string; activeDocumentId?: string } >("delete-document", { onSuccess: (_data, variables) => { void queryClient.invalidateQueries({ @@ -807,6 +1087,7 @@ export function useDeleteDocument() { void queryClient.invalidateQueries({ queryKey: ["action", "list-trashed-documents"], }); + invalidateContentDatabaseNavigationQueries(queryClient); }, }); } @@ -837,6 +1118,7 @@ export function useRestoreDocument() { void queryClient.invalidateQueries({ queryKey: ["action", "list-trashed-content-databases"], }); + invalidateContentDatabaseNavigationQueries(queryClient); }, }); } @@ -874,6 +1156,7 @@ export function useMoveDocument() { queryKey: ["action", "list-documents"], }); void queryClient.invalidateQueries(documentQueryFilter(variables.id)); + invalidateContentDatabaseNavigationQueries(queryClient); }, }, ); diff --git a/templates/content/app/i18n-data.ts b/templates/content/app/i18n-data.ts index d5b19e66ebe..7caa6225716 100644 --- a/templates/content/app/i18n-data.ts +++ b/templates/content/app/i18n-data.ts @@ -3581,7 +3581,7 @@ const enUS = { page: "Page", private: "Private", results: "Results", - search: "Search", + search: "Search...", searchPages: "Search pages...", refreshConnection: "Refresh connection", removeLocalFilesDescription: @@ -10240,7 +10240,7 @@ export const messagesByLocale = { pageRestored: "页面已恢复", private: "私有", results: "结果", - search: "搜索", + search: "搜索...", searchPages: "搜索页面...", sharedCopies: "共享副本", trashEmpty: "回收站为空", @@ -10443,7 +10443,7 @@ export const messagesByLocale = { pageRestored: "Página restaurada", private: "Privado", results: "Resultados", - search: "Buscar", + search: "Buscar...", searchPages: "Buscar páginas...", sharedCopies: "Copias compartidas", trashEmpty: "La papelera está vacía", @@ -10646,7 +10646,7 @@ export const messagesByLocale = { pageRestored: "Page restaurée", private: "Privé", results: "Résultats", - search: "Rechercher", + search: "Rechercher...", searchPages: "Rechercher des pages...", sharedCopies: "Copies partagées", trashEmpty: "La corbeille est vide", @@ -10846,7 +10846,7 @@ export const messagesByLocale = { pageRestored: "Seite wiederhergestellt", private: "Privat", results: "Ergebnisse", - search: "Suchen", + search: "Suchen...", searchPages: "Seiten suchen...", sharedCopies: "Geteilte Kopien", trashEmpty: "Papierkorb ist leer", @@ -11045,7 +11045,7 @@ export const messagesByLocale = { pageRestored: "ページを復元しました", private: "非公開", results: "結果", - search: "検索", + search: "検索...", searchPages: "ページを検索...", sharedCopies: "共有コピー", trashEmpty: "ゴミ箱は空です", @@ -11234,7 +11234,7 @@ export const messagesByLocale = { pageRestored: "페이지가 복원되었습니다", private: "비공개", results: "결과", - search: "검색", + search: "검색...", searchPages: "페이지 검색...", sharedCopies: "공유 사본", trashEmpty: "휴지통이 비어 있습니다", @@ -11435,7 +11435,7 @@ export const messagesByLocale = { pageRestored: "Página restaurada", private: "Privado", results: "Resultados", - search: "Buscar", + search: "Buscar...", searchPages: "Buscar páginas...", sharedCopies: "Cópias compartilhadas", trashEmpty: "A lixeira está vazia", @@ -11622,7 +11622,7 @@ export const messagesByLocale = { pageRestored: "पेज पुनर्स्थापित किया गया", private: "निजी", results: "परिणाम", - search: "खोजें", + search: "खोजें...", searchPages: "पेज खोजें...", sharedCopies: "साझा कॉपियां", trashEmpty: "ट्रैश खाली है", @@ -11812,7 +11812,7 @@ export const messagesByLocale = { pageRestored: "تمت استعادة الصفحة", private: "خاص", results: "النتائج", - search: "بحث", + search: "بحث...", searchPages: "البحث في الصفحات...", sharedCopies: "النسخ المشتركة", trashEmpty: "سلة المهملات فارغة", diff --git a/templates/content/app/i18n/zh-TW.ts b/templates/content/app/i18n/zh-TW.ts index 4664a2467bf..2e443be9c7d 100644 --- a/templates/content/app/i18n/zh-TW.ts +++ b/templates/content/app/i18n/zh-TW.ts @@ -1367,7 +1367,7 @@ const messages = { page: "頁面", private: "私人", results: "結果", - search: "搜尋", + search: "搜尋...", searchPages: "搜尋頁面...", refreshConnection: "重新整理連線", restoreDatabase: "還原", diff --git a/templates/content/app/lib/content-command-menu.test.ts b/templates/content/app/lib/content-command-menu.test.ts new file mode 100644 index 00000000000..6b6eec9d885 --- /dev/null +++ b/templates/content/app/lib/content-command-menu.test.ts @@ -0,0 +1,24 @@ +// @vitest-environment happy-dom + +import { describe, expect, it, vi } from "vitest"; + +import { + CONTENT_COMMAND_MENU_OPEN_EVENT, + openContentCommandMenu, +} from "./content-command-menu"; + +describe("openContentCommandMenu", () => { + it("carries the visible launcher that should regain focus", () => { + const launcher = document.createElement("button"); + const listener = vi.fn(); + window.addEventListener(CONTENT_COMMAND_MENU_OPEN_EVENT, listener); + + openContentCommandMenu(launcher); + + expect(listener).toHaveBeenCalledOnce(); + expect((listener.mock.calls[0][0] as CustomEvent).detail).toEqual({ + returnFocusTo: launcher, + }); + window.removeEventListener(CONTENT_COMMAND_MENU_OPEN_EVENT, listener); + }); +}); diff --git a/templates/content/app/lib/content-command-menu.ts b/templates/content/app/lib/content-command-menu.ts new file mode 100644 index 00000000000..6f02df4445c --- /dev/null +++ b/templates/content/app/lib/content-command-menu.ts @@ -0,0 +1,9 @@ +export const CONTENT_COMMAND_MENU_OPEN_EVENT = "content:open-command-menu"; + +export function openContentCommandMenu(returnFocusTo?: HTMLElement) { + window.dispatchEvent( + new CustomEvent(CONTENT_COMMAND_MENU_OPEN_EVENT, { + detail: { returnFocusTo }, + }), + ); +} diff --git a/templates/content/app/root.tsx b/templates/content/app/root.tsx index b59d965fa6a..a23425c4d67 100644 --- a/templates/content/app/root.tsx +++ b/templates/content/app/root.tsx @@ -40,6 +40,7 @@ import { useCallback, useEffect, useMemo, + useRef, useState, } from "react"; import { @@ -72,6 +73,7 @@ import { LocalFolderLiveSync } from "./components/LocalFolderLiveSync"; import { useDbSync } from "./hooks/use-db-sync"; import { useNavigationState } from "./hooks/use-navigation-state"; import { i18nCatalog } from "./i18n"; +import { CONTENT_COMMAND_MENU_OPEN_EVENT } from "./lib/content-command-menu"; import { contentCommandDocumentPath, groupContentCommandSearchResults, @@ -528,9 +530,11 @@ function PublicAgentShell({ children }: { children: React.ReactNode }) { function ContentCommandMenu({ open, onOpenChange, + onCloseAutoFocus, }: { open: boolean; onOpenChange: (open: boolean) => void; + onCloseAutoFocus: (event: Event) => void; }) { const t = useT(); const navigate = useNavigate(); @@ -538,6 +542,7 @@ function ContentCommandMenu({ createAgentNativeQueryClient()); const [cmdkOpen, setCmdkOpen] = useState(false); + const commandMenuReturnFocusRef = useRef(null); const location = useLocation(); const loaderData = useLoaderData(); useCommandMenuShortcut(useCallback(() => setCmdkOpen(true), [])); + useEffect(() => { + const handleOpen = (event: Event) => { + commandMenuReturnFocusRef.current = + (event as CustomEvent<{ returnFocusTo?: HTMLElement }>).detail + ?.returnFocusTo ?? null; + setCmdkOpen(true); + }; + window.addEventListener(CONTENT_COMMAND_MENU_OPEN_EVENT, handleOpen); + return () => + window.removeEventListener(CONTENT_COMMAND_MENU_OPEN_EVENT, handleOpen); + }, []); + const handleCommandMenuCloseAutoFocus = useCallback((event: Event) => { + const target = commandMenuReturnFocusRef.current; + if (!target?.isConnected) return; + event.preventDefault(); + commandMenuReturnFocusRef.current = null; + target.focus(); + }, []); // Public document paths (/p/*) SSR real content without the ClientOnly gate // so crawlers and unauthenticated visitors receive full markup on first visit. @@ -625,7 +649,11 @@ export default function Root() { - + diff --git a/templates/content/docs/product/capabilities/content.navigation.sidebar.md b/templates/content/docs/product/capabilities/content.navigation.sidebar.md index b83310efb0c..f4c97a57d97 100644 --- a/templates/content/docs/product/capabilities/content.navigation.sidebar.md +++ b/templates/content/docs/product/capabilities/content.navigation.sidebar.md @@ -30,9 +30,11 @@ evidence: "../../../actions/content-recent.test.ts", "../../../actions/content-personal-navigation-patch.test.ts", "../../../app/components/editor/database/DatabaseView.recent.test.ts", + "../../../app/components/sidebar/DocumentSidebar.layout.test.ts", + "../../../app/hooks/use-content-database.test.ts", ] superseded_by: null -last_reviewed: "2026-07-29" +last_reviewed: "2026-09-14" --- # Personal sidebar @@ -78,17 +80,21 @@ Existing sidebar section tests and sidebar rendering show useful donor behavior. not prove the full Reference/query, access, recovery, and personal-state contract; this Capability remains `approved_shape`. -Personal section settings and bounded Recent navigation now use per-user Actions. -Recent stores target and exact View identity, resolves current labels under current -context access, and records successful foreground visits rather than edits. Focused -tests cover bounded recency, scope rejection, concurrent navigation patches, legacy -preference migration, and preservation of inherited filters. Local authenticated UI -checks cover exact Table/Board navigation, reload, pointer and keyboard pin order, -retained keyboard focus, section settings, mobile drawer navigation, and independent -owner/viewer preferences. Action reads confirm unchanged parentage, membership, -shared View configuration, and access grants after personal navigation changes. -Mounted rename reconciliation passed only with the separately owned lifecycle overlay; -mounted revocation/deletion and failed-read/write UI recovery remain integration gates. +Personal section settings and bounded Recent navigation use per-user Actions. Recent +stores one entry per Database with its latest successfully visited View, resolves +current labels under current access, and records successful foreground visits rather +than reads or edits. New pins prepend in personal custom order without changing shared +parentage or membership. The persistent Search launcher opens the existing command +menu instead of maintaining a second sidebar search implementation. + +Database-backed workspace trees read at most 20 roots or immediate children per page, +use cursor-based Show more, and resolve an active path without enumerating every +document. Focused tests cover paging limits, parent-scoped reads, access filtering, +cursor scope and staleness, active-path context, bounded deletion outcomes, bounded +recency, legacy Recent migration, personal pin ordering, and navigation patch +concurrency. These are useful implementation and test evidence, not complete atomic +contract proof. Local authenticated UI evidence and mounted lifecycle recovery remain +incomplete, so this Capability remains `approved_shape`. ## Proof plan @@ -98,7 +104,14 @@ mounted revocation/deletion and failed-read/write UI recovery remain integration ## Open questions -The initial catalog is Pinned and Recent alongside existing workspace navigation. -Pinned and Recent start visible and expanded, show five entries initially, and allow -five-entry increments up to fifty. Section order, visibility, expansion, and display -limits are personal preferences. Additional dynamic sections remain outside this slice. +- The initial catalog is Pinned and Recent alongside existing workspace navigation. + Pinned and Recent start visible and expanded, show five entries initially, and allow + five-entry increments up to fifty. Section order, visibility, expansion, and display + limits are personal preferences. +- Database Recent identity is one entry per Database, retaining its latest visited View; + plain Pages remain separate destinations. Explicit exact-View links and existing + exact-View pins are not changed by this behavior. +- Database-backed workspace navigation is bounded to 20 roots or children per page. + Local-file mode still builds its sidebar from an unbounded document inventory and is + the explicit residual before the full bounded-navigation promise can be proven. +- Additional dynamic sections remain outside this slice. diff --git a/templates/content/parity/matrix.md b/templates/content/parity/matrix.md index e69d13c6742..06be982cd69 100644 --- a/templates/content/parity/matrix.md +++ b/templates/content/parity/matrix.md @@ -2,35 +2,36 @@ This generated matrix tracks whether high-value Content UI operations use the same action surface agents can call, or have an explicit exception. Edit `matrix.ts`, then regenerate this file. -| ID | Surface | User-visible action | Status | Actions | UI entrypoints | Durable effect | Exception / gap | Reliability risk | Spine priority | Test coverage | Coverage refs | Eval scenarios | Follow-up | -| -------------------------------------------------- | ----------- | --------------------------------------------------------------------------------------------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------- | -------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | ------------------------------ | -| comments.threads | comments | List, add, reply, resolve, reopen, and delete comment threads | action-backed | `add-comment`, `delete-comment`, `list-comments`, `update-comment` | `app/components/editor/CommentsSidebar.tsx`, `app/hooks/use-comments.ts` | Comment threads, replies, anchors, mentions, resolution state, and deletion are stored through comment actions. | - | - | P0 | seeded | - | - | - | -| database.form-submissions | database | Submit public database forms as new rows | action-backed | `submit-content-database-form` | `app/components/editor/database/FormView.tsx` | A validated form submission atomically creates a database row document and its editable property values. | - | - | P0 | covered | `actions/submit-content-database-form.db.test.ts` | - | - | -| database.lifecycle-and-trash | database | Create, soft-delete, restore, list, and inspect content databases | action-backed | `create-content-database`, `create-inline-content-database`, `delete-content-database`, `describe-content-database`, `get-content-database`, `list-content-databases`, `list-trashed-content-databases`, `restore-content-database` | `app/components/editor/SlashCommandMenu.tsx`, `app/hooks/use-content-database.ts`, `app/hooks/use-documents.ts` | Database pages and database records are created, read, soft-deleted, restored, and listed. | - | - | P0 | covered | `actions/content-database-lifecycle.db.test.ts`, `actions/list-content-databases.db.test.ts`, `server/plugins/agent-chat.spec.ts`, `../../packages/core/src/server/agent-chat/content-a2a-capabilities.spec.ts` | `database-source-scope` | - | -| database.private-preview-drafts | database | Persist and reconcile a user's private database-page preview draft | action-backed | `get-preview-document-draft`, `update-preview-document-draft` | `app/components/editor/database/DatabaseView.tsx`, `app/hooks/use-documents.ts` | A user's private preview draft is read, saved, conflict-checked, or deleted without changing the shared database page until the normal save flow applies it. | These per-user editor-state actions are intentionally hidden from agent tools because preview drafts are a private UI recovery mechanism. | - | P1 | covered | `actions/preview-document-draft.db.test.ts` | - | - | -| database.properties-and-view-config | database | Configure properties, values, ordering, and saved views | action-backed | `configure-document-property`, `delete-document-property`, `duplicate-document-property`, `get-content-database-personal-view`, `list-document-properties`, `reorder-document-property`, `set-document-property`, `update-content-database-personal-view`, `update-content-database-view` | `app/components/editor/DocumentProperties.tsx`, `app/components/editor/DocumentDatabase.tsx`, `app/components/editor/database/DatabaseView.tsx`, `app/hooks/use-document-properties.ts` | Property schemas, property values, property order, filters, sorts, grouping, hidden columns, view type, and view settings are stored. | - | - | P0 | covered | `actions/bind-content-database-source-field.db.test.ts`, `actions/content-database-source-actions.test.ts`, `actions/resync-content-database-source.db.test.ts` | `database-source-scope` | - | -| database.rows | database | Add, duplicate, move, open, edit, and remove database rows | action-backed | `add-database-item`, `update-database-item`, `upsert-database-item-by-key`, `list-content-database-blocks`, `mutate-content-database-block`, `remove-database-items`, `duplicate-database-items`, `duplicate-database-item`, `update-database-items`, `migrate-content-database-rows`, `manage-content-database-migration`, `move-database-item`, `set-document-property` | `app/components/editor/DocumentDatabase.tsx`, `app/components/editor/database/DatabaseView.tsx` | Database row memberships and ordering are created, duplicated, moved, edited, and removed without deleting the backing page; bounded migrations atomically update row bodies and properties through the same canonical data model. | - | - | P0 | covered | `actions/database-row-batch-actions.db.test.ts`, `actions/upsert-database-item-by-key.db.test.ts`, `actions/migrate-content-database-rows.db.test.ts`, `actions/content-database-block-actions.db.test.ts`, `parity/__tests__/database-row-batch-reliability.test.ts` | `database-bulk-row-reliability` | - | -| database.table-query-page | database | Query one constrained page while retaining database metadata | action-backed | `query-content-database-items` | `app/components/editor/database/DatabaseView.tsx`, `app/hooks/use-content-database.ts` | - | This UI-only bounded projection is intentionally hidden with agentTool: false; agents use get-content-database for the complete database contract. | - | P0 | covered | `actions/content-database-lifecycle.db.test.ts`, `app/hooks/use-content-database.test.ts` | - | - | -| editor.agent-assist-prompts | editor | Ask AI from slash generation or comment context | client-assist | - | `app/components/editor/SlashCommandMenu.tsx`, `app/components/editor/CommentsSidebar.tsx` | No direct durable mutation; the prompt asks the agent to use document actions when it decides to write. | - | - | P1 | none | - | - | - | -| editor.blocks-field-word-count | editor | Inspect per-field word counts | action-backed | `get-blocks-field-word-count` | `app/components/editor/DocumentInfoPanel.tsx` | Authorized Blocks-field word counts read the current field without combining sibling fields. | - | - | P1 | covered | `actions/get-blocks-field-word-count.test.ts`, `app/components/editor/DocumentInfoPanel.test.ts` | - | - | -| editor.client-formatting-and-insertions | editor | Rich text formatting, selection state, slash block insertion, and copy actions | client-only-ephemeral | - | `app/components/editor/BubbleToolbar.tsx`, `app/components/editor/SlashCommandMenu.tsx`, `app/components/editor/DocumentToolbar.tsx` | - | - | - | P1 | none | - | - | - | -| editor.document-body-and-title | editor | Edit document title, body, icon, image alt text, and precise text | action-backed | `edit-document`, `pull-document`, `set-image-alt-text`, `transcribe-media`, `update-document` | `app/components/editor/DocumentEditor.tsx`, `app/components/editor/DocumentDatabase.tsx`, `app/components/editor/extensions/ImageBlock.tsx`, `app/components/editor/SlashCommandMenu.tsx` | Document content, title, icon, image metadata, and text replacements are saved to the same document source. | - | - | P0 | covered | `actions/content-database-lifecycle.db.test.ts`, `actions/_local-file-documents.test.ts` | `document-search-edit` | - | -| local-files.components-workspace | local-files | Register, list, and write local MDX component workspaces | host-only | `list-local-component-files`, `register-local-component-workspace`, `write-local-component-file` | `app/routes/_app.local-files.tsx`, `actions/register-local-component-workspace.ts`, `actions/list-local-component-files.ts`, `actions/write-local-component-file.ts` | Trusted local component workspace registration and component file reads/writes support local MDX previews. | Workspace registration depends on a trusted Desktop folder path and is intentionally hidden with agentTool: false. | - | P1 | seeded | - | - | Local folder exception/docs PR | -| local-files.host-folder-handles | local-files | Choose, persist, remove, and write trusted local folder handles | host-only | - | `app/routes/_app.local-files.tsx` | Host directory handles and browser/Desktop write permissions are managed outside SQL action state. | Mounted local folders require browser/Desktop host handles that agents cannot safely or portably hold as normal tools. | - | P0 | none | - | - | Local folder exception/docs PR | -| local-files.import-export-mounted-folder | local-files | Import, check, export, push, and remove local folder source files | action-backed | `connect-local-folder-source`, `disconnect-local-folder-source`, `export-content-source`, `import-content-source`, `remove-local-file-source`, `resolve-local-folder-conflict`, `sync-local-folder-source`, `sync-manifest-local-folder-source` | `app/routes/_app.local-files.tsx`, `actions/import-content-source.ts`, `actions/export-content-source.ts` | Local Markdown/MDX source files are imported into Content documents, editable Content documents are exported back to source-friendly files, and imported source entries can be removed without deleting files on disk. | - | - | P0 | covered | `actions/_local-file-documents.test.ts`, `actions/local-folder-source.db.test.ts` | `local-file-source-truth` | - | -| notion.route-backed-document-sync | source-sync | Notion document sync status, link, unlink, pull, push, resolve, create, search, and disconnect | action-backed | `connect-notion-status`, `create-and-link-notion-page`, `disconnect-notion`, `link-notion-page`, `list-notion-links`, `pull-notion-page`, `push-notion-page`, `refresh-notion-sync-status`, `resolve-notion-sync-conflict`, `search-notion-pages`, `sync-notion-comments`, `unlink-notion-page` | `app/hooks/use-notion.ts`, `app/components/editor/DocumentToolbar.tsx`, `app/components/editor/NotionSyncBar.tsx`, `app/components/editor/DocumentEditor.tsx` | Notion connection state, page search, link metadata, and local/remote document body sync state are read or mutated through Content actions. | Notion OAuth auth-url and callback routes remain route-shaped because they initiate and receive browser redirects rather than normal app data mutations. | - | P0 | covered | `parity/__tests__/matrix-route-gap-classify.test.ts` | - | - | -| sharing.document-discoverability-and-export | sharing | Share, hide from search, export, and reveal documents | action-backed | `export-document`, `reveal-local-source-file`, `set-document-discoverability`, `share-local-file-document` | `app/components/editor/DocumentToolbar.tsx`, `app/hooks/use-documents.ts` | Search discoverability, shareable copies, exports, and OS reveal requests are managed through Content actions. | - | - | P0 | covered | `actions/_local-file-documents.test.ts` | `local-file-source-truth` | - | -| sharing.os-reveal-local-source | sharing | Reveal a local source file in the system file manager | host-only | `reveal-local-source-file` | `app/components/editor/DocumentToolbar.tsx`, `actions/reveal-local-source-file.ts` | - | OS reveal depends on trusted local host capabilities and should not spend agent tool surface or imply portable hosted behavior. | - | P2 | seeded | - | - | Local folder exception/docs PR | -| sidebar.chrome-state | sidebar | Collapse sections and resize the sidebar | client-only-ephemeral | - | `app/components/sidebar/DocumentSidebar.tsx`, `app/components/layout/Layout.tsx` | - | - | - | P2 | none | - | - | - | -| sidebar.document-tree-crud | sidebar | Create, delete, move, favorite, list, search, and open pages | action-backed | `create-document`, `clone-creative-context-document`, `delete-document`, `get-document`, `list-trashed-documents`, `list-documents`, `move-document`, `permanently-delete-document`, `restore-document`, `search-documents`, `update-document` | `app/components/sidebar/DocumentSidebar.tsx`, `app/components/sidebar/DocumentTreeItem.tsx`, `app/components/editor/DocumentToolbar.tsx`, `app/hooks/use-documents.ts` | Document tree rows and document metadata are created, updated, deleted, moved, searched, or read. | - | - | P0 | covered | `actions/content-database-lifecycle.db.test.ts`, `actions/_local-file-documents.test.ts` | `document-search-edit` | - | -| sidebar.navigation-and-screen-context | sidebar | Navigate between documents and expose current screen context | action-equivalent | `navigate`, `view-screen` | `app/components/sidebar/DocumentSidebar.tsx`, `actions/navigate.ts`, `actions/view-screen.ts` | Application navigation state is updated or read so the agent can reason about the user's current page/view. | Human navigation is router-local, while agent navigation/screen inspection uses application-state actions to produce the same workspace orientation effect. | - | P1 | seeded | - | - | - | -| sidebar.personal-recent-visits | sidebar | Read personal Recent entries and record foreground visits | action-backed | `get-content-recent`, `record-content-visit` | `app/components/sidebar/PersonalSidebarSections.tsx`, `app/hooks/use-content-recent.ts` | Per-user Recent stores bounded Page and exact View identities with visit timestamps and resolves current labels under current access. | record-content-visit is hidden with agentTool: false so agent reads and edits cannot manufacture human visit history. | - | P1 | covered | `actions/content-recent.test.ts`, `shared/content-personal-navigation.test.ts` | - | - | -| source-sync.builder-body-hydration-worker | source-sync | Process queued Builder CMS body hydration work | action-backed | `process-builder-body-hydration` | `app/components/editor/DocumentEditor.tsx`, `app/components/editor/database/DatabaseView.tsx`, `app/hooks/use-content-database.ts` | Queued Builder body hydration entries are processed into readable Content document/database body state. | This action is intentionally hidden from the model with agentTool: false because it is an internal bounded queue worker; agents should use source refresh, review, and execution actions rather than manually driving hydration internals. | - | P0 | covered | `actions/_database-source-utils.test.ts` | - | - | -| source-sync.builder-cms-review-and-write-gates | source-sync | Review, stage, validate, cancel, and execute Builder CMS source writes | action-backed | `cancel-prepared-builder-source-update`, `execute-builder-source-batch`, `execute-builder-source-execution`, `prepare-builder-source-execution`, `prepare-builder-source-review`, `preview-builder-source-review`, `review-content-database-source-change-set`, `set-content-database-source-write-mode`, `stage-builder-source-bulk-update`, `stage-builder-revision`, `validate-builder-source-execution` | `app/components/editor/DocumentDatabase.tsx`, `app/components/editor/database/DatabaseView.tsx`, `app/components/editor/database-sources/BuilderSourceReviewDialog.tsx` | Builder source write mode, staged reviews, pre-dispatch cancellations, validation records, and bounded execution records are created through guarded actions. | - | - | P0 | covered | `actions/builder-source-review-gates.db.test.ts`, `actions/cancel-prepared-builder-source-update.db.test.ts`, `actions/execute-builder-source-execution.test.ts`, `actions/stage-builder-source-bulk-update.db.test.ts` | `builder-source-review-readonly` | - | -| source-sync.builder-documents | source-sync | List, pull, check, and push Builder docs/blog MDX documents | action-backed | `check-builder-doc`, `list-builder-docs`, `pull-builder-doc`, `push-builder-doc` | `actions/list-builder-docs.ts`, `actions/pull-builder-doc.ts`, `actions/check-builder-doc.ts`, `actions/push-builder-doc.ts` | Builder docs/blog entries can be read into Content, checked locally, and pushed through guarded Builder document actions. | - | - | P1 | seeded | - | - | - | -| source-sync.builder-required-field-materialization | source-sync | Add required Builder publishing fields to a connected collection | action-backed | `materialize-builder-required-fields` | `app/components/editor/database/DatabaseView.tsx`, `app/hooks/use-content-database.ts` | Required Builder fields are materialized as editable Content properties in one local mutation. | This bounded safe-model setup action is intentionally hidden from the agent tool list; the visible source settings surface invokes it. | - | P1 | covered | `actions/materialize-builder-required-fields.test.ts` | - | - | -| source-sync.database-source-bindings | source-sync | Attach, inspect, refresh, disconnect, join, and bind database sources | action-backed | `add-content-database-source-field-property`, `attach-content-database-source`, `bind-content-database-source-field`, `change-content-database-source-role`, `disconnect-content-database-source`, `get-content-database-source`, `list-builder-cms-models`, `list-notion-database-sources`, `preview-content-database-source-attach`, `refresh-content-database-source`, `suggest-source-join-key` | `app/components/editor/DocumentDatabase.tsx`, `app/components/editor/DocumentProperties.tsx`, `app/components/editor/database/DatabaseView.tsx`, `app/hooks/use-content-database.ts` | Mounted database source metadata, fields, source role, join keys, and source-field/property bindings are stored and refreshed. | - | - | P0 | covered | `actions/bind-content-database-source-field.db.test.ts`, `actions/content-database-source-actions.test.ts`, `actions/resync-content-database-source.db.test.ts` | `database-source-scope` | - | -| source-sync.provider-api-and-staged-datasets | source-sync | Inspect provider APIs and stage/query/delete large provider datasets | action-backed | `delete-staged-dataset`, `list-staged-datasets`, `provider-api-catalog`, `provider-api-docs`, `provider-api-request`, `query-staged-dataset` | `actions/provider-api-catalog.ts`, `actions/provider-api-docs.ts`, `actions/provider-api-request.ts`, `actions/query-staged-dataset.ts` | Provider API metadata and staged dataset scratch storage support scoped agent/source analysis. | - | - | P1 | seeded | - | - | - | -| versions.history-and-restore | versions | Open version history and restore a previous document version | action-backed | `list-document-versions`, `list-document-history`, `list-document-history-checkpoints`, `get-document-history-checkpoint`, `restore-document-version` | `app/components/editor/VersionHistoryPanel.tsx`, `app/hooks/use-document-versions.ts` | Document versions are listed and selected versions can restore the document while snapshotting current state. | - | - | P0 | seeded | - | - | - | -| workspace.root-landing-resolver | workspace | Resolve the app root to the caller's last authorized page or a private welcome page | action-backed | `resolve-content-landing` | `app/routes/_app.home.tsx`, `app/lib/content-landing.ts` | The root route restores the most recent authorized page when possible and otherwise converges on one private personal welcome page while preserving last-location state. | - | - | P0 | covered | `actions/resolve-content-landing.db.test.ts`, `app/lib/content-landing.test.ts` | - | - | -| workspace.spaces-and-files-catalog | workspace | Provision, navigate, and delete Content spaces through Files and Workspaces with personal expansion state | action-backed | `backfill-content-files`, `create-content-space`, `delete-content-space`, `ensure-content-spaces`, `get-content-sidebar-state`, `list-content-spaces`, `update-content-sidebar-state` | `app/components/sidebar/DocumentSidebar.tsx`, `app/hooks/use-content-spaces.ts` | Personal and organization spaces, user-created workspaces, their canonical Files databases, the personal Workspaces catalog, and each user's sidebar expansion state are stored and reconciled in SQL; deleting a user-created workspace atomically removes its catalog row and contents. | - | - | P0 | covered | `actions/content-spaces.db.test.ts`, `actions/content-files.db.test.ts`, `actions/content-sidebar-state.test.ts` | - | - | +| ID | Surface | User-visible action | Status | Actions | UI entrypoints | Durable effect | Exception / gap | Reliability risk | Spine priority | Test coverage | Coverage refs | Eval scenarios | Follow-up | +| -------------------------------------------------- | ----------- | --------------------------------------------------------------------------------------------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------- | -------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | ---------------------------------- | +| comments.threads | comments | List, add, reply, resolve, reopen, and delete comment threads | action-backed | `add-comment`, `delete-comment`, `list-comments`, `update-comment` | `app/components/editor/CommentsSidebar.tsx`, `app/hooks/use-comments.ts` | Comment threads, replies, anchors, mentions, resolution state, and deletion are stored through comment actions. | - | - | P0 | seeded | - | - | - | +| database.form-submissions | database | Submit public database forms as new rows | action-backed | `submit-content-database-form` | `app/components/editor/database/FormView.tsx` | A validated form submission atomically creates a database row document and its editable property values. | - | - | P0 | covered | `actions/submit-content-database-form.db.test.ts` | - | - | +| database.lifecycle-and-trash | database | Create, soft-delete, restore, list, and inspect content databases | action-backed | `create-content-database`, `create-inline-content-database`, `delete-content-database`, `describe-content-database`, `get-content-database`, `list-content-databases`, `list-trashed-content-databases`, `restore-content-database` | `app/components/editor/SlashCommandMenu.tsx`, `app/hooks/use-content-database.ts`, `app/hooks/use-documents.ts` | Database pages and database records are created, read, soft-deleted, restored, and listed. | - | - | P0 | covered | `actions/content-database-lifecycle.db.test.ts`, `actions/list-content-databases.db.test.ts`, `server/plugins/agent-chat.spec.ts`, `../../packages/core/src/server/agent-chat/content-a2a-capabilities.spec.ts` | `database-source-scope` | - | +| database.private-preview-drafts | database | Persist and reconcile a user's private database-page preview draft | action-backed | `get-preview-document-draft`, `update-preview-document-draft` | `app/components/editor/database/DatabaseView.tsx`, `app/hooks/use-documents.ts` | A user's private preview draft is read, saved, conflict-checked, or deleted without changing the shared database page until the normal save flow applies it. | These per-user editor-state actions are intentionally hidden from agent tools because preview drafts are a private UI recovery mechanism. | - | P1 | covered | `actions/preview-document-draft.db.test.ts` | - | - | +| database.properties-and-view-config | database | Configure properties, values, ordering, and saved views | action-backed | `configure-document-property`, `delete-document-property`, `duplicate-document-property`, `get-content-database-personal-view`, `list-document-properties`, `reorder-document-property`, `set-document-property`, `update-content-database-personal-view`, `update-content-database-view` | `app/components/editor/DocumentProperties.tsx`, `app/components/editor/DocumentDatabase.tsx`, `app/components/editor/database/DatabaseView.tsx`, `app/hooks/use-document-properties.ts` | Property schemas, property values, property order, filters, sorts, grouping, hidden columns, view type, and view settings are stored. | - | - | P0 | covered | `actions/bind-content-database-source-field.db.test.ts`, `actions/content-database-source-actions.test.ts`, `actions/resync-content-database-source.db.test.ts` | `database-source-scope` | - | +| database.rows | database | Add, duplicate, move, open, edit, and remove database rows | action-backed | `add-database-item`, `update-database-item`, `upsert-database-item-by-key`, `list-content-database-blocks`, `mutate-content-database-block`, `remove-database-items`, `duplicate-database-items`, `duplicate-database-item`, `update-database-items`, `migrate-content-database-rows`, `manage-content-database-migration`, `move-database-item`, `set-document-property` | `app/components/editor/DocumentDatabase.tsx`, `app/components/editor/database/DatabaseView.tsx` | Database row memberships and ordering are created, duplicated, moved, edited, and removed without deleting the backing page; bounded migrations atomically update row bodies and properties through the same canonical data model. | - | - | P0 | covered | `actions/database-row-batch-actions.db.test.ts`, `actions/upsert-database-item-by-key.db.test.ts`, `actions/migrate-content-database-rows.db.test.ts`, `actions/content-database-block-actions.db.test.ts`, `parity/__tests__/database-row-batch-reliability.test.ts` | `database-bulk-row-reliability` | - | +| database.table-query-page | database | Query one constrained table page while retaining database metadata | action-backed | `query-content-database-items` | `app/components/editor/database/DatabaseView.tsx`, `app/hooks/use-content-database.ts` | - | This UI-only bounded projection is intentionally hidden with agentTool: false; agents use get-content-database for database reads and list-documents, get-document, search-documents, and navigate for workspace navigation. | - | P0 | covered | `actions/content-database-lifecycle.db.test.ts`, `app/hooks/use-content-database.test.ts` | - | - | +| editor.agent-assist-prompts | editor | Ask AI from slash generation or comment context | client-assist | - | `app/components/editor/SlashCommandMenu.tsx`, `app/components/editor/CommentsSidebar.tsx` | No direct durable mutation; the prompt asks the agent to use document actions when it decides to write. | - | - | P1 | none | - | - | - | +| editor.blocks-field-word-count | editor | Inspect per-field word counts | action-backed | `get-blocks-field-word-count` | `app/components/editor/DocumentInfoPanel.tsx` | Authorized Blocks-field word counts read the current field without combining sibling fields. | - | - | P1 | covered | `actions/get-blocks-field-word-count.test.ts`, `app/components/editor/DocumentInfoPanel.test.ts` | - | - | +| editor.client-formatting-and-insertions | editor | Rich text formatting, selection state, slash block insertion, and copy actions | client-only-ephemeral | - | `app/components/editor/BubbleToolbar.tsx`, `app/components/editor/SlashCommandMenu.tsx`, `app/components/editor/DocumentToolbar.tsx` | - | - | - | P1 | none | - | - | - | +| editor.document-body-and-title | editor | Edit document title, body, icon, image alt text, and precise text | action-backed | `edit-document`, `pull-document`, `set-image-alt-text`, `transcribe-media`, `update-document` | `app/components/editor/DocumentEditor.tsx`, `app/components/editor/DocumentDatabase.tsx`, `app/components/editor/extensions/ImageBlock.tsx`, `app/components/editor/SlashCommandMenu.tsx` | Document content, title, icon, image metadata, and text replacements are saved to the same document source. | - | - | P0 | covered | `actions/content-database-lifecycle.db.test.ts`, `actions/_local-file-documents.test.ts` | `document-search-edit` | - | +| local-files.components-workspace | local-files | Register, list, and write local MDX component workspaces | host-only | `list-local-component-files`, `register-local-component-workspace`, `write-local-component-file` | `app/routes/_app.local-files.tsx`, `actions/register-local-component-workspace.ts`, `actions/list-local-component-files.ts`, `actions/write-local-component-file.ts` | Trusted local component workspace registration and component file reads/writes support local MDX previews. | Workspace registration depends on a trusted Desktop folder path and is intentionally hidden with agentTool: false. | - | P1 | seeded | - | - | Local folder exception/docs PR | +| local-files.host-folder-handles | local-files | Choose, persist, remove, and write trusted local folder handles | host-only | - | `app/routes/_app.local-files.tsx` | Host directory handles and browser/Desktop write permissions are managed outside SQL action state. | Mounted local folders require browser/Desktop host handles that agents cannot safely or portably hold as normal tools. | - | P0 | none | - | - | Local folder exception/docs PR | +| local-files.import-export-mounted-folder | local-files | Import, check, export, push, and remove local folder source files | action-backed | `connect-local-folder-source`, `disconnect-local-folder-source`, `export-content-source`, `import-content-source`, `remove-local-file-source`, `resolve-local-folder-conflict`, `sync-local-folder-source`, `sync-manifest-local-folder-source` | `app/routes/_app.local-files.tsx`, `actions/import-content-source.ts`, `actions/export-content-source.ts` | Local Markdown/MDX source files are imported into Content documents, editable Content documents are exported back to source-friendly files, and imported source entries can be removed without deleting files on disk. | - | - | P0 | covered | `actions/_local-file-documents.test.ts`, `actions/local-folder-source.db.test.ts` | `local-file-source-truth` | - | +| notion.route-backed-document-sync | source-sync | Notion document sync status, link, unlink, pull, push, resolve, create, search, and disconnect | action-backed | `connect-notion-status`, `create-and-link-notion-page`, `disconnect-notion`, `link-notion-page`, `list-notion-links`, `pull-notion-page`, `push-notion-page`, `refresh-notion-sync-status`, `resolve-notion-sync-conflict`, `search-notion-pages`, `sync-notion-comments`, `unlink-notion-page` | `app/hooks/use-notion.ts`, `app/components/editor/DocumentToolbar.tsx`, `app/components/editor/NotionSyncBar.tsx`, `app/components/editor/DocumentEditor.tsx` | Notion connection state, page search, link metadata, and local/remote document body sync state are read or mutated through Content actions. | Notion OAuth auth-url and callback routes remain route-shaped because they initiate and receive browser redirects rather than normal app data mutations. | - | P0 | covered | `parity/__tests__/matrix-route-gap-classify.test.ts` | - | - | +| sharing.document-discoverability-and-export | sharing | Share, hide from search, export, and reveal documents | action-backed | `export-document`, `reveal-local-source-file`, `set-document-discoverability`, `share-local-file-document` | `app/components/editor/DocumentToolbar.tsx`, `app/hooks/use-documents.ts` | Search discoverability, shareable copies, exports, and OS reveal requests are managed through Content actions. | - | - | P0 | covered | `actions/_local-file-documents.test.ts` | `local-file-source-truth` | - | +| sharing.os-reveal-local-source | sharing | Reveal a local source file in the system file manager | host-only | `reveal-local-source-file` | `app/components/editor/DocumentToolbar.tsx`, `actions/reveal-local-source-file.ts` | - | OS reveal depends on trusted local host capabilities and should not spend agent tool surface or imply portable hosted behavior. | - | P2 | seeded | - | - | Local folder exception/docs PR | +| sidebar.bounded-workspace-navigation | sidebar | Page through database-backed workspace roots and children and reveal the active path | action-backed | `get-content-navigation-context`, `query-content-database-items` | `app/components/sidebar/DocumentSidebar.tsx`, `app/components/editor/database/sidebar.tsx`, `app/hooks/use-content-database.ts` | - | Both reads are hidden with agentTool: false because they are lean UI projections, not agent capability limits; agents use list-documents, get-document, search-documents, and navigate. | - | P0 | covered | `actions/content-navigation-bounds.test.ts`, `actions/query-content-database-items.navigation.db.test.ts`, `app/hooks/use-content-database.test.ts` | - | Bound local-file sidebar inventory | +| sidebar.chrome-state | sidebar | Collapse sections and resize the sidebar | client-only-ephemeral | - | `app/components/sidebar/DocumentSidebar.tsx`, `app/components/layout/Layout.tsx` | - | - | - | P2 | none | - | - | - | +| sidebar.document-tree-crud | sidebar | Create, delete, move, favorite, list, search, and open pages | action-backed | `create-document`, `clone-creative-context-document`, `delete-document`, `get-document`, `list-trashed-documents`, `list-documents`, `move-document`, `permanently-delete-document`, `restore-document`, `search-documents`, `update-document` | `app/components/sidebar/DocumentSidebar.tsx`, `app/components/sidebar/DocumentTreeItem.tsx`, `app/components/editor/DocumentToolbar.tsx`, `app/hooks/use-documents.ts` | Document tree rows and document metadata are created, updated, deleted, moved, searched, or read. | - | - | P0 | covered | `actions/content-database-lifecycle.db.test.ts`, `actions/_local-file-documents.test.ts` | `document-search-edit` | - | +| sidebar.navigation-and-screen-context | sidebar | Navigate between documents and expose current screen context | action-equivalent | `navigate`, `view-screen` | `app/components/sidebar/DocumentSidebar.tsx`, `actions/navigate.ts`, `actions/view-screen.ts` | Application navigation state is updated or read so the agent can reason about the user's current page/view. | Human navigation is router-local, while agent navigation/screen inspection uses application-state actions to produce the same workspace orientation effect. | - | P1 | seeded | - | - | - | +| sidebar.personal-recent-visits | sidebar | Read personal Recent entries and record foreground visits | action-backed | `get-content-recent`, `record-content-visit` | `app/components/sidebar/PersonalSidebarSections.tsx`, `app/hooks/use-content-recent.ts` | Per-user Recent stores bounded Page destinations and one destination per Database with its latest visited View, then resolves current labels under current access. | record-content-visit is hidden with agentTool: false so agent reads and edits cannot manufacture human visit history. | - | P1 | covered | `actions/content-recent.test.ts`, `shared/content-personal-navigation.test.ts` | - | - | +| source-sync.builder-body-hydration-worker | source-sync | Process queued Builder CMS body hydration work | action-backed | `process-builder-body-hydration` | `app/components/editor/DocumentEditor.tsx`, `app/components/editor/database/DatabaseView.tsx`, `app/hooks/use-content-database.ts` | Queued Builder body hydration entries are processed into readable Content document/database body state. | This action is intentionally hidden from the model with agentTool: false because it is an internal bounded queue worker; agents should use source refresh, review, and execution actions rather than manually driving hydration internals. | - | P0 | covered | `actions/_database-source-utils.test.ts` | - | - | +| source-sync.builder-cms-review-and-write-gates | source-sync | Review, stage, validate, cancel, and execute Builder CMS source writes | action-backed | `cancel-prepared-builder-source-update`, `execute-builder-source-batch`, `execute-builder-source-execution`, `prepare-builder-source-execution`, `prepare-builder-source-review`, `preview-builder-source-review`, `review-content-database-source-change-set`, `set-content-database-source-write-mode`, `stage-builder-source-bulk-update`, `stage-builder-revision`, `validate-builder-source-execution` | `app/components/editor/DocumentDatabase.tsx`, `app/components/editor/database/DatabaseView.tsx`, `app/components/editor/database-sources/BuilderSourceReviewDialog.tsx` | Builder source write mode, staged reviews, pre-dispatch cancellations, validation records, and bounded execution records are created through guarded actions. | - | - | P0 | covered | `actions/builder-source-review-gates.db.test.ts`, `actions/cancel-prepared-builder-source-update.db.test.ts`, `actions/execute-builder-source-execution.test.ts`, `actions/stage-builder-source-bulk-update.db.test.ts` | `builder-source-review-readonly` | - | +| source-sync.builder-documents | source-sync | List, pull, check, and push Builder docs/blog MDX documents | action-backed | `check-builder-doc`, `list-builder-docs`, `pull-builder-doc`, `push-builder-doc` | `actions/list-builder-docs.ts`, `actions/pull-builder-doc.ts`, `actions/check-builder-doc.ts`, `actions/push-builder-doc.ts` | Builder docs/blog entries can be read into Content, checked locally, and pushed through guarded Builder document actions. | - | - | P1 | seeded | - | - | - | +| source-sync.builder-required-field-materialization | source-sync | Add required Builder publishing fields to a connected collection | action-backed | `materialize-builder-required-fields` | `app/components/editor/database/DatabaseView.tsx`, `app/hooks/use-content-database.ts` | Required Builder fields are materialized as editable Content properties in one local mutation. | This bounded safe-model setup action is intentionally hidden from the agent tool list; the visible source settings surface invokes it. | - | P1 | covered | `actions/materialize-builder-required-fields.test.ts` | - | - | +| source-sync.database-source-bindings | source-sync | Attach, inspect, refresh, disconnect, join, and bind database sources | action-backed | `add-content-database-source-field-property`, `attach-content-database-source`, `bind-content-database-source-field`, `change-content-database-source-role`, `disconnect-content-database-source`, `get-content-database-source`, `list-builder-cms-models`, `list-notion-database-sources`, `preview-content-database-source-attach`, `refresh-content-database-source`, `suggest-source-join-key` | `app/components/editor/DocumentDatabase.tsx`, `app/components/editor/DocumentProperties.tsx`, `app/components/editor/database/DatabaseView.tsx`, `app/hooks/use-content-database.ts` | Mounted database source metadata, fields, source role, join keys, and source-field/property bindings are stored and refreshed. | - | - | P0 | covered | `actions/bind-content-database-source-field.db.test.ts`, `actions/content-database-source-actions.test.ts`, `actions/resync-content-database-source.db.test.ts` | `database-source-scope` | - | +| source-sync.provider-api-and-staged-datasets | source-sync | Inspect provider APIs and stage/query/delete large provider datasets | action-backed | `delete-staged-dataset`, `list-staged-datasets`, `provider-api-catalog`, `provider-api-docs`, `provider-api-request`, `query-staged-dataset` | `actions/provider-api-catalog.ts`, `actions/provider-api-docs.ts`, `actions/provider-api-request.ts`, `actions/query-staged-dataset.ts` | Provider API metadata and staged dataset scratch storage support scoped agent/source analysis. | - | - | P1 | seeded | - | - | - | +| versions.history-and-restore | versions | Open version history and restore a previous document version | action-backed | `list-document-versions`, `list-document-history`, `list-document-history-checkpoints`, `get-document-history-checkpoint`, `restore-document-version` | `app/components/editor/VersionHistoryPanel.tsx`, `app/hooks/use-document-versions.ts` | Document versions are listed and selected versions can restore the document while snapshotting current state. | - | - | P0 | seeded | - | - | - | +| workspace.root-landing-resolver | workspace | Resolve the app root to the caller's last authorized page or a private welcome page | action-backed | `resolve-content-landing` | `app/routes/_app.home.tsx`, `app/lib/content-landing.ts` | The root route restores the most recent authorized page when possible and otherwise converges on one private personal welcome page while preserving last-location state. | - | - | P0 | covered | `actions/resolve-content-landing.db.test.ts`, `app/lib/content-landing.test.ts` | - | - | +| workspace.spaces-and-files-catalog | workspace | Provision, navigate, and delete Content spaces through Files and Workspaces with personal expansion state | action-backed | `backfill-content-files`, `create-content-space`, `delete-content-space`, `ensure-content-spaces`, `get-content-sidebar-state`, `list-content-spaces`, `update-content-sidebar-state` | `app/components/sidebar/DocumentSidebar.tsx`, `app/hooks/use-content-spaces.ts` | Personal and organization spaces, user-created workspaces, their canonical Files databases, the personal Workspaces catalog, and each user's sidebar expansion state are stored and reconciled in SQL; deleting a user-created workspace atomically removes its catalog row and contents. | - | - | P0 | covered | `actions/content-spaces.db.test.ts`, `actions/content-files.db.test.ts`, `actions/content-sidebar-state.test.ts` | - | - | diff --git a/templates/content/parity/matrix.ts b/templates/content/parity/matrix.ts index 0e11aacc9f7..113127f73bf 100644 --- a/templates/content/parity/matrix.ts +++ b/templates/content/parity/matrix.ts @@ -10,7 +10,7 @@ export const parityMatrix: ParityRow[] = [ "app/hooks/use-content-recent.ts", ], durableEffect: - "Per-user Recent stores bounded Page and exact View identities with visit timestamps and resolves current labels under current access.", + "Per-user Recent stores bounded Page destinations and one destination per Database with its latest visited View, then resolves current labels under current access.", uiImplementation: "Recent reads use the shared Action; successful foreground navigation records a visit through the UI-only Action.", status: "action-backed", @@ -26,6 +26,33 @@ export const parityMatrix: ParityRow[] = [ "shared/content-personal-navigation.test.ts", ], }, + { + id: "sidebar.bounded-workspace-navigation", + surface: "sidebar", + label: + "Page through database-backed workspace roots and children and reveal the active path", + uiEntrypoints: [ + "app/components/sidebar/DocumentSidebar.tsx", + "app/components/editor/database/sidebar.tsx", + "app/hooks/use-content-database.ts", + ], + durableEffect: null, + uiImplementation: + "Database-backed Files navigation requests at most 20 immediate children per page and uses a bounded active-item context read; local-file mode still uses the document inventory.", + status: "action-backed", + actions: ["get-content-navigation-context", "query-content-database-items"], + exception: + "Both reads are hidden with agentTool: false because they are lean UI projections, not agent capability limits; agents use list-documents, get-document, search-documents, and navigate.", + reliabilityRisk: "none", + spinePriority: "P0", + testCoverage: "covered", + followUpPR: "Bound local-file sidebar inventory", + coverageRefs: [ + "actions/content-navigation-bounds.test.ts", + "actions/query-content-database-items.navigation.db.test.ts", + "app/hooks/use-content-database.test.ts", + ], + }, { id: "sidebar.document-tree-crud", surface: "sidebar", @@ -344,18 +371,18 @@ export const parityMatrix: ParityRow[] = [ { id: "database.table-query-page", surface: "database", - label: "Query one constrained page while retaining database metadata", + label: "Query one constrained table page while retaining database metadata", uiEntrypoints: [ "app/components/editor/database/DatabaseView.tsx", "app/hooks/use-content-database.ts", ], durableEffect: null, uiImplementation: - "The table view loads changed search, filter, and sort results through a page-only action while the base database response remains visible.", + "The table view loads changed search, filter, and sort results through a page-only action while the base database response remains visible; the sidebar uses the same action's separate bounded navigation projection.", status: "action-backed", actions: ["query-content-database-items"], exception: - "This UI-only bounded projection is intentionally hidden with agentTool: false; agents use get-content-database for the complete database contract.", + "This UI-only bounded projection is intentionally hidden with agentTool: false; agents use get-content-database for database reads and list-documents, get-document, search-documents, and navigate for workspace navigation.", reliabilityRisk: "none", spinePriority: "P0", testCoverage: "covered", diff --git a/templates/content/server/plugins/db.ts b/templates/content/server/plugins/db.ts index 1911f94c50e..7fa6b26ac97 100644 --- a/templates/content/server/plugins/db.ts +++ b/templates/content/server/plugins/db.ts @@ -1119,6 +1119,14 @@ export const runContentMigrations = runMigrations( name: "content-databases-document-idx", sql: `CREATE INDEX IF NOT EXISTS content_databases_document_idx ON content_databases (document_id)`, }, + { + version: 91, + name: "content-files-navigation-indexes", + sql: `CREATE INDEX IF NOT EXISTS documents_parent_title_id_idx ON documents (parent_id, title, id); + CREATE INDEX IF NOT EXISTS documents_parent_created_id_idx ON documents (parent_id, created_at, id); + CREATE INDEX IF NOT EXISTS documents_parent_updated_id_idx ON documents (parent_id, updated_at, id); + CREATE INDEX IF NOT EXISTS content_database_items_database_position_id_idx ON content_database_items (database_id, position, id)`, + }, ], { table: "content_migrations" }, ); diff --git a/templates/content/shared/api.ts b/templates/content/shared/api.ts index 08fa0911065..ffd4ff518ec 100644 --- a/templates/content/shared/api.ts +++ b/templates/content/shared/api.ts @@ -144,6 +144,32 @@ export interface DocumentListResponse { pagination: DocumentDiscoveryPagination; } +export interface ContentNavigationPathEntry { + id: string; + parentId: string | null; + title: string; + icon: string | null; + databaseId: string | null; + databaseDocumentId: string | null; + isFavorite: boolean; + visibility?: "private" | "org" | "public"; + accessRole?: DocumentAccessRole; + canView?: boolean; + canComment?: boolean; + canEdit?: boolean; + canManage?: boolean; + source?: DocumentSourceInfo; + createdAt: string; + updatedAt: string; +} + +export interface ContentNavigationContext { + mode: "database" | "local-files"; + document: Document; + path: ContentNavigationPathEntry[]; + workspaceFilesDatabaseId: string | null; +} + export interface DocumentDiscoveryPagination { offset: number; limit: number; @@ -492,6 +518,7 @@ export interface ContentDatabaseItem { document: Document; position: number; properties: DocumentProperty[]; + workspaceFilesDatabaseId?: string | null; bodyHydration?: ContentDatabaseBodyHydration; sourceRecord?: ContentDatabaseSourceRow; // Federation (NEXT): the row's normalized join key, and the read-only columns @@ -953,6 +980,39 @@ export type ContentDatabaseItemsPageResponse = Pick< "items" | "source" | "sources" | "pagination" | "tableQueryMode" >; +export type ContentDatabaseNavigationSort = + | "custom" + | "name" + | "created" + | "last_edited"; + +export interface ContentDatabaseNavigationItem { + membershipId: string; + membershipPosition: number; + documentId: string; + parentId: string | null; + title: string; + icon: string | null; + type: "page" | "database"; + hasChildren: boolean; + spaceId: string | null; + sourceKind: string | null; + isFavorite: boolean; + canEdit: boolean; + canManage: boolean; + createdAt: string; + updatedAt: string; +} + +export interface ContentDatabaseNavigationPageResponse { + items: ContentDatabaseNavigationItem[]; + pagination: { + limit: number; + hasMore: boolean; + nextCursor: string | null; + }; +} + export interface BuilderActionTiming { name: string; durationMs: number; diff --git a/templates/content/shared/content-personal-navigation-patch.test.ts b/templates/content/shared/content-personal-navigation-patch.test.ts index 677d7e8a2b5..e8273db26bf 100644 --- a/templates/content/shared/content-personal-navigation-patch.test.ts +++ b/templates/content/shared/content-personal-navigation-patch.test.ts @@ -85,4 +85,51 @@ describe("personal navigation patch", () => { it("rejects empty patches instead of reporting a no-op save", () => { expect(() => contentPersonalNavigationPatchSchema.parse({})).toThrow(); }); + it("merges relative pin changes without replacing unloaded order or alternate sort", () => { + const current = { + version: CONTENT_DATABASE_PERSONAL_VIEW_OVERRIDES_VERSION, + activeViewId: "table", + views: [ + { + id: "table", + sorts: [], + filters: [], + filterMode: "and" as const, + sidebarOrder: { + mode: "name" as const, + itemIds: ["a", "b", "c", "d", "e", "f"], + }, + }, + ], + }; + const pinned = applyContentPersonalNavigationPatch(current, { + sidebarOrder: { + operation: "prepend", + viewId: "table", + itemId: "new-membership", + }, + }); + expect(pinned.views[0].sidebarOrder).toEqual({ + mode: "name", + itemIds: ["new-membership", "a", "b", "c", "d", "e", "f"], + }); + expect( + applyContentPersonalNavigationPatch(pinned, { + sidebarOrder: { + operation: "prepend", + viewId: "table", + itemId: "new-membership", + }, + }), + ).toEqual(pinned); + expect( + applyContentPersonalNavigationPatch(pinned, { + sidebarOrder: { + operation: "remove", + viewId: "table", + itemId: "new-membership", + }, + }).views[0].sidebarOrder?.itemIds, + ).toEqual(["a", "b", "c", "d", "e", "f"]); + }); }); diff --git a/templates/content/shared/content-personal-navigation-patch.ts b/templates/content/shared/content-personal-navigation-patch.ts index 46e18cb3c02..0e916e4e039 100644 --- a/templates/content/shared/content-personal-navigation-patch.ts +++ b/templates/content/shared/content-personal-navigation-patch.ts @@ -11,18 +11,26 @@ export const contentPersonalNavigationPatchSchema = z .object({ activeViewId: idSchema.optional(), sidebarOrder: z - .object({ - viewId: idSchema, - mode: z.enum(["custom", "last_edited", "name", "created"]), - itemIds: z.array(idSchema).max(5_000), - }) + .discriminatedUnion("operation", [ + z.object({ + operation: z.literal("replace").optional().default("replace"), + viewId: idSchema, + mode: z.enum(["custom", "last_edited", "name", "created"]), + itemIds: z.array(idSchema).max(5_000), + }), + z.object({ + operation: z.enum(["prepend", "remove"]), + viewId: idSchema, + itemId: idSchema, + }), + ]) .optional(), }) .refine( (patch) => patch.activeViewId !== undefined || patch.sidebarOrder !== undefined, ); -export type ContentPersonalNavigationPatch = z.infer< +export type ContentPersonalNavigationPatch = z.input< typeof contentPersonalNavigationPatchSchema >; @@ -39,20 +47,41 @@ export function applyContentPersonalNavigationPatch( }; if (!patch.sidebarOrder) return { ...next, activeViewId: patch.activeViewId ?? next.activeViewId }; - const { viewId, ...sidebarOrder } = patch.sidebarOrder; + const sidebarPatch = patch.sidebarOrder; + const { viewId } = sidebarPatch; const previous = next.views.find((view) => view.id === viewId); const query = previous ?? sharedViews.find((view) => view.id === viewId); if (!query) throw new Error("Shared View query is unavailable."); + const previousOrder = previous?.sidebarOrder ?? { + mode: "custom" as const, + itemIds: [], + }; + const sidebarOrder = + "itemId" in sidebarPatch && sidebarPatch.operation === "prepend" + ? { + ...previousOrder, + itemIds: previousOrder.itemIds.includes(sidebarPatch.itemId) + ? previousOrder.itemIds + : [sidebarPatch.itemId, ...previousOrder.itemIds], + } + : "itemId" in sidebarPatch + ? { + ...previousOrder, + itemIds: previousOrder.itemIds.filter( + (id) => id !== sidebarPatch.itemId, + ), + } + : { + mode: sidebarPatch.mode, + itemIds: [...new Set(sidebarPatch.itemIds)], + }; const view = { id: viewId, sorts: query.sorts, filters: query.filters, filterMode: query.filterMode ?? "and", ...previous, - sidebarOrder: { - ...sidebarOrder, - itemIds: [...new Set(sidebarOrder.itemIds)], - }, + sidebarOrder, }; return { ...next, diff --git a/templates/content/shared/content-personal-navigation.test.ts b/templates/content/shared/content-personal-navigation.test.ts index 80f6a6ab662..e0e470c6c6f 100644 --- a/templates/content/shared/content-personal-navigation.test.ts +++ b/templates/content/shared/content-personal-navigation.test.ts @@ -4,6 +4,7 @@ import { contentRecentHref, readContentRecentState, recordContentRecentVisit, + contentRecentVisitKey, } from "./content-personal-navigation"; describe("personal Recent navigation", () => { @@ -18,7 +19,7 @@ describe("personal Recent navigation", () => { visitedAt: "2026-09-09T11:00:00.000Z", }); state = recordContentRecentVisit(state, { - target, + target: { ...target, viewId: "table" }, visitedAt: "2026-09-09T10:00:00.000Z", }); expect(state.entries).toEqual([ @@ -30,7 +31,7 @@ describe("personal Recent navigation", () => { ]); }); - it("retains separate Views and promotes only the explicitly visited identity", () => { + it("keeps one database entry while restoring its latest visited View", () => { const table = { documentId: "page", databaseId: "db", viewId: "table" }; const board = { ...table, viewId: "board" }; let state = readContentRecentState(null); @@ -48,13 +49,46 @@ describe("personal Recent navigation", () => { }); expect(state.entries.map((entry) => entry.target.viewId)).toEqual([ "table", - "board", ]); + expect(contentRecentVisitKey(table)).not.toBe(contentRecentVisitKey(board)); expect(contentRecentHref(table)).toBe( "/page/page?databaseId=db&viewId=table", ); }); + it("migrates v1 View duplicates deterministically and keeps the newest visit", () => { + const state = readContentRecentState({ + version: 1, + entries: [ + { + target: { documentId: "page", databaseId: "db", viewId: "board" }, + visitedAt: "2026-09-09T12:00:00.000Z", + }, + { + target: { documentId: "page", databaseId: "db", viewId: "table" }, + visitedAt: "2026-09-09T12:00:00.000Z", + }, + { + target: { documentId: "other" }, + visitedAt: "2026-09-09T11:00:00.000Z", + }, + ], + }); + expect(state).toEqual({ + version: 2, + entries: [ + { + target: { documentId: "page", databaseId: "db", viewId: "board" }, + visitedAt: "2026-09-09T12:00:00.000Z", + }, + { + target: { documentId: "other" }, + visitedAt: "2026-09-09T11:00:00.000Z", + }, + ], + }); + }); + it("bounds navigation history without storing target metadata", () => { let state = readContentRecentState(null); for (let i = 0; i < 60; i++) diff --git a/templates/content/shared/content-personal-navigation.ts b/templates/content/shared/content-personal-navigation.ts index 4bb37986254..b18d4f6df88 100644 --- a/templates/content/shared/content-personal-navigation.ts +++ b/templates/content/shared/content-personal-navigation.ts @@ -50,8 +50,12 @@ export const contentRecentEntrySchema = z.object({ visitedAt: z.string().datetime(), }); export type ContentRecentEntry = z.infer; -export const contentRecentStateSchema = z.object({ +const contentRecentStateV1Schema = z.object({ version: z.literal(1), + entries: z.array(contentRecentEntrySchema), +}); +export const contentRecentStateSchema = z.object({ + version: z.literal(2), entries: z.array(contentRecentEntrySchema).max(CONTENT_RECENT_LIMIT), }); export type ContentRecentState = z.infer; @@ -59,9 +63,16 @@ export type ContentRecentResult = ContentRecentEntry & { title: string; icon: string | null; viewName: string | null; + fallback?: { reason: "saved_view_unavailable"; requestedViewId: string }; }; export function contentRecentTargetKey(target: ContentRecentTarget) { + return target.databaseId + ? JSON.stringify(["database", target.databaseId]) + : JSON.stringify(["document", target.documentId]); +} + +export function contentRecentVisitKey(target: ContentRecentTarget) { return JSON.stringify([ target.documentId, target.databaseId ?? null, @@ -70,8 +81,26 @@ export function contentRecentTargetKey(target: ContentRecentTarget) { } export function readContentRecentState(value: unknown): ContentRecentState { - if (value === null) return { version: 1, entries: [] }; - return contentRecentStateSchema.parse(value); + if (value === null) return { version: 2, entries: [] }; + const current = contentRecentStateSchema.safeParse(value); + if (current.success) return current.data; + const legacy = contentRecentStateV1Schema.parse(value); + return migrateContentRecentState(legacy.entries); +} + +function migrateContentRecentState( + entries: ContentRecentEntry[], +): ContentRecentState { + return entries + .map((entry, index) => ({ entry, index })) + .sort( + (a, b) => + b.entry.visitedAt.localeCompare(a.entry.visitedAt) || a.index - b.index, + ) + .reduce( + (state, { entry }) => recordContentRecentVisit(state, entry), + { version: 2, entries: [] }, + ); } export function recordContentRecentVisit( @@ -82,9 +111,9 @@ export function recordContentRecentVisit( const existing = state.entries.find( (candidate) => contentRecentTargetKey(candidate.target) === key, ); - if (existing && existing.visitedAt > entry.visitedAt) return state; + if (existing && existing.visitedAt >= entry.visitedAt) return state; return { - version: 1, + version: 2, entries: [ entry, ...state.entries.filter( From d31e805c222e667533cbac63976bd1c975e09b0e Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 17 Sep 2026 22:27:06 -0400 Subject: [PATCH 08/20] Scope Content sidebar navigation to the selected space --- templates/content/AGENTS.md | 2 +- .../_content-database-personal-view.ts | 3 +- templates/content/actions/_content-recent.ts | 37 +- .../content/actions/_content-sidebar-state.ts | 80 ++- templates/content/actions/_database-utils.ts | 117 +++- .../content-personal-navigation-patch.test.ts | 25 + .../content/actions/content-recent.test.ts | 105 ++- .../actions/content-sidebar-state.test.ts | 80 ++- ...ontent-database.favorites-order.db.test.ts | 259 +++++++ .../content/actions/get-content-database.ts | 46 +- .../actions/get-content-navigation-context.ts | 137 +++- .../content/actions/get-content-recent.ts | 19 +- .../actions/get-content-sidebar-state.ts | 6 +- ...ntent-database-items.navigation.db.test.ts | 256 ++++++- ...ate-content-database-personal-view.test.ts | 53 ++ .../update-content-database-personal-view.ts | 24 +- .../actions/update-content-sidebar-state.ts | 12 +- .../components/editor/database/sidebar.tsx | 31 +- .../sidebar/DocumentSidebar.layout.test.ts | 124 +--- .../components/sidebar/DocumentSidebar.tsx | 633 +++++++++--------- .../sidebar/PersonalSidebarSections.tsx | 113 +++- .../sidebar/SidebarNavigationRow.tsx | 42 ++ .../sidebar/select-content-space.test.ts | 103 ++- .../sidebar/select-content-space.ts | 48 +- .../content/app/hooks/use-content-database.ts | 23 +- .../app/hooks/use-content-recent.test.ts | 17 + .../content/app/hooks/use-content-recent.ts | 13 +- .../hooks/use-document-title-mutation.test.ts | 4 +- templates/content/app/hooks/use-documents.ts | 56 +- .../app/hooks/use-local-storage.test.tsx | 65 ++ .../content/app/hooks/use-local-storage.ts | 5 +- templates/content/app/i18n-data.ts | 11 + templates/content/app/i18n/zh-TW.ts | 1 + .../content.navigation.sidebar.md | 19 +- .../content-personal-navigation-patch.test.ts | 65 ++ .../content-personal-navigation-patch.ts | 61 +- .../shared/content-personal-navigation.ts | 15 +- 37 files changed, 2136 insertions(+), 574 deletions(-) create mode 100644 templates/content/actions/get-content-database.favorites-order.db.test.ts create mode 100644 templates/content/app/components/sidebar/SidebarNavigationRow.tsx create mode 100644 templates/content/app/hooks/use-content-recent.test.ts diff --git a/templates/content/AGENTS.md b/templates/content/AGENTS.md index a94a652ed85..f0e94c833ff 100644 --- a/templates/content/AGENTS.md +++ b/templates/content/AGENTS.md @@ -65,7 +65,7 @@ Read the relevant skill before deeper work: | `pull-document` | Flush collaboration state, then read | | `create-document` | Create a page | | `resolve-content-landing` | Resolve the caller's landing page | -| `get-content-recent` | List personal recent destinations with current access | +| `get-content-recent` | List personal recent destinations with current access, optionally scoped to a Content space's Files membership | | `edit-document` | Make a targeted text change | | `update-document` | Replace title, content, or description | | `delete-document` | Move a page tree to Trash | diff --git a/templates/content/actions/_content-database-personal-view.ts b/templates/content/actions/_content-database-personal-view.ts index a1c83f03032..35f026a79f6 100644 --- a/templates/content/actions/_content-database-personal-view.ts +++ b/templates/content/actions/_content-database-personal-view.ts @@ -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 = { diff --git a/templates/content/actions/_content-recent.ts b/templates/content/actions/_content-recent.ts index a37ffefa42a..ba4af5393cb 100644 --- a/templates/content/actions/_content-recent.ts +++ b/templates/content/actions/_content-recent.ts @@ -1,5 +1,5 @@ import { getRequestOrgId } from "@agent-native/core/server/request-context"; -import { and, inArray, isNull, or } from "drizzle-orm"; +import { and, eq, inArray, isNull, or } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; @@ -9,6 +9,7 @@ import { 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"; @@ -25,10 +26,36 @@ const viewIdentitySchema = z.object({ export async function resolveContentRecentEntries( userEmail: string, entries: ContentRecentEntry[], + spaceId?: string, ): Promise { 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, @@ -42,12 +69,14 @@ export async function resolveContentRecentEntries( authorizedOrgIds: orgId ? [orgId] : [], additional: inArray( schema.documents.id, - entries.map((entry) => entry.target.documentId), + scopedEntries.map((entry) => entry.target.documentId), ), }), ); const byId = new Map(documents.map((document) => [document.id, document])); - const databaseEntries = entries.filter((entry) => entry.target.databaseId); + const databaseEntries = scopedEntries.filter( + (entry) => entry.target.databaseId, + ); const viewDocumentIds = databaseEntries .filter( (entry) => entry.target.databaseId && byId.has(entry.target.documentId), @@ -100,7 +129,7 @@ export async function resolveContentRecentEntries( } const results: ContentRecentResult[] = []; const seen = new Set(); - for (const entry of entries) { + for (const entry of scopedEntries) { const database = entry.target.databaseId ? databasesById.get(entry.target.databaseId) : undefined; diff --git a/templates/content/actions/_content-sidebar-state.ts b/templates/content/actions/_content-sidebar-state.ts index a525bd343bf..6e3f2ccfd4a 100644 --- a/templates/content/actions/_content-sidebar-state.ts +++ b/templates/content/actions/_content-sidebar-state.ts @@ -2,11 +2,35 @@ import { z } from "zod"; import { contentSidebarSectionsSchema } from "../shared/content-personal-navigation.js"; -export const CONTENT_SIDEBAR_STATE_VERSION = 1; +export const CONTENT_SIDEBAR_STATE_VERSION = 2; export const CONTENT_SIDEBAR_STATE_SETTING_KEY = "content-sidebar-state"; const expandedIdSchema = z.string().min(1).max(256); +const contentSidebarStateV1Schema = z.object({ + version: z.literal(1), + expandedWorkspaceIds: z.array(expandedIdSchema).max(1_000).optional(), + expandedDocumentIds: z.array(expandedIdSchema).max(5_000).optional(), + sections: z + .object({ + order: z + .array(z.enum(["pinned", "recent", "workspaces"])) + .length(3) + .refine((ids) => new Set(ids).size === 3), + pinned: z.object({ + visible: z.boolean(), + expanded: z.boolean(), + limit: z.number().int().min(5).max(50), + }), + recent: z.object({ + visible: z.boolean(), + expanded: z.boolean(), + limit: z.number().int().min(5).max(50), + }), + }) + .optional(), +}); + export const contentSidebarStateSchema = z.object({ version: z.literal(CONTENT_SIDEBAR_STATE_VERSION), expandedWorkspaceIds: z.array(expandedIdSchema).max(1_000).optional(), @@ -16,9 +40,18 @@ export const contentSidebarStateSchema = z.object({ export type ContentSidebarState = z.infer; -export function normalizeContentSidebarState(value: unknown) { +export function normalizeContentSidebarState( + value: unknown, + selectedSpaceId?: string, +) { if (value === null) return null; - const data = contentSidebarStateSchema.parse(value); + const current = contentSidebarStateSchema.safeParse(value); + const data = current.success + ? current.data + : migrateContentSidebarState( + contentSidebarStateV1Schema.parse(value), + selectedSpaceId, + ); return { ...data, ...(data.expandedWorkspaceIds === undefined @@ -29,3 +62,44 @@ export function normalizeContentSidebarState(value: unknown) { : { expandedDocumentIds: [...new Set(data.expandedDocumentIds)] }), }; } + +function migrateContentSidebarState( + state: z.infer, + selectedSpaceId?: string, +): ContentSidebarState { + const sections = state.sections; + return { + version: CONTENT_SIDEBAR_STATE_VERSION, + ...(state.expandedWorkspaceIds === undefined + ? {} + : { expandedWorkspaceIds: state.expandedWorkspaceIds }), + ...(state.expandedDocumentIds === undefined + ? {} + : { expandedDocumentIds: state.expandedDocumentIds }), + ...(sections + ? { + sections: { + order: sections.order.map((id) => + id === "workspaces" ? "files" : id, + ), + pinned: { + visible: sections.pinned.visible, + expanded: sections.pinned.expanded, + }, + recent: { + visible: sections.recent.visible, + expanded: sections.recent.expanded, + }, + files: { + visible: true, + expanded: + state.expandedWorkspaceIds === undefined || + (selectedSpaceId + ? state.expandedWorkspaceIds.includes(selectedSpaceId) + : state.expandedWorkspaceIds.length > 0), + }, + }, + } + : {}), + }; +} diff --git a/templates/content/actions/_database-utils.ts b/templates/content/actions/_database-utils.ts index 429d14bd6fb..e3910be5578 100644 --- a/templates/content/actions/_database-utils.ts +++ b/templates/content/actions/_database-utils.ts @@ -1,3 +1,4 @@ +import { alias } from "@agent-native/core/db/schema"; import { getRequestOrgId, getRequestUserEmail, @@ -8,7 +9,17 @@ import { resolveAccess, type ShareRole, } from "@agent-native/core/sharing"; -import { and, asc, eq, inArray, isNull, or, sql } from "drizzle-orm"; +import { + and, + asc, + desc, + eq, + exists, + inArray, + isNull, + or, + sql, +} from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; @@ -24,6 +35,7 @@ import type { ContentDatabaseMembership, ContentDatabaseResponse, ContentDatabaseTableQuery, + ContentSidebarViewOrder, DocumentProperty, } from "../shared/api.js"; import { @@ -553,6 +565,34 @@ type ContentDatabasePageBuild = ContentDatabasePageResponse & { hydratedItemCount: number; }; +const scopedFilesMemberships = alias( + schema.contentDatabaseItems, + "scoped_files_memberships", +); + +export function contentDatabaseFilesMembershipFilter(filesDatabaseId: string) { + return exists( + getDb() + .select({ id: scopedFilesMemberships.id }) + .from(scopedFilesMemberships) + .where( + and( + eq(scopedFilesMemberships.databaseId, filesDatabaseId), + eq( + scopedFilesMemberships.documentId, + schema.contentDatabaseItems.documentId, + ), + ), + ), + ); +} + +export function contentDatabaseCustomOrderRank(itemIds: string[]) { + return itemIds.length > 0 + ? sql`COALESCE(array_position(ARRAY(SELECT jsonb_array_elements_text(${JSON.stringify(itemIds)}::jsonb)), ${schema.contentDatabaseItems.id}), ${itemIds.length + 1})` + : sql`CAST(1 AS integer)`; +} + export async function getContentDatabasePageResponse( databaseId: string, options: { @@ -561,6 +601,8 @@ export async function getContentDatabasePageResponse( tableQuery?: ContentDatabaseTableQuery; includeSources?: boolean; documentIds?: string[]; + filesMembershipDatabaseId?: string; + sidebarOrder?: ContentSidebarViewOrder; database?: typeof schema.contentDatabases.$inferSelect; } = {}, ): Promise { @@ -685,6 +727,9 @@ export async function getContentDatabasePageResponse( ? inArray(schema.contentDatabaseItems.documentId, options.documentIds) : sql`1 = 0` : undefined, + options.filesMembershipDatabaseId + ? contentDatabaseFilesMembershipFilter(options.filesMembershipDatabaseId) + : undefined, sql`exists ( select 1 from ${schema.documents} where ${schema.documents.id} = ${schema.contentDatabaseItems.documentId} @@ -733,22 +778,60 @@ export async function getContentDatabasePageResponse( ) : null; - let itemsQuery = db - .select() - .from(schema.contentDatabaseItems) - .where(visibleItemFilter) - .orderBy( - asc(schema.contentDatabaseItems.position), - asc(schema.contentDatabaseItems.createdAt), - asc(schema.contentDatabaseItems.id), - ) - .$dynamic(); - if (serverTableQuery) { - itemsQuery = itemsQuery.limit(CONTENT_DATABASE_MAX_READ_LIMIT); - } else if (limit !== null) { - itemsQuery = itemsQuery.limit(limit).offset(offset); + let items; + if (options.sidebarOrder && !serverTableQuery) { + const customItemIds = options.sidebarOrder.itemIds; + const customRank = contentDatabaseCustomOrderRank(customItemIds); + const order = + options.sidebarOrder.mode === "custom" + ? [ + asc(customRank), + asc(schema.contentDatabaseItems.position), + asc(schema.contentDatabaseItems.id), + ] + : options.sidebarOrder.mode === "name" + ? [asc(schema.documents.title), asc(schema.contentDatabaseItems.id)] + : options.sidebarOrder.mode === "created" + ? [ + desc(schema.documents.createdAt), + asc(schema.contentDatabaseItems.id), + ] + : [ + desc(schema.documents.updatedAt), + asc(schema.contentDatabaseItems.id), + ]; + let orderedItemsQuery = db + .select({ item: schema.contentDatabaseItems }) + .from(schema.contentDatabaseItems) + .innerJoin( + schema.documents, + eq(schema.documents.id, schema.contentDatabaseItems.documentId), + ) + .where(visibleItemFilter) + .orderBy(...order) + .$dynamic(); + if (limit !== null) { + orderedItemsQuery = orderedItemsQuery.limit(limit).offset(offset); + } + items = (await orderedItemsQuery).map((row) => row.item); + } else { + let itemsQuery = db + .select() + .from(schema.contentDatabaseItems) + .where(visibleItemFilter) + .orderBy( + asc(schema.contentDatabaseItems.position), + asc(schema.contentDatabaseItems.createdAt), + asc(schema.contentDatabaseItems.id), + ) + .$dynamic(); + if (serverTableQuery) { + itemsQuery = itemsQuery.limit(CONTENT_DATABASE_MAX_READ_LIMIT); + } else if (limit !== null) { + itemsQuery = itemsQuery.limit(limit).offset(offset); + } + items = await itemsQuery; } - let items = await itemsQuery; let boundedTableQueryTotal: number | null = null; if (serverTableQuery && boundedProjectionPropertyIds) { const candidateDocuments = await db @@ -1234,6 +1317,8 @@ export async function getContentDatabaseResponse( tableQuery?: ContentDatabaseTableQuery; includeSources?: boolean; documentIds?: string[]; + filesMembershipDatabaseId?: string; + sidebarOrder?: ContentSidebarViewOrder; database?: typeof schema.contentDatabases.$inferSelect; } = {}, ): Promise { diff --git a/templates/content/actions/content-personal-navigation-patch.test.ts b/templates/content/actions/content-personal-navigation-patch.test.ts index ab7ed702d18..6da08e17601 100644 --- a/templates/content/actions/content-personal-navigation-patch.test.ts +++ b/templates/content/actions/content-personal-navigation-patch.test.ts @@ -132,6 +132,31 @@ describe("personal navigation patch action", () => { readPersonalDatabaseViewOverrides(ctx.userEmail, "db"), ).rejects.toThrow(); }); + it("rejects a persisted 5,001-item sidebar order instead of truncating it", async () => { + saved = { + ...initialState(), + views: [ + { + ...initialState().views[0], + sidebarOrder: { + mode: "custom", + itemIds: Array.from( + { length: 5_001 }, + (_, index) => `item-${index}`, + ), + }, + }, + ], + }; + + await expect( + readPersonalDatabaseViewOverrides(ctx.userEmail, "db"), + ).rejects.toMatchObject({ name: "ZodError" }); + expect( + (saved as ContentDatabasePersonalViewOverrides).views[0].sidebarOrder + ?.itemIds, + ).toHaveLength(5_001); + }); it("seeds the first sidebar override from the shared View's query", async () => { saved = null; await action.run( diff --git a/templates/content/actions/content-recent.test.ts b/templates/content/actions/content-recent.test.ts index 850a8dd1554..9fbb06cfef4 100644 --- a/templates/content/actions/content-recent.test.ts +++ b/templates/content/actions/content-recent.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const boundary = vi.hoisted(() => ({ orgId: null as string | null, + spaceOrgId: null as string | null, discovery: vi.fn(), select: vi.fn(), getSetting: vi.fn(), @@ -18,6 +19,14 @@ vi.mock("@agent-native/core/settings", () => ({ vi.mock("./_document-discovery-query.js", () => ({ documentDiscoveryWhere: boundary.discovery, })); +vi.mock("./_content-space-access.js", () => ({ + resolveContentSpaceAccess: async () => ({ + space: { + filesDatabaseId: "files-db", + orgId: boundary.spaceOrgId, + }, + }), +})); vi.mock("../server/db/index.js", () => ({ getDb: () => ({ select: boundary.select }), schema: { @@ -28,6 +37,10 @@ vi.mock("../server/db/index.js", () => ({ viewConfigJson: "view-config", deletedAt: "deleted-at", }, + contentDatabaseItems: { + databaseId: "item-database-id", + documentId: "item-document-id", + }, }, })); @@ -62,6 +75,7 @@ beforeEach(() => { vi.clearAllMocks(); boundary.select.mockReset(); boundary.orgId = null; + boundary.spaceOrgId = null; stored.clear(); boundary.getSetting.mockImplementation( async (email: string, key: string) => @@ -188,6 +202,81 @@ describe("Recent access resolution", () => { expect(boundary.select).toHaveBeenCalledTimes(2); }); + it("filters by current Files membership before resolving Recent rows", async () => { + rowsOnce([{ documentId: "in-space" }]); + rowsOnce([{ id: "in-space", title: "In space", icon: null }]); + expect( + await resolveContentRecentEntries( + alice.userEmail, + [entry("outside"), entry("in-space")], + "space-a", + ), + ).toEqual([ + { + ...entry("in-space"), + title: "In space", + icon: null, + viewName: null, + }, + ]); + expect(boundary.discovery).toHaveBeenCalledWith( + expect.objectContaining({ additional: expect.anything() }), + ); + }); + + it("returns no Recent rows when the selected space belongs to another org", async () => { + boundary.orgId = "org-a"; + boundary.spaceOrgId = "org-b"; + expect( + await resolveContentRecentEntries( + alice.userEmail, + [entry("org-b-page")], + "org-b-space", + ), + ).toEqual([]); + expect(boundary.select).not.toHaveBeenCalled(); + expect(boundary.discovery).not.toHaveBeenCalled(); + }); + + it("keeps the get action empty for an authorized space in another org", async () => { + boundary.orgId = "org-a"; + boundary.spaceOrgId = "org-b"; + stored.set(settingId(alice.userEmail, contentRecentSettingKey()), { + version: 2, + entries: [entry("org-b-page")], + }); + const spaceId = "org-b-space"; + expect(await getRecent.run({ spaceId }, alice)).toEqual({ + entries: [], + scopeKey: JSON.stringify([alice.userEmail, "org-a", spaceId]), + }); + expect(boundary.select).not.toHaveBeenCalled(); + }); + + it("resolves scoped Recent rows when the selected space matches the active org", async () => { + boundary.orgId = "org-a"; + boundary.spaceOrgId = "org-a"; + rowsOnce([{ documentId: "org-a-page" }]); + rowsOnce([{ id: "org-a-page", title: "Org A", icon: null }]); + expect( + await resolveContentRecentEntries( + alice.userEmail, + [entry("org-a-page")], + "org-a-space", + ), + ).toEqual([ + { + ...entry("org-a-page"), + title: "Org A", + icon: null, + viewName: null, + }, + ]); + expect(boundary.discovery).toHaveBeenCalledWith( + expect.objectContaining({ authorizedOrgIds: ["org-a"] }), + ); + }); + it("falls back within the same database and omits an unavailable database", async () => { rowsOnce([{ id: "page", title: "Database page", icon: null }]); rowsOnce([ @@ -362,12 +451,12 @@ describe("Recent action persistence", () => { ); expect(await getRecent.run({}, bob)).toEqual({ entries: [], - scopeKey: JSON.stringify([bob.userEmail, "org-a"]), + scopeKey: JSON.stringify([bob.userEmail, "org-a", null]), }); boundary.orgId = "org-b"; expect(await getRecent.run({}, alice)).toEqual({ entries: [], - scopeKey: JSON.stringify([alice.userEmail, "org-b"]), + scopeKey: JSON.stringify([alice.userEmail, "org-b", null]), }); expect(contentRecentSettingKey()).toBe('content-recent:"org-b"'); }); @@ -387,7 +476,7 @@ describe("Recent action persistence", () => { }); it("accepts and returns the normalized identity scope", async () => { - const scopeKey = JSON.stringify([alice.userEmail, null]); + const scopeKey = JSON.stringify([alice.userEmail, null, null]); expect( await getRecent.run({ scopeKey }, { userEmail: " Alice@Example.Test " }), ).toEqual({ scopeKey, entries: [] }); @@ -453,13 +542,13 @@ describe("sidebar partial state persistence", () => { const sections = defaultContentSidebarSections(); sections.recent.visible = false; await Promise.all([ - updateSidebar.run({ version: 1, expandedDocumentIds: ["page"] }, alice), - updateSidebar.run({ version: 1, sections }, alice), + updateSidebar.run({ version: 2, expandedDocumentIds: ["page"] }, alice), + updateSidebar.run({ version: 2, sections }, alice), ]); expect( stored.get(settingId(alice.userEmail, "content-sidebar-state")), ).toEqual({ - version: 1, + version: 2, expandedDocumentIds: ["page"], sections, }); @@ -471,10 +560,10 @@ describe("sidebar partial state persistence", () => { it("does not overwrite corrupt persisted state or report a failed mutation as saved", async () => { const id = settingId(alice.userEmail, "content-sidebar-state"); stored.set(id, { version: 99 }); - await expect(updateSidebar.run({ version: 1 }, alice)).rejects.toThrow(); + await expect(updateSidebar.run({ version: 2 }, alice)).rejects.toThrow(); expect(stored.get(id)).toEqual({ version: 99 }); boundary.mutateSetting.mockRejectedValueOnce(new Error("write failed")); - await expect(updateSidebar.run({ version: 1 }, alice)).rejects.toThrow( + await expect(updateSidebar.run({ version: 2 }, alice)).rejects.toThrow( "write failed", ); }); diff --git a/templates/content/actions/content-sidebar-state.test.ts b/templates/content/actions/content-sidebar-state.test.ts index 4bff39c394f..1dfab33af8c 100644 --- a/templates/content/actions/content-sidebar-state.test.ts +++ b/templates/content/actions/content-sidebar-state.test.ts @@ -29,7 +29,7 @@ describe("normalizeContentSidebarState", () => { it("keeps expansion preferences absent through a first section-only save and read", async () => { const sections = defaultContentSidebarSections(); sections.recent.visible = false; - const state = { version: 1 as const, sections }; + const state = { version: 2 as const, sections }; const ctx = { userEmail: "sidebar@example.test" }; expect(await updateSidebar.run(state, ctx)).toEqual({ state }); expect(await getSidebar.run({}, ctx)).toEqual({ state }); @@ -40,14 +40,14 @@ describe("normalizeContentSidebarState", () => { it("preserves explicit empty expansion preferences when sections change", async () => { const ctx = { userEmail: "sidebar@example.test" }; await updateSidebar.run( - { version: 1, expandedWorkspaceIds: [], expandedDocumentIds: [] }, + { version: 2, expandedWorkspaceIds: [], expandedDocumentIds: [] }, ctx, ); const sections = defaultContentSidebarSections(); - await updateSidebar.run({ version: 1, sections }, ctx); + await updateSidebar.run({ version: 2, sections }, ctx); expect(await getSidebar.run({}, ctx)).toEqual({ state: { - version: 1, + version: 2, expandedWorkspaceIds: [], expandedDocumentIds: [], sections, @@ -72,4 +72,76 @@ describe("normalizeContentSidebarState", () => { expect(normalizeContentSidebarState(null)).toBeNull(); expect(() => normalizeContentSidebarState({ version: 0 })).toThrow(); }); + + it("migrates workspaces to recoverable Files and discards saved limits", () => { + expect( + normalizeContentSidebarState( + { + version: 1, + expandedWorkspaceIds: [], + sections: { + order: ["recent", "workspaces", "pinned"], + pinned: { visible: false, expanded: false, limit: 45 }, + recent: { visible: true, expanded: false, limit: 30 }, + }, + }, + "other-space", + ), + ).toEqual({ + version: 2, + expandedWorkspaceIds: [], + sections: { + order: ["recent", "files", "pinned"], + pinned: { visible: false, expanded: false }, + recent: { visible: true, expanded: false }, + files: { visible: true, expanded: false }, + }, + }); + }); + + it("inherits Files expansion from the selected v1 workspace", () => { + const legacy = { + version: 1, + expandedWorkspaceIds: ["space-a"], + sections: { + order: ["pinned", "recent", "workspaces"], + pinned: { visible: true, expanded: true, limit: 5 }, + recent: { visible: true, expanded: true, limit: 5 }, + }, + }; + expect( + normalizeContentSidebarState(legacy, "space-a")?.sections?.files, + ).toEqual({ visible: true, expanded: true }); + expect( + normalizeContentSidebarState(legacy, "space-b")?.sections?.files, + ).toEqual({ visible: true, expanded: false }); + }); + + it("uses the selected space when the first v2 partial write migrates v1", async () => { + settings.value = { + version: 1, + expandedWorkspaceIds: ["space-a"], + sections: { + order: ["pinned", "recent", "workspaces"], + pinned: { visible: true, expanded: true, limit: 25 }, + recent: { visible: true, expanded: true, limit: 15 }, + }, + }; + await updateSidebar.run( + { + version: 2, + spaceId: "space-b", + expandedDocumentIds: ["child"], + }, + { userEmail: "sidebar@example.test" }, + ); + expect(settings.value).toMatchObject({ + version: 2, + expandedWorkspaceIds: ["space-a"], + expandedDocumentIds: ["child"], + sections: { files: { visible: true, expanded: false } }, + }); + expect(settings.value).not.toHaveProperty("spaceId"); + expect(settings.value.sections.pinned).not.toHaveProperty("limit"); + }); }); diff --git a/templates/content/actions/get-content-database.favorites-order.db.test.ts b/templates/content/actions/get-content-database.favorites-order.db.test.ts new file mode 100644 index 00000000000..82eef06b670 --- /dev/null +++ b/templates/content/actions/get-content-database.favorites-order.db.test.ts @@ -0,0 +1,259 @@ +import { rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { runWithRequestContext } from "@agent-native/core/server"; +import { putUserSetting } from "@agent-native/core/settings"; +import { and, eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +const TEST_DB_PATH = join( + tmpdir(), + `content-favorites-order-${process.pid}-${Date.now()}.pglite`, +); +const OWNER = "favorites-order-owner@example.com"; + +type Schema = typeof import("../server/db/schema.js"); +let getDb: () => any; +let schema: Schema; +let getContentDatabase: typeof import("./get-content-database.js").default; +let personalContentSpaceId: typeof import("./_content-spaces.js").personalContentSpaceId; +let personalDatabaseViewSettingKey: typeof import("./_content-database-personal-view.js").personalDatabaseViewSettingKey; + +beforeAll(async () => { + process.env.DATABASE_URL = `pglite:${TEST_DB_PATH}`; + const dbModule = await import("../server/db/index.js"); + getDb = dbModule.getDb; + schema = dbModule.schema; + const spaces = await import("./_content-spaces.js"); + personalContentSpaceId = spaces.personalContentSpaceId; + ({ personalDatabaseViewSettingKey } = + await import("./_content-database-personal-view.js")); + getContentDatabase = (await import("./get-content-database.js")).default; + const plugin = (await import("../server/plugins/db.js")).default; + await plugin(undefined as any); + await runWithRequestContext({ userEmail: OWNER }, () => + spaces.provisionContentSpaces(getDb(), OWNER), + ); +}, 60_000); + +afterAll(() => { + rmSync(TEST_DB_PATH, { force: true, recursive: true }); +}); + +describe("scoped Favorites personal ordering", () => { + it("orders accessible selected-space pins before applying the display bound", async () => { + const db = getDb(); + const spaceId = personalContentSpaceId(OWNER); + const databases = await db + .select() + .from(schema.contentDatabases) + .where(eq(schema.contentDatabases.spaceId, spaceId)); + const favorites = databases.find( + (database: { systemRole: string | null }) => + database.systemRole === "favorites", + ); + const files = databases.find( + (database: { systemRole: string | null }) => + database.systemRole === "files", + ); + if (!favorites || !files) + throw new Error("Missing personal system databases"); + + const now = "2026-09-17T12:00:00.000Z"; + const scopedDocuments = Array.from({ length: 54 }, (_, index) => ({ + id: `favorites-order-document-${String(index).padStart(2, "0")}`, + ownerEmail: OWNER, + orgId: null, + spaceId, + parentId: null, + title: + index < 2 ? "Same title" : `Title ${String(index).padStart(2, "0")}`, + content: "", + description: "", + position: index, + isFavorite: 1, + hideFromSearch: 0, + visibility: "private", + createdAt: now, + updatedAt: now, + })); + const inaccessibleDocument = { + ...scopedDocuments[0], + id: "favorites-order-inaccessible", + ownerEmail: "someone-else@example.com", + title: "Inaccessible", + }; + const unscopedDocument = { + ...scopedDocuments[0], + id: "favorites-order-unscoped", + spaceId: null, + title: "Unscoped", + }; + await db + .insert(schema.documents) + .values([...scopedDocuments, inaccessibleDocument, unscopedDocument]); + + const favoriteItems = [ + ...scopedDocuments.map((document, index) => ({ + id: + index === 0 + ? "favorites-order-item-a" + : index === 1 + ? "favorites-order-item-b" + : `favorites-order-item-${String(index).padStart(2, "0")}`, + ownerEmail: OWNER, + databaseId: favorites.id, + documentId: document.id, + position: index < 2 ? 0 : index, + createdAt: now, + updatedAt: now, + })), + { + id: "favorites-order-item-inaccessible", + ownerEmail: OWNER, + databaseId: favorites.id, + documentId: inaccessibleDocument.id, + position: 0, + createdAt: now, + updatedAt: now, + }, + { + id: "favorites-order-item-unscoped", + ownerEmail: OWNER, + databaseId: favorites.id, + documentId: unscopedDocument.id, + position: 0, + createdAt: now, + updatedAt: now, + }, + ]; + await db.insert(schema.contentDatabaseItems).values([ + ...favoriteItems, + ...[...scopedDocuments, inaccessibleDocument].map((document, index) => ({ + id: `favorites-order-files-item-${String(index).padStart(2, "0")}`, + ownerEmail: OWNER, + databaseId: files.id, + documentId: document.id, + position: index, + createdAt: now, + updatedAt: now, + })), + ]); + + const viewConfig = JSON.parse(favorites.viewConfigJson); + const activeViewId = viewConfig.activeViewId ?? viewConfig.views[0].id; + const customFirst = favoriteItems[53].id; + await putUserSetting(OWNER, personalDatabaseViewSettingKey(favorites.id), { + version: 2, + activeViewId, + views: [ + { + id: activeViewId, + sorts: [], + filters: [], + filterMode: "and", + sidebarOrder: { mode: "custom", itemIds: [customFirst] }, + }, + ], + }); + + const { + contentDatabaseCustomOrderRank, + contentDatabaseFilesMembershipFilter, + } = await import("./_database-utils.js"); + const scopedQueryShape = db + .select({ id: schema.contentDatabaseItems.id }) + .from(schema.contentDatabaseItems) + .where( + and( + eq(schema.contentDatabaseItems.databaseId, favorites.id), + contentDatabaseFilesMembershipFilter(files.id), + ), + ) + .limit(50) + .toSQL(); + expect(scopedQueryShape.sql).toContain( + 'exists (select "id" from "content_database_items" "scoped_files_memberships"', + ); + expect(scopedQueryShape.sql).toContain( + '"scoped_files_memberships"."document_id" = "content_database_items"."document_id"', + ); + expect(scopedQueryShape.sql).toMatch(/\)\) limit \$3$/); + expect(scopedQueryShape.params).toEqual([favorites.id, files.id, 50]); + + const boundedCustomIds = Array.from( + { length: 5_000 }, + (_, index) => `stored-item-${index}`, + ); + const customOrderQueryShape = db + .select({ id: schema.contentDatabaseItems.id }) + .from(schema.contentDatabaseItems) + .where(eq(schema.contentDatabaseItems.databaseId, favorites.id)) + .orderBy(contentDatabaseCustomOrderRank(boundedCustomIds)) + .limit(50) + .toSQL(); + expect(customOrderQueryShape.params).toEqual([ + favorites.id, + JSON.stringify(boundedCustomIds), + 5_001, + 50, + ]); + + const scoped = await runWithRequestContext({ userEmail: OWNER }, () => + getContentDatabase.run( + { databaseId: favorites.id, contentSpaceId: spaceId, limit: 50 }, + { userEmail: OWNER } as any, + ), + ); + expect(scoped.items).toHaveLength(50); + expect(scoped.items.slice(0, 3).map((item) => item.id)).toEqual([ + customFirst, + "favorites-order-item-a", + "favorites-order-item-b", + ]); + expect(scoped.items.map((item) => item.id)).not.toContain( + "favorites-order-item-inaccessible", + ); + expect(scoped.items.map((item) => item.id)).not.toContain( + "favorites-order-item-unscoped", + ); + expect(scoped.pagination).toMatchObject({ totalItems: 54, hasMore: true }); + + await putUserSetting(OWNER, personalDatabaseViewSettingKey(favorites.id), { + version: 2, + activeViewId, + views: [ + { + id: activeViewId, + sorts: [], + filters: [], + filterMode: "and", + sidebarOrder: { mode: "name", itemIds: [customFirst] }, + }, + ], + }); + const byName = await runWithRequestContext({ userEmail: OWNER }, () => + getContentDatabase.run( + { databaseId: favorites.id, contentSpaceId: spaceId, limit: 2 }, + { userEmail: OWNER } as any, + ), + ); + expect(byName.items.map((item) => item.id)).toEqual([ + "favorites-order-item-a", + "favorites-order-item-b", + ]); + + const aggregate = await runWithRequestContext({ userEmail: OWNER }, () => + getContentDatabase.run({ databaseId: favorites.id, limit: 100 }, { + userEmail: OWNER, + } as any), + ); + expect(aggregate.items.map((item) => item.id)).toContain( + "favorites-order-item-unscoped", + ); + expect(aggregate.items.map((item) => item.id)).not.toContain( + "favorites-order-item-inaccessible", + ); + }); +}); diff --git a/templates/content/actions/get-content-database.ts b/templates/content/actions/get-content-database.ts index c171f0ba75e..7c445910e19 100644 --- a/templates/content/actions/get-content-database.ts +++ b/templates/content/actions/get-content-database.ts @@ -1,16 +1,21 @@ import { defineAction, type ActionRunContext } from "@agent-native/core/action"; import { z } from "zod"; +import { getDb } from "../server/db/index.js"; import type { ContentDatabaseResponse, ContentDatabaseUnavailableResponse, + ContentSidebarViewOrder, } from "../shared/api.js"; +import { readPersonalDatabaseViewOverrides } from "./_content-database-personal-view.js"; +import { resolveContentSpaceAccess } from "./_content-space-access.js"; import { CONTENT_DATABASE_MAX_READ_LIMIT, contentDatabaseTableQuerySchema, getContentDatabaseResponse, resolveContentDatabaseRead, } from "./_database-utils.js"; +import { parseDatabaseViewConfig } from "./_property-utils.js"; const getContentDatabaseSchema = z.object({ databaseId: z.string().optional().describe("Database ID"), @@ -22,6 +27,14 @@ const getContentDatabaseSchema = z.object({ .max(CONTENT_DATABASE_MAX_READ_LIMIT) .optional(), offset: z.coerce.number().int().min(0).optional(), + contentSpaceId: z + .string() + .min(1) + .max(256) + .optional() + .describe( + "For the personal Favorites database only, filter rows to authoritative Files membership in this Content space before pagination.", + ), tableQuery: contentDatabaseTableQuerySchema, }); @@ -49,7 +62,7 @@ export default defineAction({ http: { method: "GET" }, readOnly: true, run: async ( - { databaseId, documentId, limit, offset, tableQuery }, + { databaseId, documentId, limit, offset, tableQuery, contentSpaceId }, context, ): Promise => { const resolved = await resolveContentDatabaseRead({ @@ -58,11 +71,42 @@ export default defineAction({ }); if (!resolved.available) return resolved; + let filesMembershipDatabaseId: string | undefined; + let sidebarOrder: ContentSidebarViewOrder | undefined; + if (contentSpaceId) { + if (resolved.database.systemRole !== "favorites") { + throw new Error("Content-space scope is only valid for Favorites."); + } + const db = getDb(); + const access = await resolveContentSpaceAccess(contentSpaceId, "viewer", { + db, + }); + filesMembershipDatabaseId = access.space.filesDatabaseId; + if (context?.userEmail) { + const sharedConfig = parseDatabaseViewConfig( + resolved.database.viewConfigJson, + ); + const overrides = await readPersonalDatabaseViewOverrides( + context.userEmail, + resolved.database.id, + ); + const activeViewId = + overrides?.activeViewId && + sharedConfig.views.some((view) => view.id === overrides.activeViewId) + ? overrides.activeViewId + : sharedConfig.activeViewId; + sidebarOrder = overrides?.views.find((view) => view.id === activeViewId) + ?.sidebarOrder ?? { mode: "custom", itemIds: [] }; + } + } + return getContentDatabaseResponse(resolved.database.id, { limit: resolveContentDatabaseReadLimit(limit, context?.caller), offset, tableQuery, database: resolved.database, + filesMembershipDatabaseId, + sidebarOrder, }); }, }); diff --git a/templates/content/actions/get-content-navigation-context.ts b/templates/content/actions/get-content-navigation-context.ts index c5141283c89..e49bd74c448 100644 --- a/templates/content/actions/get-content-navigation-context.ts +++ b/templates/content/actions/get-content-navigation-context.ts @@ -4,13 +4,14 @@ import { getRequestOrgId, getRequestUserEmail, } from "@agent-native/core/server/request-context"; -import { accessFilter } from "@agent-native/core/sharing"; +import { accessFilter, type AccessContext } from "@agent-native/core/sharing"; import { and, eq, isNull, or } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; import type { ContentNavigationContext } from "../shared/api.js"; import { favoriteDocumentIds } from "./_content-favorites.js"; +import { resolveContentSpaceAccess } from "./_content-space-access.js"; import { serializeDocumentSource } from "./_document-source.js"; import { getContentSourceMode, @@ -21,6 +22,107 @@ import { const MAX_ANCESTORS = 100; +type NavigationFilesContext = { + databaseId: string; + databaseDocumentId: string; + spaceId: string; + accessContext: AccessContext; +}; + +function isContentSpaceAccessDenial(error: unknown): boolean { + return ( + error instanceof Error && + (error.message.startsWith("Not authorized for Content space") || + (error.message.startsWith("Content space") && + error.message.endsWith("not found"))) + ); +} + +async function resolveNavigationFilesContext( + db: ReturnType, + documentId: string, +): Promise { + const membershipCandidates = await db + .select({ + databaseId: schema.contentDatabases.id, + databaseDocumentId: schema.contentDatabases.documentId, + spaceId: schema.contentSpaces.id, + }) + .from(schema.contentDatabaseItems) + .innerJoin( + schema.contentDatabases, + eq(schema.contentDatabases.id, schema.contentDatabaseItems.databaseId), + ) + .innerJoin( + schema.contentSpaces, + eq(schema.contentSpaces.id, schema.contentDatabases.spaceId), + ) + .where( + and( + eq(schema.contentDatabaseItems.documentId, documentId), + eq(schema.contentDatabases.systemRole, "files"), + eq(schema.contentSpaces.filesDatabaseId, schema.contentDatabases.id), + isNull(schema.contentDatabases.deletedAt), + isNull(schema.contentSpaces.archivedAt), + ), + ); + const filesDocumentCandidates = await db + .select({ + databaseId: schema.contentDatabases.id, + databaseDocumentId: schema.contentDatabases.documentId, + spaceId: schema.contentSpaces.id, + }) + .from(schema.contentDatabases) + .innerJoin( + schema.contentSpaces, + eq(schema.contentSpaces.id, schema.contentDatabases.spaceId), + ) + .where( + and( + eq(schema.contentDatabases.documentId, documentId), + eq(schema.contentDatabases.systemRole, "files"), + eq(schema.contentSpaces.filesDatabaseId, schema.contentDatabases.id), + isNull(schema.contentDatabases.deletedAt), + isNull(schema.contentSpaces.archivedAt), + ), + ); + const candidates = new Map( + [...membershipCandidates, ...filesDocumentCandidates].map((candidate) => [ + candidate.databaseId, + candidate, + ]), + ); + const authorized: NavigationFilesContext[] = []; + for (const candidate of candidates.values()) { + try { + const access = await resolveContentSpaceAccess( + candidate.spaceId, + "viewer", + { db }, + ); + authorized.push({ + ...candidate, + accessContext: { + userEmail: access.authority.userEmail, + orgId: access.authority.orgId ?? undefined, + }, + }); + } catch (error) { + if (!isContentSpaceAccessDenial(error)) throw error; + } + } + if (authorized.length > 1) { + fail( + "The document belongs to more than one authoritative Files navigation context.", + { + errorCode: "navigation_context_ambiguous", + statusCode: 409, + }, + ); + } + return authorized[0]; +} + function permissions(role: string) { return { accessRole: role as "owner" | "admin" | "editor" | "commenter" | "viewer", @@ -91,7 +193,13 @@ export default defineAction({ ); const path: ContentNavigationContext["path"] = []; const userEmail = getRequestUserEmail(); - const orgId = getRequestOrgId(); + const navigationFilesContext = await resolveNavigationFilesContext(db, id); + const accessContext: AccessContext = + navigationFilesContext?.accessContext ?? { + userEmail, + orgId: getRequestOrgId() ?? undefined, + }; + const orgId = accessContext.orgId; const seen = new Set(); let activeRow: typeof schema.documents.$inferSelect | undefined; let activeMembership: @@ -132,7 +240,11 @@ export default defineAction({ and( eq(schema.documents.id, currentId), isNull(schema.documents.trashedAt), - accessFilter(schema.documents, schema.documentShares), + accessFilter( + schema.documents, + schema.documentShares, + accessContext, + ), ), ) .limit(1); @@ -176,7 +288,11 @@ export default defineAction({ isNull(schema.contentDatabases.deletedAt), eq(schema.contentDatabases.systemRole, "files"), isNull(databaseDocuments.trashedAt), - accessFilter(databaseDocuments, schema.documentShares), + accessFilter( + databaseDocuments, + schema.documentShares, + accessContext, + ), ), ) .limit(2); @@ -190,6 +306,13 @@ export default defineAction({ ); } const membership = memberships[0]; + const activeFilesDatabase = + row.id === id && + !membership && + navigationFilesContext?.databaseDocumentId === row.id + ? navigationFilesContext + : undefined; + const navigationMembership = membership ?? activeFilesDatabase; const shareRows = userEmail ? await db .select({ role: schema.documentShares.role }) @@ -225,8 +348,8 @@ export default defineAction({ parentId: row.parentId, title: row.title, icon: row.icon, - databaseId: membership?.databaseId ?? null, - databaseDocumentId: membership?.databaseDocumentId ?? null, + databaseId: navigationMembership?.databaseId ?? null, + databaseDocumentId: navigationMembership?.databaseDocumentId ?? null, isFavorite: false, visibility: row.visibility as "private" | "org" | "public", ...permissions(role), @@ -241,7 +364,7 @@ export default defineAction({ ); if (row.id === id) { activeRow = row as typeof schema.documents.$inferSelect; - activeMembership = membership; + activeMembership = navigationMembership; } } if (userEmail) { diff --git a/templates/content/actions/get-content-recent.ts b/templates/content/actions/get-content-recent.ts index ad76077bfb8..63de569cb2a 100644 --- a/templates/content/actions/get-content-recent.ts +++ b/templates/content/actions/get-content-recent.ts @@ -12,13 +12,24 @@ import { export default defineAction({ description: "Read the current user's recently visited Pages and exact Views in the current context, resolving every target under current access.", - schema: z.object({ scopeKey: z.string().optional() }), + schema: z.object({ + scopeKey: z.string().optional(), + spaceId: z + .string() + .min(1) + .max(256) + .optional() + .describe( + "Optional Content space whose authoritative Files membership scopes the returned destinations.", + ), + }), http: { method: "GET" }, run: async (args, ctx) => { if (!ctx?.userEmail) fail("Not authenticated.", { statusCode: 401 }); const scopeKey = JSON.stringify([ ctx.userEmail.trim().toLowerCase(), getRequestOrgId() ?? null, + args.spaceId ?? null, ]); if (args.scopeKey && args.scopeKey !== scopeKey) fail("Navigation context changed.", { @@ -37,7 +48,11 @@ export default defineAction({ } return { scopeKey, - entries: await resolveContentRecentEntries(ctx.userEmail, state.entries), + entries: await resolveContentRecentEntries( + ctx.userEmail, + state.entries, + args.spaceId, + ), }; }, }); diff --git a/templates/content/actions/get-content-sidebar-state.ts b/templates/content/actions/get-content-sidebar-state.ts index 58703ae1025..d281ddaf780 100644 --- a/templates/content/actions/get-content-sidebar-state.ts +++ b/templates/content/actions/get-content-sidebar-state.ts @@ -9,15 +9,15 @@ import { export default defineAction({ description: "Read the current user's Content sidebar expansion state.", - schema: z.object({}), + schema: z.object({ spaceId: z.string().min(1).max(256).optional() }), http: { method: "GET" }, agentTool: false, - run: async (_args, ctx) => { + run: async (args, ctx) => { if (!ctx?.userEmail) throw new Error("Not authenticated."); const stored = await getUserSetting( ctx.userEmail, CONTENT_SIDEBAR_STATE_SETTING_KEY, ); - return { state: normalizeContentSidebarState(stored) }; + return { state: normalizeContentSidebarState(stored, args.spaceId) }; }, }); diff --git a/templates/content/actions/query-content-database-items.navigation.db.test.ts b/templates/content/actions/query-content-database-items.navigation.db.test.ts index ef355b880b4..eac26f74be4 100644 --- a/templates/content/actions/query-content-database-items.navigation.db.test.ts +++ b/templates/content/actions/query-content-database-items.navigation.db.test.ts @@ -2,9 +2,10 @@ import { rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { getDbExec } from "@agent-native/core/db"; import { runWithRequestContext } from "@agent-native/core/server"; import { putUserSetting } from "@agent-native/core/settings"; -import { eq } from "drizzle-orm"; +import { eq, inArray } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { CONTENT_DATABASE_PERSONAL_VIEW_OVERRIDES_VERSION } from "../shared/api.js"; @@ -17,6 +18,14 @@ const OWNER = "navigation-owner@example.com"; const OTHER = "navigation-other@example.com"; const SPACE_ID = "navigation-space"; const DATABASE_ID = "navigation-files"; +const ORGANIZATION_MEMBER = "navigation-member@example.com"; +const ORGANIZATION_OTHER = "navigation-org-owner@example.com"; +const ORGANIZATION_DENIED = "navigation-denied@example.com"; +const ORGANIZATION_A_ID = "navigation-org-a"; +const ORGANIZATION_B_ID = "navigation-org-b"; +const ORGANIZATION_SPACE_ID = "navigation-org-space"; +const ORGANIZATION_DATABASE_ID = "navigation-org-files"; +const ORGANIZATION_FILES_DOCUMENT_ID = "navigation-org-files-document"; type Schema = typeof import("../server/db/schema.js"); let getDb: () => any; @@ -35,6 +44,14 @@ beforeAll(async () => { ).default; const plugin = (await import("../server/plugins/db.js")).default; await plugin(undefined as any); + await getDbExec().execute(`CREATE TABLE IF NOT EXISTS organizations ( + id TEXT PRIMARY KEY, name TEXT NOT NULL, created_by TEXT NOT NULL, created_at BIGINT NOT NULL, + identity_authority TEXT, identity_id TEXT + )`); + await getDbExec().execute(`CREATE TABLE IF NOT EXISTS org_members ( + id TEXT PRIMARY KEY, org_id TEXT NOT NULL, email TEXT NOT NULL, role TEXT NOT NULL, joined_at BIGINT NOT NULL, + federation_removal_pending_at BIGINT + )`); const now = new Date().toISOString(); await getDb().insert(schema.documents).values({ @@ -67,6 +84,61 @@ beforeAll(async () => { createdAt: now, updatedAt: now, }); + + for (const [id, name] of [ + [ORGANIZATION_A_ID, "Navigation Org A"], + [ORGANIZATION_B_ID, "Navigation Org B"], + ]) { + await getDbExec().execute({ + sql: "INSERT INTO organizations (id, name, created_by, created_at) VALUES ($1, $2, $3, $4)", + args: [id, name, ORGANIZATION_OTHER, Date.now()], + }); + } + for (const orgId of [ORGANIZATION_A_ID, ORGANIZATION_B_ID]) { + await getDbExec().execute({ + sql: "INSERT INTO org_members (id, org_id, email, role, joined_at) VALUES ($1, $2, $3, $4, $5)", + args: [ + `navigation-member-${orgId}`, + orgId, + ORGANIZATION_MEMBER, + "member", + Date.now(), + ], + }); + } + await getDb().insert(schema.documents).values({ + id: ORGANIZATION_FILES_DOCUMENT_ID, + spaceId: ORGANIZATION_SPACE_ID, + ownerEmail: ORGANIZATION_OTHER, + orgId: ORGANIZATION_B_ID, + title: "Organization Files", + content: "", + visibility: "org", + createdAt: now, + updatedAt: now, + }); + await getDb().insert(schema.contentSpaces).values({ + id: ORGANIZATION_SPACE_ID, + name: "Organization Navigation", + kind: "organization", + ownerEmail: ORGANIZATION_OTHER, + orgId: ORGANIZATION_B_ID, + filesDatabaseId: ORGANIZATION_DATABASE_ID, + createdBy: ORGANIZATION_OTHER, + createdAt: now, + updatedAt: now, + }); + await getDb().insert(schema.contentDatabases).values({ + id: ORGANIZATION_DATABASE_ID, + spaceId: ORGANIZATION_SPACE_ID, + ownerEmail: ORGANIZATION_OTHER, + orgId: ORGANIZATION_B_ID, + documentId: ORGANIZATION_FILES_DOCUMENT_ID, + title: "Organization Files", + systemRole: "files", + createdAt: now, + updatedAt: now, + }); }, 60_000); afterAll(() => { @@ -111,6 +183,40 @@ async function addFile(args: { }); } +async function addOrganizationFile(args: { + id: string; + parentId?: string | null; + visibility?: "private" | "org"; +}) { + const now = new Date().toISOString(); + await getDb() + .insert(schema.documents) + .values({ + id: args.id, + spaceId: ORGANIZATION_SPACE_ID, + parentId: args.parentId ?? null, + ownerEmail: ORGANIZATION_OTHER, + orgId: ORGANIZATION_B_ID, + title: args.id, + content: "", + visibility: args.visibility ?? "org", + createdAt: now, + updatedAt: now, + }); + await getDb() + .insert(schema.contentDatabaseItems) + .values({ + id: `membership-${args.id}`, + ownerEmail: ORGANIZATION_OTHER, + orgId: ORGANIZATION_B_ID, + databaseId: ORGANIZATION_DATABASE_ID, + documentId: args.id, + position: 0, + createdAt: now, + updatedAt: now, + }); +} + async function navigate( navigation: { parentId: string | null; @@ -128,6 +234,79 @@ async function navigate( } describe("query-content-database-items Files navigation", () => { + it.each([50, 100, 500])( + "keeps %i roots bounded to stable 20-row pages", + async (rootCount) => { + const prefix = `bounded-${rootCount}`; + const ids = Array.from( + { length: rootCount }, + (_, index) => `${prefix}-${String(index).padStart(3, "0")}`, + ); + const timestamp = "2026-01-01T00:00:00.000Z"; + for (let offset = 0; offset < ids.length; offset += 100) { + const batch = ids.slice(offset, offset + 100); + await getDb() + .insert(schema.documents) + .values( + batch.map((id, index) => ({ + id, + spaceId: SPACE_ID, + parentId: null, + ownerEmail: OWNER, + title: id, + content: "", + visibility: "private" as const, + position: offset + index, + createdAt: timestamp, + updatedAt: timestamp, + })), + ); + await getDb() + .insert(schema.contentDatabaseItems) + .values( + batch.map((id, index) => ({ + id: `membership-${id}`, + ownerEmail: OWNER, + databaseId: DATABASE_ID, + documentId: id, + position: offset + index, + createdAt: timestamp, + updatedAt: timestamp, + })), + ); + } + + const first = await navigate({ parentId: null }, 20); + expect(first.items.map((item) => item.documentId)).toEqual( + ids.slice(0, 20), + ); + expect(first.pagination.hasMore).toBe(true); + + const second = await navigate( + { parentId: null, cursor: first.pagination.nextCursor! }, + 20, + ); + expect(second.items.map((item) => item.documentId)).toEqual( + ids.slice(20, 40), + ); + expect(second.pagination.hasMore).toBe(true); + + const repeatedSecond = await navigate( + { parentId: null, cursor: first.pagination.nextCursor! }, + 20, + ); + expect(repeatedSecond).toEqual(second); + + await getDb() + .delete(schema.contentDatabaseItems) + .where(inArray(schema.contentDatabaseItems.documentId, ids)); + await getDb() + .delete(schema.documents) + .where(inArray(schema.documents.id, ids)); + }, + 60_000, + ); + it("pages roots and immediate children without cross-parent or inaccessible leakage", async () => { for (let index = 0; index < 22; index += 1) { await addFile({ @@ -711,6 +890,81 @@ describe("query-content-database-items Files navigation", () => { expect(context.path.at(-1)).toMatchObject({ databaseId: DATABASE_ID }); }); + it("returns the authoritative Files database for the Files document itself", async () => { + const context = await runWithRequestContext({ userEmail: OWNER }, () => + navigationContextAction.run({ id: "navigation-files-document" }), + ); + + expect(context.workspaceFilesDatabaseId).toBe(DATABASE_ID); + expect(context.path).toHaveLength(1); + expect(context.path[0]).toMatchObject({ + id: "navigation-files-document", + databaseId: DATABASE_ID, + databaseDocumentId: "navigation-files-document", + }); + }); + + it("resolves an authorized organization Files root and child independently of the active organization", async () => { + await addOrganizationFile({ id: "organization-child" }); + + for (const orgId of [ORGANIZATION_A_ID, undefined]) { + const filesContext = await runWithRequestContext( + { userEmail: ORGANIZATION_MEMBER, orgId }, + () => + navigationContextAction.run({ id: ORGANIZATION_FILES_DOCUMENT_ID }), + ); + expect(filesContext.workspaceFilesDatabaseId).toBe( + ORGANIZATION_DATABASE_ID, + ); + expect(filesContext.path.map((entry) => entry.id)).toEqual([ + ORGANIZATION_FILES_DOCUMENT_ID, + ]); + + const childContext = await runWithRequestContext( + { userEmail: ORGANIZATION_MEMBER, orgId }, + () => navigationContextAction.run({ id: "organization-child" }), + ); + expect(childContext.workspaceFilesDatabaseId).toBe( + ORGANIZATION_DATABASE_ID, + ); + expect(childContext.path.map((entry) => entry.id)).toEqual([ + "organization-child", + ]); + } + }); + + it("denies Files navigation without selected-space membership", async () => { + await expect( + runWithRequestContext({ userEmail: ORGANIZATION_DENIED }, () => + navigationContextAction.run({ id: "organization-child" }), + ), + ).rejects.toMatchObject({ statusCode: 404 }); + }); + + it("does not disclose a restricted ancestor from an authorized organization space", async () => { + await addOrganizationFile({ + id: "restricted-organization-parent", + visibility: "private", + }); + await addOrganizationFile({ + id: "organization-child-with-restricted-parent", + parentId: "restricted-organization-parent", + }); + + const context = await runWithRequestContext( + { userEmail: ORGANIZATION_MEMBER, orgId: ORGANIZATION_A_ID }, + () => + navigationContextAction.run({ + id: "organization-child-with-restricted-parent", + }), + ); + + expect(context.workspaceFilesDatabaseId).toBe(ORGANIZATION_DATABASE_ID); + expect(context.path.map((entry) => entry.id)).toEqual([ + "organization-child-with-restricted-parent", + ]); + }); + it("associates a child without denormalized spaceId to its authoritative Files path", async () => { await addFile({ id: "space-less-parent", position: 300 }); await addFile({ diff --git a/templates/content/actions/update-content-database-personal-view.test.ts b/templates/content/actions/update-content-database-personal-view.test.ts index 8b46e77136e..a2f619eda92 100644 --- a/templates/content/actions/update-content-database-personal-view.test.ts +++ b/templates/content/actions/update-content-database-personal-view.test.ts @@ -13,6 +13,59 @@ import action, { } from "./update-content-database-personal-view"; describe("update content database personal view", () => { + it("rejects oversized or malformed sidebar order ids on full override saves", () => { + const overrides = { + version: PERSONAL_DATABASE_VIEW_OVERRIDES_VERSION, + views: [ + { + id: "table", + sorts: [], + filters: [], + filterMode: "and", + sidebarOrder: { + mode: "custom", + itemIds: Array.from( + { length: 5_001 }, + (_, index) => `item-${index}`, + ), + }, + }, + ], + }; + const oversized = action.schema.safeParse({ + databaseId: "database", + overrides, + }); + expect(oversized.success).toBe(false); + if (!oversized.success) { + expect(oversized.error.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "too_big", + path: ["overrides", "views", 0, "sidebarOrder", "itemIds"], + }), + ]), + ); + } + + overrides.views[0]!.sidebarOrder.itemIds = ["x".repeat(257)]; + const malformed = action.schema.safeParse({ + databaseId: "database", + overrides, + }); + expect(malformed.success).toBe(false); + if (!malformed.success) { + expect(malformed.error.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "too_big", + path: ["overrides", "views", 0, "sidebarOrder", "itemIds", 0], + }), + ]), + ); + } + }); + it("accepts grouped filter overrides for the current user", () => { const parsed = action.schema.parse({ databaseId: "database", diff --git a/templates/content/actions/update-content-database-personal-view.ts b/templates/content/actions/update-content-database-personal-view.ts index 2dbd3074b9f..9bca4ad3bb2 100644 --- a/templates/content/actions/update-content-database-personal-view.ts +++ b/templates/content/actions/update-content-database-personal-view.ts @@ -102,7 +102,12 @@ export default defineAction({ const requestedItemIds = navigation.sidebarOrder ? navigation.sidebarOrder.operation === "replace" ? navigation.sidebarOrder.itemIds - : [navigation.sidebarOrder.itemId] + : navigation.sidebarOrder.operation === "reorder-subset" + ? [ + ...navigation.sidebarOrder.itemIds, + ...navigation.sidebarOrder.previousItemIds, + ] + : [navigation.sidebarOrder.itemId] : []; const validItemIds = new Set(); for (const itemIds of chunks( @@ -150,7 +155,12 @@ export default defineAction({ ? navigation.sidebarOrder : null; const incrementalOrder = - navigation.sidebarOrder?.operation !== "replace" + navigation.sidebarOrder?.operation === "prepend" || + navigation.sidebarOrder?.operation === "remove" + ? navigation.sidebarOrder + : null; + const subsetOrder = + navigation.sidebarOrder?.operation === "reorder-subset" ? navigation.sidebarOrder : null; const patch = replaceOrder @@ -173,7 +183,15 @@ export default defineAction({ statusCode: 404, errorCode: "item_unavailable", }) - : navigation; + : subsetOrder && + [...subsetOrder.itemIds, ...subsetOrder.previousItemIds].some( + (id) => !validItemIds.has(id), + ) + ? fail("A reordered sidebar item is unavailable.", { + statusCode: 409, + errorCode: "item_unavailable", + }) + : navigation; const value = { ...applyContentPersonalNavigationPatch(migrated, patch, config.views), }; diff --git a/templates/content/actions/update-content-sidebar-state.ts b/templates/content/actions/update-content-sidebar-state.ts index 2dfb1164516..382329ead12 100644 --- a/templates/content/actions/update-content-sidebar-state.ts +++ b/templates/content/actions/update-content-sidebar-state.ts @@ -1,5 +1,6 @@ import { defineAction } from "@agent-native/core/action"; import { mutateUserSetting } from "@agent-native/core/settings"; +import { z } from "zod"; import { CONTENT_SIDEBAR_STATE_SETTING_KEY, @@ -9,19 +10,22 @@ import { export default defineAction({ description: "Persist the current user's Content sidebar expansion state.", - schema: contentSidebarStateSchema.partial().required({ version: true }), + schema: contentSidebarStateSchema + .partial() + .required({ version: true }) + .extend({ spaceId: z.string().min(1).max(256).optional() }), agentTool: false, - run: async (state, ctx) => { + run: async ({ spaceId, ...state }, ctx) => { if (!ctx?.userEmail) throw new Error("Not authenticated."); const saved = await mutateUserSetting( ctx.userEmail, CONTENT_SIDEBAR_STATE_SETTING_KEY, (current) => normalizeContentSidebarState({ - ...normalizeContentSidebarState(current), + ...normalizeContentSidebarState(current, spaceId), ...state, })!, ); - return { state: normalizeContentSidebarState(saved) }; + return { state: normalizeContentSidebarState(saved, spaceId) }; }, }); diff --git a/templates/content/app/components/editor/database/sidebar.tsx b/templates/content/app/components/editor/database/sidebar.tsx index f1df957feba..e7f6439981a 100644 --- a/templates/content/app/components/editor/database/sidebar.tsx +++ b/templates/content/app/components/editor/database/sidebar.tsx @@ -28,6 +28,7 @@ import { useEffect, useState, type MouseEvent, type ReactNode } from "react"; import { Link } from "react-router"; import { documentSidebarActionAvailability } from "@/components/sidebar/document-sidebar-actions"; +import { SidebarNavigationRow } from "@/components/sidebar/SidebarNavigationRow"; import { Button } from "@/components/ui/button"; import { Collapsible, @@ -435,6 +436,7 @@ export function ContentFilesSidebarView({ labels, onSelectView, sidebarOrder, + serverOrdered = false, manualReorder, onOpenItem, onCreateChildPage, @@ -453,6 +455,7 @@ export function ContentFilesSidebarView({ onSelectView?: (viewId: string) => void; /** A parent-owned, user-scoped Files order. It never writes database membership. */ sidebarOrder?: ContentSidebarViewOrder; + serverOrdered?: boolean; manualReorder?: ContentFilesSidebarManualReorder; onOpenItem?: (item: ContentDatabaseItem) => boolean; onCreateChildPage?: (item: ContentDatabaseItem) => void; @@ -513,9 +516,10 @@ export function ContentFilesSidebarView({ activeView.filterMode ?? "and", ) : []; - const items = sidebarOrder - ? contentSidebarOrderedItems(filteredItems, sidebarOrder) - : filteredItems; + const items = + sidebarOrder && !serverOrdered + ? contentSidebarOrderedItems(filteredItems, sidebarOrder) + : filteredItems; const groups = databaseVisibleGroups( databaseViewItemGroups( items, @@ -1070,14 +1074,15 @@ function DatabaseSidebarRow({ /> ) : null} - event.currentTarget.blur()} aria-current={active ? "page" : undefined} > - {title} - + {(hasMenuActions || canCreateChild) && (
diff --git a/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts b/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts index 928205b71bf..19cbd402a87 100644 --- a/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts +++ b/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts @@ -278,116 +278,39 @@ describe("document sidebar layout", () => { expect(sidebar).toContain("navigate(previousPath, {"); }); - it("keeps independently expanded Files lists beneath their workspaces", () => { + it("renders one selected Content space with a shallow Files tree", () => { const sidebar = readSidebarSource("./DocumentSidebar.tsx"); - expect(sidebar).toContain("aria-expanded={expanded}"); expect(sidebar).toContain('"get-content-sidebar-state"'); expect(sidebar).toContain('"update-content-sidebar-state"'); - expect(sidebar).toContain( - "stored?.expandedWorkspaceIds ?? contentSpaces.map", - ); expect(sidebar).toContain( "expandedDocumentIds={visibleExpandedDocumentIds}", ); expect(sidebar).toContain( "new Set([...expandedDocumentIds, ...activeAncestorIds])", ); - expect(sidebar).toContain("toggleExpandedWorkspaceIds(current, space.id)"); - expect(sidebar).toContain("ensureWorkspaceExpanded(current, space.id)"); - expect(sidebar).not.toContain( - "if (!selectedSpace || !sidebarStateHydratedRef.current) return", - ); expect(sidebar).toContain("createContentSidebarStateWriteQueue"); - expect(sidebar).not.toContain("sidebarStateWriteTimerRef"); expect(sidebar).toContain( 'toast.error(t("sidebar.failedSaveSidebarState")', ); - expect(sidebar).toContain( - '"group/workspace-header flex h-7 w-full min-w-0 items-center rounded-md"', - ); - expect(sidebar).toContain("group-hover/workspace-header:opacity-100"); - expect(sidebar).toContain( - "group-focus-visible/workspace-toggle:opacity-100", - ); - expect(sidebar).toContain( - "group-focus-within/workspace-header:opacity-100", - ); - expect(sidebar).not.toContain('className="group/workspace min-w-0"'); - expect(sidebar).toContain("{expanded ? ("); expect(sidebar).toContain(""); - expect(sidebar).toContain("{space.name}', - ); - expect(sidebar).toContain('className="min-w-0 pb-1 ps-4"'); - expect(sidebar).not.toContain( - 'className="ms-3 border-s border-border/70 pb-1 ps-1"', + "renderWorkspaceRoot(selectedSpace, undefined, true)", ); - expect(sidebar).toContain('role="link"'); - expect(sidebar).toContain( - 'aria-label={`${t("sidebar.newPage")} — ${space.name}`}', - ); - expect(sidebar).toContain("selected={selectedSpace?.id === space.id}"); - expect(sidebar).toContain("onOpenItem={(item: ContentDatabaseItem) =>"); + expect(sidebar).toContain("value={selectedSpace.id}"); + expect(sidebar).toContain("contentSpaces.map((space)"); + expect(sidebar).toContain(''); expect(sidebar).toContain("void handleSelectContentSpace(space, null)"); - expect(sidebar).toContain( - "await handleCreatePage(undefined, space.id, id, space.filesDatabaseId)", - ); - expect(sidebar).toContain("activeDocumentId={activeDocumentId}"); - expect(sidebar).toContain("onCreateChildPage={(nextSpace, item) =>"); - expect(sidebar).toContain("onDeleteItem={(item) =>"); - expect(sidebar).toContain("onToggleFavorite={(item) =>"); - expect(sidebar).toContain( - "applyOptimisticItemToContentDatabase(current, optimisticItem)", - ); - expect(sidebar).not.toContain("', - ); expect(sidebar).toContain( 'import { OrgSwitcher } from "@agent-native/core/client/org";', ); expect(sidebar).toContain(""); - expect(sidebar).not.toContain(""); - expect(sidebar.indexOf("")).toBeLessThan( - sidebar.indexOf("{/* Footer */}"), - ); expect(sidebar).toContain('t("sidebar.addWorkspace")'); expect(sidebar).toContain(""); - expect(sidebar).toContain("name: item.document.title || space.name"); expect(sidebar).toContain("scroll={false}"); - expect(sidebar.match(/scroll=\{false\}/g)).toHaveLength(3); }); it("never empties the Files tree while a deferred database read is paused", () => { @@ -410,6 +333,19 @@ describe("document sidebar layout", () => { expect(hooks).toContain( "isContentDatabaseByIdQueryEnabled(databaseId, options)", ); + expect(sidebar).toContain("const previouslyExpanded = useRef(false)"); + }); + + it("waits for a selected Content space before mounting scoped sidebar queries", () => { + const sidebar = readSidebarSource("./DocumentSidebar.tsx"); + const sections = readSidebarSource("./PersonalSidebarSections.tsx"); + + expect(sidebar).toContain("contentSpaceActionArgs(selectedSpace?.id)"); + expect(sidebar).toContain("enabled: Boolean(sidebarStateArgs)"); + expect(sidebar).toContain("{selectedSpace ? ("); + expect(sidebar).toContain("spaceId={selectedSpace.id}"); + expect(sidebar).toContain('t("sidebar.contentSpace")'); + expect(sections).toContain("contentSpaceActionArgs(spaceId)"); }); it("uses the full row width until right-side actions are revealed", () => { @@ -533,26 +469,34 @@ describe("document sidebar layout", () => { const sections = readSidebarSource("./PersonalSidebarSections.tsx"); expect(sidebar).toContain(""); expect(sections).toContain("sections[id].visible"); expect(sections).toContain("expanded={sections[id].expanded}"); expect(sections).toContain( - "onToggle={() => change(id, { expanded: !sections[id].expanded })}", + "change(id, { expanded: !sections[id].expanded });", ); expect(sections).toContain("aria-expanded={expanded}"); expect(sections).toContain('expanded && "rotate-90"'); - expect(sections).toContain("renderPinned(sections.pinned.limit)"); + expect(sections).toContain("IconGripVertical"); + expect(sections).toContain("group-hover/toggle:opacity-0"); + expect(sections).toContain("group-focus-visible/toggle:opacity-100"); + expect(sections).toContain(" item.id)"); + expect(sidebar).toContain("contentSidebarSubsetReorder("); expect(sidebar).toContain( "flex h-7 w-full min-w-0 items-center rounded-md px-1", ); diff --git a/templates/content/app/components/sidebar/DocumentSidebar.tsx b/templates/content/app/components/sidebar/DocumentSidebar.tsx index b70d2967cbc..f64b230d70c 100644 --- a/templates/content/app/components/sidebar/DocumentSidebar.tsx +++ b/templates/content/app/components/sidebar/DocumentSidebar.tsx @@ -132,7 +132,10 @@ import { } from "./local-source-hierarchy"; import { PersonalSidebarSections } from "./PersonalSidebarSections"; import { + contentSpaceActionArgs, contentSpaceAvailability, + contentSpaceRouteReconciliation, + contentSidebarSubsetReorder, contentSpaceForStoredSelection, createContentSidebarStateWriteQueue, createContentSpaceSelectionQueue, @@ -218,10 +221,11 @@ const SIDEBAR_SECTION_COLLAPSE_STORAGE_KEY = "content-sidebar-collapsed-sections"; const TRASH_COLLAPSED_DEFAULT_MIGRATION_KEY = "content-sidebar-trash-collapsed-default-v2"; -const CONTENT_SIDEBAR_STATE_VERSION = 1 as const; +const CONTENT_SIDEBAR_STATE_VERSION = 2 as const; interface ContentSidebarStateSnapshot { version: typeof CONTENT_SIDEBAR_STATE_VERSION; + spaceId?: string; expandedWorkspaceIds?: string[]; expandedDocumentIds?: string[]; } @@ -316,7 +320,7 @@ function useDeferredFilesDatabaseId( expanded: boolean, deferUntilDocumentId: string | null, ) { - const previouslyExpanded = useRef(expanded); + const previouslyExpanded = useRef(false); const [ready, setReady] = useState(false); useEffect(() => { @@ -401,6 +405,7 @@ function WorkspaceSidebarItem({ onCreateChildDatabase, onDeleteItem, onToggleFavorite, + compact = false, }: { space: ContentSpaceSummary; selected: boolean; @@ -427,6 +432,7 @@ function WorkspaceSidebarItem({ ) => void; onDeleteItem: (item: ContentDatabaseItem) => void; onToggleFavorite: (item: ContentDatabaseItem) => void; + compact?: boolean; }) { const t = useT(); const [localWorkingCopies, setLocalWorkingCopies] = useState< @@ -632,119 +638,125 @@ function WorkspaceSidebarItem({ return (
-
- - { - if ( - !event.metaKey && - !event.ctrlKey && - !event.shiftKey && - !event.altKey - ) { - event.preventDefault(); - onActivate(space); - } - }} > - {space.name} - - {expanded ? ( - - - - - - - - - {t("sidebar.orderButton", { - order: sidebarOrderModeLabels[sidebarOrder.mode], - })} - - - - - updateSidebarOrder({ - ...sidebarOrder, - mode: value as ContentSidebarViewOrder["mode"], - }) - } - > - {( - Object.keys( - sidebarOrderModeLabels, - ) as ContentSidebarViewOrder["mode"][] - ).map((mode) => ( - - {sidebarOrderModeLabels[mode]} - - ))} - - - - ) : null} - -
+ + { + if ( + !event.metaKey && + !event.ctrlKey && + !event.shiftKey && + !event.altKey + ) { + event.preventDefault(); + onActivate(space); + } + }} + > + {space.name} + + {expanded ? ( + + + + + + + + + {t("sidebar.orderButton", { + order: sidebarOrderModeLabels[sidebarOrder.mode], + })} + + + + + updateSidebarOrder({ + ...sidebarOrder, + mode: value as ContentSidebarViewOrder["mode"], + }) + } + > + {( + Object.keys( + sidebarOrderModeLabels, + ) as ContentSidebarViewOrder["mode"][] + ).map((mode) => ( + + {sidebarOrderModeLabels[mode]} + + ))} + + + + ) : null} + +
+ )} {expanded ? ( -
+
{relatedWorkingCopies.some((folder) => folder.kind === "temporary") && selectedWorkingCopy && hasRelatedLocalFiles ? ( @@ -871,7 +883,9 @@ function WorkspaceSidebarItem({ expandedDocumentIds={expandedDocumentIds} onDocumentExpandedChange={onDocumentExpandedChange} documentMetadata={documentMetadata} - activePathDocuments={activePathDocuments} + activePathDocuments={activePathDocuments.filter( + (document) => document.id !== space.filesDocumentId, + )} onOpenItem={(item) => { if (selected) return false; onActivate(space, item.document.id); @@ -944,13 +958,24 @@ export function DocumentSidebar({ const updateDocument = useUpdateDocument(); const ensureContentSpaces = useEnsureContentSpaces(); const workspaceSelectionQueueRef = useRef(createContentSpaceSelectionQueue()); + const explicitSpaceSelectionRef = useRef(null); const contentSpaces = contentSpacesQuery.data?.spaces ?? []; + const [storedSpaceId, setStoredSpaceId] = useLocalStorage( + SELECTED_CONTENT_SPACE_STORAGE_KEY, + null, + { syncAcrossTabs: false }, + ); + const selectedSpace = contentSpaceForStoredSelection({ + spaces: contentSpaces, + storedSpaceId, + }); const workspaceCatalogDatabaseId = contentSpacesQuery.data?.catalogDatabaseId ?? null; const favoritesDatabaseId = contentSpacesQuery.data?.favoritesDatabaseId ?? null; const favoritesDatabase = useContentDatabaseById(favoritesDatabaseId, { limit: 50, + contentSpaceId: selectedSpace?.id, }); const workspaceCatalogDatabase = useContentDatabaseById( workspaceCatalogDatabaseId, @@ -1038,20 +1063,17 @@ export function DocumentSidebar({ ensureContentSpaces.isPending, spaceReconciliationRetryNonce, ]); - const [storedSpaceId, setStoredSpaceId] = useLocalStorage( - SELECTED_CONTENT_SPACE_STORAGE_KEY, - null, + const sidebarStateArgs = contentSpaceActionArgs(selectedSpace?.id); + const sidebarStateQuery = useActionQuery( + "get-content-sidebar-state", + sidebarStateArgs, + { enabled: Boolean(sidebarStateArgs) }, ); - const selectedSpace = contentSpaceForStoredSelection({ - spaces: contentSpaces, - storedSpaceId, - }); - const sidebarStateQuery = useActionQuery("get-content-sidebar-state", {}); const updateSidebarState = useActionMutation("update-content-sidebar-state", { skipActionQueryInvalidation: true, onSuccess: (data) => { queryClient.setQueryData( - ["action", "get-content-sidebar-state", {}], + ["action", "get-content-sidebar-state", sidebarStateArgs], data, ); }, @@ -1094,10 +1116,12 @@ export function DocumentSidebar({ ensureContentSpaces.mutate({}); }, [contentSpacesQuery, ensureContentSpaces]); useEffect(() => { - if (selectedSpace && selectedSpace.id !== storedSpaceId) { - setStoredSpaceId(selectedSpace.id); - } - }, [selectedSpace, setStoredSpaceId, storedSpaceId]); + sidebarStateHydratedRef.current = false; + expandedWorkspaceIdsRef.current = []; + expandedDocumentIdsRef.current = []; + setExpandedWorkspaceIds([]); + setExpandedDocumentIds([]); + }, [selectedSpace?.id]); useEffect(() => { if ( sidebarStateHydratedRef.current || @@ -1128,6 +1152,7 @@ export function DocumentSidebar({ void sidebarStateWriteQueueRef .current?.({ version: CONTENT_SIDEBAR_STATE_VERSION, + spaceId: selectedSpace?.id, expandedWorkspaceIds: workspaceIds, expandedDocumentIds: documentIds, }) @@ -1137,7 +1162,7 @@ export function DocumentSidebar({ }); }); }, - [t], + [selectedSpace?.id, t], ); const updateExpandedWorkspaceIds = useCallback( @@ -1215,6 +1240,9 @@ export function DocumentSidebar({ space: (typeof contentSpaces)[number], targetDocumentId?: string | null, ) => { + if (targetDocumentId === undefined) { + explicitSpaceSelectionRef.current = space.id; + } updateExpandedWorkspaceIds((current) => ensureWorkspaceExpanded(current, space.id), ); @@ -1370,6 +1398,30 @@ export function DocumentSidebar({ activeDocumentId ? { id: activeDocumentId } : undefined, { enabled: Boolean(activeDocumentId) }, ); + useEffect(() => { + const filesDatabaseId = + navigationContextQuery.data?.workspaceFilesDatabaseId ?? null; + const reconciliation = contentSpaceRouteReconciliation({ + activeDocumentId, + routeDocumentId: navigationContextQuery.data?.document.id, + routeFilesDatabaseId: filesDatabaseId, + selectedSpace, + explicitSpaceId: explicitSpaceSelectionRef.current, + spaces: contentSpaces, + }); + if (reconciliation.explicitSelectionReachedRoute) { + explicitSpaceSelectionRef.current = null; + } + if (reconciliation.routeSpace) { + void handleSelectContentSpace(reconciliation.routeSpace, null); + } + }, [ + activeDocumentId, + contentSpaces, + handleSelectContentSpace, + navigationContextQuery.data, + selectedSpace?.filesDatabaseId, + ]); const activeDocument = navigationContextQuery.data?.document ?? null; const trashItems = trashedDatabases?.databases ?? []; const trashedPageItems = trashedDocuments?.documents ?? []; @@ -1828,21 +1880,16 @@ export function DocumentSidebar({ }, []); const handlePinnedReorder = useCallback( - (itemIds: string[]) => { - if (!favoritesDatabaseId || !favoritesData) return; - const allIds = contentSidebarOrderedItems( - favoritesData.items, - favoritesOrder.order, - ).map((item) => item.id); - const order = { - mode: "custom" as const, - itemIds: [...itemIds, ...allIds.filter((id) => !itemIds.includes(id))], - }; + (itemIds: string[], previousItemIds: string[]) => { + if (!favoritesDatabaseId) return; updateFavoritesPersonalView.mutate( { databaseId: favoritesDatabaseId, navigation: { - sidebarOrder: { viewId: favoritesOrder.activeViewId, ...order }, + sidebarOrder: { + viewId: favoritesOrder.activeViewId, + ...contentSidebarSubsetReorder(itemIds, previousItemIds), + }, }, }, { onError: () => toast.error(t("sidebar.failedSaveOrder")) }, @@ -1850,7 +1897,6 @@ export function DocumentSidebar({ }, [ favoritesDatabaseId, - favoritesData, favoritesOrder, favoritesPersonalView.data?.overrides, updateFavoritesPersonalView, @@ -2082,6 +2128,52 @@ export function DocumentSidebar({ ); + const contentSpaceSelector = selectedSpace ? ( +
+ + + + + + { + const space = contentSpaces.find( + (candidate) => candidate.id === spaceId, + ); + if (space) void handleSelectContentSpace(space); + }} + > + {contentSpaces.map((space) => ( + + {space.name} + + ))} + + + {t("sidebar.pinned")} + + + + + + +
+ ) : null; const feedbackButton = ( ( ( -
+
{contentSpaceState === "ready" && selectedSpace ? (
- {workspaceCatalogDatabase.isError || - workspaceCatalogPersonalView.isError ? ( - { - void workspaceCatalogDatabase.refetch(); - void workspaceCatalogPersonalView.refetch(); - }} - retrying={ - workspaceCatalogDatabase.isFetching || - workspaceCatalogPersonalView.isFetching - } - /> - ) : ( - { - if (!workspaceCatalogDatabaseId) return; - const current = workspaceCatalogPersonalView.data?.overrides; - updateWorkspaceCatalogPersonalView.mutate({ - databaseId: workspaceCatalogDatabaseId, - overrides: { - version: - current?.version ?? - CONTENT_DATABASE_PERSONAL_VIEW_OVERRIDES_VERSION, - activeViewId: viewId, - views: current?.views ?? [], - }, - }); - }} - manualReorder={ - updateWorkspaceCatalogPersonalView.isPending - ? undefined - : { - labels: sidebarReorderLabels, - onReorder: handleWorkspaceReorder, - } - } - renderItem={(item, reorder) => { - const space = contentSpaces.find( - (candidate) => - candidate.catalogDocumentId === item.document.id, - ); - return space - ? renderWorkspaceRoot( - { - ...space, - name: item.document.title || space.name, - }, - reorder, - ) - : null; - }} - scroll={false} - labels={{ - noMatchesLabel: t("database.noRowsMatchThisView"), - clearLabel: t("database.clearSearchAndFilters"), - navigationLabel: "Content navigation", - untitledLabel: t("sidebar.untitled"), - }} - /> - )} -
- - - -
+ {renderWorkspaceRoot(selectedSpace, undefined, true)}
) : contentSpaceState === "loading" ? ( renderTreeSkeleton() @@ -2557,88 +2563,105 @@ export function DocumentSidebar({
{brandButton(false)}
+ {contentSpaceSelector}
{searchButton}
- - favoritesDatabase.isError || favoritesPersonalView.isError ? ( - { - void favoritesDatabase.refetch(); - void favoritesPersonalView.refetch(); - }} - /> - ) : ( - { - const space = contentSpaces.find( - (candidate) => - candidate.filesDatabaseId === - item.workspaceFilesDatabaseId, - ); - if (!space || selectedSpace?.id === space.id) { + {selectedSpace ? ( + { + const serverOrdered = favoritesOrder.order.mode !== "custom"; + const renderedItems = ( + serverOrdered + ? (favoritesData?.items ?? []) + : contentSidebarOrderedItems( + favoritesData?.items ?? [], + favoritesOrder.order, + ) + ).slice(0, limit); + return favoritesDatabase.isError || + favoritesPersonalView.isError ? ( + { + void favoritesDatabase.refetch(); + void favoritesPersonalView.refetch(); + }} + /> + ) : ( + + handlePinnedReorder( + itemIds, + renderedItems.map((item) => item.id), + ), + }} + onOpenItem={(item) => { + const space = contentSpaces.find( + (candidate) => + candidate.filesDatabaseId === + item.workspaceFilesDatabaseId, + ); + if (!space || selectedSpace?.id === space.id) { + onNavigate?.(); + return false; + } + void handleSelectContentSpace(space, item.document.id); onNavigate?.(); - return false; + return true; + }} + onCreateChildPage={(item) => + void handleCreatePage(item.document.id) } - void handleSelectContentSpace(space, item.document.id); - onNavigate?.(); - return true; - }} - onCreateChildPage={(item) => - void handleCreatePage(item.document.id) - } - onCreateChildDatabase={(item) => - void handleCreateDatabase(item.document.id) - } - onDeleteItem={(item) => - requestDelete( - item.document.id, - item.document.title || t("sidebar.untitled"), - ) - } - onToggleFavorite={(item) => - handleToggleFavorite(item.document.id, false) - } - scroll={false} - labels={{ - noMatchesLabel: t("database.noRowsMatchThisView"), - clearLabel: t("database.clearSearchAndFilters"), - navigationLabel: t("sidebar.pinned"), - untitledLabel: t("sidebar.untitled"), - }} - /> - ) - } - /> + onCreateChildDatabase={(item) => + void handleCreateDatabase(item.document.id) + } + onDeleteItem={(item) => + requestDelete( + item.document.id, + item.document.title || t("sidebar.untitled"), + ) + } + onToggleFavorite={(item) => + handleToggleFavorite(item.document.id, false) + } + scroll={false} + labels={{ + noMatchesLabel: t("database.noRowsMatchThisView"), + clearLabel: t("database.clearSearchAndFilters"), + navigationLabel: t("sidebar.pinned"), + untitledLabel: t("sidebar.untitled"), + }} + /> + ); + }} + /> + ) : null} {renderTrashSection()}
diff --git a/templates/content/app/components/sidebar/PersonalSidebarSections.tsx b/templates/content/app/components/sidebar/PersonalSidebarSections.tsx index df6a332fe26..36519df5710 100644 --- a/templates/content/app/components/sidebar/PersonalSidebarSections.tsx +++ b/templates/content/app/components/sidebar/PersonalSidebarSections.tsx @@ -10,10 +10,16 @@ import { type ContentSidebarSections, type ContentSidebarSectionId, } from "@shared/content-personal-navigation"; -import { IconChevronRight, IconDots } from "@tabler/icons-react"; +import { + IconChevronRight, + IconClock, + IconDots, + IconFiles, + IconGripVertical, + IconPin, +} from "@tabler/icons-react"; import { useQueryClient } from "@tanstack/react-query"; -import { useRef, useState, type ReactNode } from "react"; -import { Link } from "react-router"; +import { useEffect, useRef, useState, type ReactNode } from "react"; import { toast } from "sonner"; import { QueryErrorState } from "@/components/QueryErrorState"; @@ -31,31 +37,35 @@ import { Skeleton } from "@/components/ui/skeleton"; import { useContentRecent } from "@/hooks/use-content-recent"; import { cn } from "@/lib/utils"; +import { contentSpaceActionArgs } from "./select-content-space"; import { SidebarReorderProvider, useSidebarReorderItem, type SidebarReorderLabels, } from "./sidebar-reorder"; - -const stateKey = ["action", "get-content-sidebar-state", {}]; +import { SidebarNavigationRow } from "./SidebarNavigationRow"; export function PersonalSidebarSections({ renderPinned, pinnedCount, - renderWorkspaces, + renderFiles, + spaceId, onNavigate, reorderLabels, }: { renderPinned: (limit: number) => ReactNode; pinnedCount: number; - renderWorkspaces: () => ReactNode; + renderFiles: () => ReactNode; + spaceId: string; onNavigate?: () => void; reorderLabels: SidebarReorderLabels; }) { const t = useT(); const queryClient = useQueryClient(); - const state = useActionQuery("get-content-sidebar-state", {}); - const recent = useContentRecent(); + const stateArgs = contentSpaceActionArgs(spaceId); + const stateKey = ["action", "get-content-sidebar-state", stateArgs]; + const state = useActionQuery("get-content-sidebar-state", stateArgs); + const recent = useContentRecent(spaceId); const update = useActionMutation("update-content-sidebar-state", { skipActionQueryInvalidation: true, }); @@ -64,6 +74,8 @@ export function PersonalSidebarSections({ ); const pending = useRef(0); const queue = useRef>(Promise.resolve()); + const [limits, setLimits] = useState({ pinned: 5, recent: 5 }); + useEffect(() => setLimits({ pinned: 5, recent: 5 }), [spaceId]); const sections = optimistic ?? state.data?.state?.sections ?? @@ -76,7 +88,8 @@ export function PersonalSidebarSections({ .then(async () => { try { const saved = await update.mutateAsync({ - version: 1, + version: 2, + spaceId, sections: next, }); queryClient.setQueryData(stateKey, saved); @@ -92,7 +105,7 @@ export function PersonalSidebarSections({ void queue.current.catch(() => undefined); } function change( - id: "pinned" | "recent", + id: ContentSidebarSectionId, patch: Partial, ) { save({ ...sections, [id]: { ...sections[id], ...patch } }); @@ -100,26 +113,26 @@ export function PersonalSidebarSections({ const labels = { pinned: t("sidebar.pinned"), recent: t("sidebar.recent"), - workspaces: t("sidebar.workspaces"), + files: t("sidebar.files"), }; function canShowMore(id: "pinned" | "recent") { const count = id === "pinned" ? pinnedCount : (recent.data?.entries.length ?? 0); - if (sections[id].limit >= 50) return false; - return count > sections[id].limit; + if (limits[id] >= 50) return false; + return count > limits[id]; } if (state.isError) return ( <> void state.refetch()} /> - {renderWorkspaces()} + {renderFiles()} ); if (state.isLoading) return ( <> - {renderWorkspaces()} + {renderFiles()} ); return ( @@ -136,11 +149,15 @@ export function PersonalSidebarSections({ } > {sections.order.map((id) => - id === "workspaces" ? ( + id === "files" ? ( + change("files", { expanded: !sections.files.expanded }) + } reorderLabels={reorderLabels} sections={sections} labels={labels} @@ -148,7 +165,7 @@ export function PersonalSidebarSections({ change(sectionId, { visible }) } > - {renderWorkspaces()} + {sections.files.expanded ? renderFiles() : null} ) : sections[id].visible ? ( change(id, { expanded: !sections[id].expanded })} + onToggle={() => { + if (sections[id].expanded) { + setLimits((current) => ({ ...current, [id]: 5 })); + } + change(id, { expanded: !sections[id].expanded }); + }} reorderLabels={reorderLabels} sections={sections} labels={labels} @@ -167,7 +189,7 @@ export function PersonalSidebarSections({ {sections[id].expanded && ( <> {id === "pinned" ? ( - renderPinned(sections.pinned.limit) + renderPinned(limits.pinned) ) : recent.isError ? ( ) : recent.data?.entries.length ? ( -