From 05bddc4cd2d44268173af6c9b3c784f91cd4c2dd Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:49:03 -0400 Subject: [PATCH 01/76] feat(content): unify workspaces around database-backed files --- .changeset/calm-files-share.md | 5 + .../core/docs/content/local-file-mode.mdx | 588 +++------------- .../content/locales/ar-SA/local-file-mode.mdx | 525 +------------- .../locales/ar-SA/template-content.mdx | 34 +- .../content/locales/de-DE/local-file-mode.mdx | 528 +-------------- .../locales/de-DE/template-content.mdx | 34 +- .../content/locales/es-ES/local-file-mode.mdx | 528 +-------------- .../locales/es-ES/template-content.mdx | 34 +- .../content/locales/fr-FR/local-file-mode.mdx | 529 ++------------- .../locales/fr-FR/template-content.mdx | 34 +- .../content/locales/hi-IN/local-file-mode.mdx | 525 +------------- .../locales/hi-IN/template-content.mdx | 34 +- .../content/locales/ja-JP/local-file-mode.mdx | 528 +-------------- .../locales/ja-JP/template-content.mdx | 34 +- .../content/locales/ko-KR/local-file-mode.mdx | 525 +------------- .../locales/ko-KR/template-content.mdx | 34 +- .../content/locales/pt-BR/local-file-mode.mdx | 528 +-------------- .../locales/pt-BR/template-content.mdx | 34 +- .../content/locales/zh-CN/local-file-mode.mdx | 520 +------------- .../locales/zh-CN/template-content.mdx | 33 +- .../content/locales/zh-TW/local-file-mode.mdx | 520 +------------- .../locales/zh-TW/template-content.mdx | 33 +- .../core/docs/content/template-content.mdx | 68 +- packages/core/src/cli/content-local.spec.ts | 56 +- packages/core/src/cli/content-local.ts | 69 +- packages/core/src/cli/index.ts | 4 +- .../core/src/local-artifacts/index.spec.ts | 47 ++ packages/core/src/local-artifacts/index.ts | 65 ++ .../content/.agents/skills/content/SKILL.md | 65 +- templates/content/AGENTS.md | 55 +- .../content/actions/_builder-docs-client.ts | 23 + .../_content-database-source-adapters.test.ts | 5 +- .../_content-database-source-adapters.ts | 24 +- templates/content/actions/_content-files.ts | 320 +++++++++ .../content/actions/_content-space-access.ts | 135 ++++ templates/content/actions/_content-spaces.ts | 613 +++++++++++++++++ .../content/actions/_database-source-utils.ts | 89 ++- templates/content/actions/_database-utils.ts | 8 +- .../actions/_local-file-documents.test.ts | 73 +- .../content/actions/_local-file-documents.ts | 9 - .../actions/_local-folder-source.test.ts | 40 ++ .../content/actions/_local-folder-source.ts | 53 ++ templates/content/actions/_property-utils.ts | 13 +- .../content/actions/add-database-item.ts | 172 +++-- .../content/actions/backfill-content-files.ts | 22 + .../actions/connect-local-folder-source.ts | 221 ++++++ .../content/actions/content-files.db.test.ts | 272 ++++++++ .../content/actions/content-spaces.db.test.ts | 256 +++++++ .../actions/create-content-database.ts | 126 +++- .../content/actions/create-document.test.ts | 53 -- templates/content/actions/create-document.ts | 117 ++-- .../actions/create-inline-content-database.ts | 7 +- .../database-row-batch-actions.db.test.ts | 35 + .../actions/delete-content-database.ts | 3 + templates/content/actions/delete-document.ts | 18 +- .../actions/disconnect-local-folder-source.ts | 103 +++ .../actions/duplicate-database-item.ts | 10 + .../actions/duplicate-database-items.ts | 13 + templates/content/actions/edit-document.ts | 30 +- .../content/actions/ensure-content-spaces.ts | 26 + .../content/actions/export-content-source.ts | 56 +- templates/content/actions/get-document.ts | 14 - .../actions/import-content-source.db.test.ts | 19 +- .../content/actions/import-content-source.ts | 44 +- .../content/actions/list-content-spaces.ts | 103 +++ templates/content/actions/list-documents.ts | 21 +- .../actions/local-folder-source.db.test.ts | 467 +++++++++++++ .../content/actions/move-document.db.test.ts | 12 + templates/content/actions/move-document.ts | 17 +- templates/content/actions/pull-document.ts | 23 - .../actions/remove-local-file-source.ts | 79 ++- .../actions/resolve-local-folder-conflict.ts | 295 ++++++++ .../resync-content-database-source.db.test.ts | 36 + .../actions/reveal-local-source-file.ts | 23 +- templates/content/actions/search-documents.ts | 34 - .../actions/share-local-file-document.ts | 26 +- .../actions/space-aware-writers.db.test.ts | 212 ++++++ .../actions/split-mode-retirement.test.ts | 38 ++ .../submit-content-database-form.db.test.ts | 33 + .../actions/submit-content-database-form.ts | 12 + .../actions/sync-local-folder-source.ts | 638 ++++++++++++++++++ .../sync-manifest-local-folder-source.ts | 123 ++++ .../update-content-database-view.test.ts | 22 + .../actions/update-content-database-view.ts | 11 +- templates/content/actions/update-document.ts | 15 - templates/content/actions/view-screen.ts | 52 +- .../editor/database/DatabaseView.tsx | 92 ++- .../components/editor/database/grouping.ts | 3 +- .../components/editor/database/settings.tsx | 43 +- .../app/components/editor/database/shared.tsx | 2 + .../editor/database/sidebar.test.tsx | 72 ++ .../components/editor/database/sidebar.tsx | 330 +++++++++ .../app/components/editor/database/types.ts | 1 + .../editor/database/view-config.test.ts | 20 + .../components/editor/database/view-config.ts | 4 +- .../content/app/components/layout/Layout.tsx | 26 +- .../sidebar/DocumentSidebar.layout.test.ts | 29 +- .../components/sidebar/DocumentSidebar.tsx | 288 +++++--- .../content/app/hooks/use-content-database.ts | 16 + .../content/app/hooks/use-content-spaces.ts | 39 ++ templates/content/app/i18n-data.ts | 35 + templates/content/app/i18n/zh-TW.ts | 7 + .../content/app/routes/_app.local-files.tsx | 239 +++++-- ...ace-through-a-customizable-files-databa.md | 6 + templates/content/parity/matrix.md | 3 +- templates/content/parity/matrix.ts | 38 +- templates/content/scripts/dev-database.mjs | 12 +- .../server/db/content-spaces-schema.spec.ts | 51 ++ templates/content/server/db/schema.ts | 153 +++-- .../content/server/lib/document-context.ts | 16 +- templates/content/server/lib/notion-sync.ts | 6 + templates/content/server/plugins/db.ts | 153 +++++ templates/content/shared/api.ts | 20 +- 113 files changed, 7049 insertions(+), 6585 deletions(-) create mode 100644 .changeset/calm-files-share.md create mode 100644 templates/content/actions/_content-files.ts create mode 100644 templates/content/actions/_content-space-access.ts create mode 100644 templates/content/actions/_content-spaces.ts create mode 100644 templates/content/actions/_local-folder-source.test.ts create mode 100644 templates/content/actions/_local-folder-source.ts create mode 100644 templates/content/actions/backfill-content-files.ts create mode 100644 templates/content/actions/connect-local-folder-source.ts create mode 100644 templates/content/actions/content-files.db.test.ts create mode 100644 templates/content/actions/content-spaces.db.test.ts delete mode 100644 templates/content/actions/create-document.test.ts create mode 100644 templates/content/actions/disconnect-local-folder-source.ts create mode 100644 templates/content/actions/ensure-content-spaces.ts create mode 100644 templates/content/actions/list-content-spaces.ts create mode 100644 templates/content/actions/local-folder-source.db.test.ts create mode 100644 templates/content/actions/resolve-local-folder-conflict.ts create mode 100644 templates/content/actions/space-aware-writers.db.test.ts create mode 100644 templates/content/actions/split-mode-retirement.test.ts create mode 100644 templates/content/actions/sync-local-folder-source.ts create mode 100644 templates/content/actions/sync-manifest-local-folder-source.ts create mode 100644 templates/content/app/components/editor/database/sidebar.test.tsx create mode 100644 templates/content/app/components/editor/database/sidebar.tsx create mode 100644 templates/content/app/hooks/use-content-spaces.ts create mode 100644 templates/content/changelog/2026-07-16-organize-every-workspace-through-a-customizable-files-databa.md create mode 100644 templates/content/server/db/content-spaces-schema.spec.ts diff --git a/.changeset/calm-files-share.md b/.changeset/calm-files-share.md new file mode 100644 index 0000000000..65be1757e7 --- /dev/null +++ b/.changeset/calm-files-share.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": minor +--- + +Let Content connect manifest-declared local folders to its normal database-backed workspace without enabling a separate local-file data mode. diff --git a/packages/core/docs/content/local-file-mode.mdx b/packages/core/docs/content/local-file-mode.mdx index b412935b0d..1f2ec7e062 100644 --- a/packages/core/docs/content/local-file-mode.mdx +++ b/packages/core/docs/content/local-file-mode.mdx @@ -1,531 +1,141 @@ --- -title: "Local File Mode" -description: "Run agent-native apps with local Markdown, MDX, and other repo files as the source of truth - including Obsidian-style MDX docs with custom components." +title: "Local folder sources" +description: "Connect Markdown and MDX folders to Content's database-backed spaces without switching the app into a separate local mode." --- -# Local File Mode - -Local File Mode lets an agent-native app attach its normal UI and action surface -directly to files in a repo or workspace. The app still feels like the hosted -product, but its list views, editor, and agent tools read and write local files -instead of SQL-backed app records. - -The first implementation is in the Content template: the left sidebar is -populated from local `.md` and `.mdx` files, selecting a page opens the standard -Content editor, and saving writes back to the selected file. The same files can -also be edited by Codex, Claude Code, the Agent-Native sidebar agent, or a normal -editor. - -For Content, this makes the product feel like open-source Obsidian for MDX: -your docs live as files, while the app adds a visual editor, agent actions, -shareable copies, and rich interactive MDX components. - -Use Local File Mode when you want a repo-first workflow: - -- a docs repo with `docs/*.mdx` -- a blog with `blog/*.mdx` -- resources such as positioning, messaging, or team notes in `resources/*.md` -- a personal Obsidian-style knowledge base with a richer MDX editor -- docs that need interactive custom MDX blocks generated from local React code -- app artifacts that should be easy for coding agents to inspect and patch - -Use database mode when you want the hosted collaborative app experience: -multi-user sharing, SQL-backed permissions, comments, version history, and -production hosting without local filesystem access. - -## The Mental Model - -There are two source-of-truth modes: - -| Mode | Source of truth | Best for | -| --------------- | ------------------------------------------ | ------------------------------------------------------------------------ | -| Database mode | SQL rows through Drizzle | Hosted apps, collaboration, sharing, comments, version history | -| Local File Mode | Repo files declared by `agent-native.json` | Local/dev workflows, Git review, coding-agent edits, file-native content | - -The UI and agent actions should stay the same shape in both modes. A Content -editor still edits documents; the difference is whether those documents resolve -to SQL rows or local files. - - - -```html -
-
-
Content UI
-
- Agent + actions
list/get/update-document -
-
- -
-
- Database modeSQL rows via Drizzlehosted · sharing · comments · history -
-
- Local File Moderepo files via agent-native.jsonGit review · coding-agent edits -
-
-
-``` +# Local folder sources -```css -.diagram-mode { - display: flex; - align-items: center; - gap: 14px; - flex-wrap: wrap; -} -.diagram-mode .diagram-col { - display: flex; - flex-direction: column; - gap: 10px; -} -.diagram-mode .diagram-arrow { - font-size: 22px; - line-height: 1; -} -.diagram-mode .resolve { - display: flex; - gap: 12px; - flex-wrap: wrap; -} -.diagram-mode .diagram-panel { - display: flex; - flex-direction: column; - gap: 4px; - padding: 12px 14px; -} -``` +Content has one data model: every page lives in SQL, belongs to a **space**, and +is a member of that space's system **Files** database. A local Markdown or MDX +folder is a source connected to Files. It is not a second application mode and +does not replace the database. -
- -## Example Repo - -A Content workspace can be as small as this: - - - -In Local File Mode, the Content sidebar shows the `docs/`, `blog/`, and -`resources/` trees as pages. Selecting `docs/getting-started.mdx` opens that -file in the standard Content editor; editing in the UI writes back to -`docs/getting-started.mdx`. - -`components/` is not a content root. It is a preview component library that MDX -files can import or reference. The editor can render simple local MDX components -without requiring you to clone or fork the entire Content app. - -`extensions/` is also not a content root. It is a local extension library: -small sandboxed widgets that can render in app slots while their source stays in -the repo. - -## Install Content Into A Repo - -For an existing docs, blog, or MDX workspace, install the Content local-files -skill: +This means local files can keep their familiar repo workflow while Content still +provides collaboration, sharing, comments, history, search, database properties, +and the same action surface everywhere. There is no `AGENT_NATIVE_MODE` switch +and no database/local fork in document actions. -```bash -npx @agent-native/core@latest skills add content --mode local-files --scope project -``` +## Spaces, Files, and Workspaces -This copies the `content` skill into the repo's agent skill folders and writes -or updates `agent-native.json` with Content defaults: +Content provisions: -- `mode: "local-files"` at the workspace level -- `apps.content.mode: "local-files"` -- content roots for `docs/`, `blog/`, `content/`, and `resources/` -- `components/` for local MDX components -- `extensions/` for local extension widgets +- a personal space for each user +- an organization space for each organization the user can access +- a system **Files** database in every space +- a personal **Workspaces** catalog that references the spaces shown in the app -The installed skill tells coding agents to use Content actions -(`list-documents`, `get-document`, `edit-document`, `update-document`, -`share-local-file-document`, and component-file actions) when a local Content app -or Agent Native Desktop bridge exposes them. If no bridge is running, the skill -falls back to safe direct repo edits while preserving frontmatter, imports, JSX, -and unknown MDX. +Every ordinary page belongs to exactly one space and exactly one Files database. +Files is a normal Content database: add properties, save views, filter, sort, or +group it. Its sidebar view renders those same rows as navigation. Grouping a +sidebar view creates collapsible sections, while saved views can become alternate +sidebar tabs. -## Configuration +The Workspaces catalog applies the same idea one level higher. It controls which +personal, organization, and folder-backed spaces participate in the app sidebar. -Add `agent-native.json` to the repo or workspace root: +## Connect a folder -```json -{ - "version": 1, - "apps": { - "content": { - "mode": "local-files", - "roots": [ - { - "name": "Docs", - "path": "docs", - "kind": "docs", - "extensions": [".md", ".mdx"] - }, - { - "name": "Blog", - "path": "blog", - "kind": "blog", - "extensions": [".md", ".mdx"] - }, - { - "name": "Resources", - "path": "resources", - "kind": "resources", - "extensions": [".md", ".mdx"] - } - ], - "components": "components", - "extensions": "extensions", - "hide": ["**/_*.md", "**/_*.mdx"] - } - } -} -``` +Open Content's local-folder screen and select a folder through the browser File +System Access API or Agent Native Desktop. Content stores only an opaque +connection identifier, relative paths, hashes, and source metadata in SQL. It +does not store the absolute path, browser handle, or raw file bodies there. -You can also enable local files with `AGENT_NATIVE_MODE=local-files` or -`AGENT_NATIVE_DATA_MODE=local-files`; the manifest is preferred because it -documents the folder contract in the repo itself. +A folder can be connected in either way: -## Content File Format +- attach it to the Files database of an existing personal or organization space +- create a private folder-backed space with its own Files database -Content reads Markdown and MDX. Frontmatter holds page metadata, and the body is -the editable document: +Choose a truth policy for the connection: -```mdx ---- -title: "Getting Started" -icon: "sparkles" -isFavorite: true -updatedAt: "2026-06-12T20:00:00.000Z" ---- +| Policy | Behavior | +| ------------------------ | ------------------------------------------------------------------ | +| `database_primary` | Content is authoritative; folder changes are reviewed before use | +| `source_primary` | The folder is authoritative when no concurrent Content edit exists | +| `reviewed_bidirectional` | Changes in either direction require review at conflicts | -# Getting Started +Initial sync materializes files as ordinary SQL-backed pages and records their +source identity. A repeated sync is idempotent. If a file and its Content page +both changed, Content records an incoming change set instead of silently +overwriting either side. Disconnecting keeps both the SQL pages and local files. -Use to show framework-specific code. -``` +## Start Content for a repo -The title comes from `title` frontmatter when present, otherwise from the -filename. The editor preserves MDX source that it cannot visually edit yet, so -coding agents and normal text editors remain safe escape hatches. - -## Custom MDX Components - -Content can preview local components from the configured `components` folder. -This is meant for docs-style MDX components such as tabs, callouts, package -install snippets, or framework-specific code blocks. - -For example, add an interactive component next to your content: - -```tsx filename="components/ImpactCounter.tsx" -import { useState } from "react"; - -export function ImpactCounter({ - label = "points", - accent = "blue", - featured = false, -}: { - label?: string; - accent?: "blue" | "green" | "purple"; - featured?: boolean; -}) { - const [count, setCount] = useState(3); - const accentClass = - accent === "green" - ? "border-green-300 bg-green-50" - : accent === "purple" - ? "border-purple-300 bg-purple-50" - : "border-blue-300 bg-blue-50"; - - return ( -
-
Launch impact
-
- {count} {label} -
- {featured ?
Featured metric
: null} - -
- ); -} +From a repo or folder, run: -export const ImpactCounterInputs = { - label: { - type: "string", - label: "Metric label", - default: "points", - }, - accent: { - type: "select", - label: "Accent", - options: ["blue", "green", "purple"], - default: "blue", - }, - featured: { - type: "boolean", - label: "Featured", - default: false, - }, -}; +```bash +npx @agent-native/core@latest content . ``` -Then use it from any local MDX file: - -```mdx ---- -title: "Launch Notes" ---- - -# Launch Notes - - -``` +You can also target one file: -The Content dev server discovers PascalCase named exports and PascalCase default -exports from `.tsx`, `.jsx`, `.ts`, and `.js` files under `components/`. Those -components render inside the editor and appear in the slash menu under -**Local components**. Slash insertion creates a minimal tag such as -``; add props in the MDX source when needed. - -Component execution is intentionally a local-dev/Desktop bridge capability, not -plain hosted browser folder access. If you open `content.agent-native.com`, -choose **Local files**, and pick a folder in Chrome, the app can read and write -the `.md` and `.mdx` files through the browser File System Access API, but -Chrome does not expose an absolute folder path for Vite to compile -`components/*.tsx`. To preview and hot reload custom React components, run -Content locally or use Agent Native Desktop so the trusted local bridge can -register the picked workspace with the local Content dev server. In that mode, -edits to existing component files hot reload through Vite, and adding or -removing component files reloads the component registry and slash menu. - -Agents can also work with those registered component files. Use -`list-local-component-files` to find the registered workspace id, then -`write-local-component-file` to create or update `.tsx`, `.jsx`, `.ts`, or -`.js` files under the workspace's `components/` folder. The MDX files remain the -source of truth for component usage; the component files remain normal repo -source files reviewed with Git. - -If a component exports input metadata, selecting the component in the editor -shows an edit button in the component's top-right corner. Supported input types -are `string`, `textarea`, `number`, `boolean`, and `select`. The form writes -changes back to the MDX tag, so local files remain the source of truth. The -metadata can be exported as `ComponentNameInputs`, `ComponentNameConfig.inputs`, -`Component.inputs`, or `agentNative.inputs`. - -Simple component tags with literal props can preview inline: - -```mdx - - - - -This setting affects production deploys. - - +```bash +npx @agent-native/core@latest content docs/getting-started.mdx ``` -Complex JSX expressions are preserved in source. If the editor cannot safely -preview a component prop yet, it shows a warning placeholder rather than -silently dropping data. - -## Sharing Local Files - -Local files are not shared directly because other users cannot read a path on -your machine. The Content toolbar's Share button creates or refreshes a -database-backed copy of the selected file, navigates to that copy, and opens the -normal share popover. The original local file remains under Local files; the -database copy appears under Shared copies in Local File Mode and uses the -standard document sharing model. +The CLI starts the normal database-backed Content app, registers the folder as a +local-folder source, and opens the connection screen. It does not set a mode +environment variable. -## Local Extensions +## Manifest configuration -Local File Mode can also load repo-backed extensions from the configured -`extensions` folder. Each extension is one directory with an `extension.json` -manifest and an HTML entry file: - -```text -extensions/ - doc-status/ - extension.json - index.html -``` +`agent-native.json` declares folder roots and their adapter metadata. `mode` is +not a runtime selector: ```json { - "id": "doc-status", - "name": "Doc Status", - "description": "Shows metadata for the selected Content file.", - "entry": "index.html", - "slots": ["content.sidebar.bottom"], - "permissions": { - "appActions": ["list-documents"], - "extensionData": true + "version": 1, + "apps": { + "content": { + "roots": [ + { + "name": "Docs", + "path": "docs", + "kind": "docs", + "extensions": [".md", ".mdx"], + "source": { + "type": "local-folder", + "connectionId": "local-folder:", + "truthPolicy": "source_primary" + } + } + ], + "components": "components", + "extensions": "extensions", + "hide": ["**/_*.md", "**/_*.mdx"] + } } } ``` -`index.html` is the same Alpine/Tailwind extension body format used by normal -database-backed extensions. When the Content app sees a local extension that -declares `content.sidebar.bottom`, it renders that extension at the bottom of -the Content sidebar. The host passes `window.slotContext` with the selected -document id, title, source metadata, and whether Content is in Local File Mode. - -Local extensions are previewed by the app but edited as files. The Extensions -list shows them with a Local File badge, and the full-page viewer points back to -the entry file. SQL-backed extension actions such as update, delete, share, and -history do not apply; use your editor, Codex, Claude Code, or Git history for -source changes. +The connection identifier is deliberately opaque. The trusted browser or +Desktop bridge maps it back to a folder handle locally. -For v1, local extensions are intentionally conservative: +## File format and identity -- they can use `extensionData` for their own small runtime state -- they can call only the `appAction`s listed in `extension.json` -- raw SQL helpers and external `extensionFetch` are disabled -- slot targets are declared in `extension.json`, not installed through SQL +Content reads `.md` and `.mdx`. Frontmatter may preserve the stable Content page +ID, title, parent, ordering, and other supported metadata. Keeping the ID allows +renames and moves to retain page identity instead of creating duplicates. -This gives local workspaces an Obsidian-like plugin surface without letting an -arbitrary repo file inherit every capability of a database-backed extension. +Unknown frontmatter, imports, JSX, and custom MDX should be preserved by tools +that edit the source. Local components remain in the configured `components/` +folder and are loaded only through the trusted local bridge. Local extensions +remain sandboxed under `extensions/`. -## How Apps Use It +## Migration from Local File Mode -Local File Mode is implemented through the framework's local artifact helpers. -An app declares roots for the artifact types it owns, then reads and writes -through the same action surface its UI and agent already use. - -For Content, that means: - -- `list-documents` lists configured `.md` and `.mdx` files. -- `get-document` reads a selected local file. -- `update-document` writes the selected local file. -- `create-document` creates a new local `.mdx` file in the selected folder. -- `delete-document` deletes the local file. -- search runs across the configured local files. - -Moving, renaming, and reordering local-file pages from the Content UI is not -supported yet. Do those operations in the workspace or with a coding agent; the -Content sidebar will reflect the resulting file tree. - -This keeps the agent contract simple: the agent can keep using Content actions, -and those actions decide whether the target is SQL-backed or file-backed. - -Other apps can adopt the same pattern over time. A Slides app can map -`slides/*.mdx` to decks, a Plans app can map `plans/*` to plan documents, and a -Dashboards app can map `dashboards/*.mdx` to dashboards. Those app-specific -folders are conventions layered on top of the same local artifact contract. - -## Local Files vs. Export/Import - -Content has two different file workflows: - -| Workflow | What happens | -| ---------------------------- | -------------------------------------------------------------------------------------------------------------------- | -| `/local-files` export/import | Database mode remains the source of truth. Files are an explicit sync surface you export, edit, preview, and import. | -| Local File Mode | Files are the source of truth. The Content sidebar and editor operate directly on local files. | - -Use export/import when you want occasional file review around a hosted workspace. -Use Local File Mode when the repo itself is the workspace. - -## History And Collaboration - -Local File Mode leans on file-native history: - -- commit important changes to Git -- use pull requests for review -- let coding agents edit the same files directly -- use normal file diffs to understand changes - -Database mode remains the better fit for hosted collaboration features such as -sharing, comments, SQL-backed version history, and live multi-user editing. - -Provider sync can be layered on top of either mode. For example, a docs repo can -add actions that pull content from a CMS into local MDX files or push selected -local files back to that CMS. - -## Production Safety - -Local File Mode gives app actions direct write access to configured workspace -files. That is appropriate for local development and trusted single-tenant file -bridges, but it is not the default production security model. - -When `NODE_ENV=production`, the framework refuses `local-files` mode unless you -set: - -```bash -AGENT_NATIVE_ALLOW_LOCAL_FILES_IN_PRODUCTION=true -``` +Older manifests may contain `mode: "local-files"`, and older launch workflows +may set `AGENT_NATIVE_MODE=local-files`. Remove both. Keep the configured roots, +add `source.type`, `source.connectionId`, and `source.truthPolicy`, then launch +with `agent-native content `. -Only set that for a trusted single-tenant deployment where everyone who can use -the app is allowed to read and write the configured files. For normal hosted, -multi-user apps, use database mode and SQL-backed sharing. +After the first connection and sync, verify: -## What's next +1. the folder appears as a source on the intended Files database +2. each imported page belongs to the intended space +3. custom properties and saved sidebar views work normally +4. concurrent edits appear as reviewable change sets +5. disconnecting preserves the database pages and the files on disk -- [**Database**](/docs/database) — the SQL-backed mode this page contrasts with -- [**Sharing**](/docs/sharing) — the standard document-sharing model that database-mode copies use -- [**Extensions**](/docs/extensions) — the SQL-backed extension model that local extensions intentionally simplify -- [**Creating Templates**](/docs/creating-templates) — adopting the same local-artifact pattern in a new app +The old two-mode architecture is retired. Local folders are now adapters around +the global database model—which is less dramatic than a mode switch, and much +less likely to leave half the product living in a parallel universe. diff --git a/packages/core/docs/content/locales/ar-SA/local-file-mode.mdx b/packages/core/docs/content/locales/ar-SA/local-file-mode.mdx index 73358f9877..fac64eed34 100644 --- a/packages/core/docs/content/locales/ar-SA/local-file-mode.mdx +++ b/packages/core/docs/content/locales/ar-SA/local-file-mode.mdx @@ -1,523 +1,68 @@ --- -title: "وضع الملف المحلي" -description: "قم بتشغيل تطبيقات الوكيل الأصلية باستخدام ملفات Markdown وMDX المحلية وملفات الريبو الأخرى كمصدر للحقيقة - بما في ذلك مستندات MDX ذات النمط Obsidian مع المكونات المخصصة." +title: "مصادر المجلدات المحلية" +description: "اربط مجلدات Markdown وMDX بمساحات Content المدعومة بقاعدة البيانات." --- -# وضع الملف المحلي +# مصادر المجلدات المحلية -يتيح وضع الملف المحلي لتطبيق الوكيل الأصلي إرفاق UI العادي وسطح الإجراء -مباشرة إلى الملفات الموجودة في الريبو أو مساحة العمل. لا يزال التطبيق يبدو وكأنه التطبيق المستضاف -المنتج، لكن طرق عرض القائمة والمحرر وأدوات الوكيل الخاصة به تقرأ وتكتب الملفات المحلية -بدلاً من سجلات التطبيقات المدعومة بـ SQL. +يستخدم Content الآن نموذج بيانات واحدًا: تُحفظ كل صفحة في SQL، وتنتمي إلى +**مساحة**، وتكون عنصرًا في قاعدة النظام **Files** لتلك المساحة. المجلد المحلي +مصدر متصل بـ Files، وليس وضعًا آخر للتطبيق. لم يعد هناك مفتاح +`AGENT_NATIVE_MODE`. -التنفيذ الأول موجود في قالب المحتوى: الشريط الجانبي الأيسر هو -يتم ملؤها من ملفات `.md` و`.mdx` المحلية، ويؤدي تحديد صفحة إلى فتح المعيار -محرر المحتوى، ويحفظ الحفظ ويكتب مرة أخرى إلى الملف المحدد. نفس الملفات يمكن -يتم تحريره أيضًا بواسطة Codex أو Claude أو وكيل الشريط الجانبي Agent-Native أو وكيل عادي -محرر. +لكل مستخدم مساحة شخصية، ولكل مؤسسة متاحة مساحة مؤسسة، ويحدد كتالوج +**Workspaces** الشخصي المساحات التي تظهر. Files قاعدة Content عادية؛ يمكن +لخصائصها وعوامل التصفية والترتيب والتجميع وطرق العرض المحفوظة أن تعرض الشريط +الجانبي أيضًا. -بالنسبة للمحتوى، هذا يجعل المنتج يبدو وكأنه سبج مفتوح المصدر لـ MDX: -تظهر مستنداتك كملفات، بينما يضيف التطبيق محررًا مرئيًا، الوكيل actions، -نسخ قابلة للمشاركة، ومكونات MDX تفاعلية غنية. +## ربط مجلد -استخدم وضع الملف المحلي عندما تريد سير عمل الريبو الأول: +اختر مجلدًا عبر المتصفح أو Agent Native Desktop، ثم اربطه بمساحة موجودة أو +أنشئ له مساحة خاصة. لا يخزن SQL سوى معرف الاتصال المعتم، والمسارات النسبية، +والبصمات، وبيانات المصدر الوصفية؛ ولا يخزن المسار المطلق أو مقبض المتصفح أو نص +الملف الخام. -- مستودع المستندات باستخدام `docs/*.mdx` -- مدونة تحتوي على `blog/*.mdx` -- موارد مثل تحديد المواقع أو المراسلة أو ملاحظات الفريق في `resources/*.md` -- قاعدة معرفية شخصية على طراز Obsidian مع محرر MDX الأكثر ثراءً -- المستندات التي تحتاج إلى كتل MDX تفاعلية مخصصة تم إنشاؤها من كود React المحلي -- عناصر التطبيق التي ينبغي أن يسهل على وكلاء البرمجة فحصها وتصحيحها +السياسات هي `database_primary` و`source_primary` و`reviewed_bidirectional`. +تحول المزامنة الملفات إلى صفحات SQL عادية. إذا تغير الملف وصفحته معًا، يُنشئ +Content تغييرًا للمراجعة بدل الكتابة فوق أحدهما. يحافظ قطع الاتصال على الصفحات +والملفات المحلية. -استخدم وضع قاعدة البيانات عندما تريد تجربة التطبيق التعاوني المستضاف: -مشاركة متعددة المستخدمين، والأذونات المدعومة SQL، والتعليقات، وسجل الإصدارات، و -استضافة الإنتاج دون الوصول إلى نظام الملفات المحلي. - -## النموذج العقلي - -هناك وضعان لمصدر الحقيقة: - -| الوضع | مصدر الحقيقة | الأفضل لـ | -| ------------------ | ----------------------------------------------- | ---------------------------------------------------------------------------------- | -| وضع قاعدة البيانات | صفوف SQL حتى Drizzle | التطبيقات المستضافة والتعاون والمشاركة والتعليقات وسجل الإصدارات | -| وضع الملف المحلي | ملفات الريبو المعلنة بواسطة `agent-native.json` | سير العمل المحلي/المطور، ومراجعة Git، وتعديلات وكيل الترميز، والمحتوى الأصلي للملف | - -يجب أن يظل UI والوكيل actions بنفس الشكل في كلا الوضعين. المحتوى -لا يزال المحرر يقوم بتحرير المستندات؛ والفرق هو ما إذا كانت هذه المستندات قد تم حلها -إلى صفوف SQL أو الملفات المحلية. - - - -```html -
-
-
واجهة المحتوى
-
- الوكيل + الإجراءات
list/get/update-document -
-
- -
-
- وضع قاعدة البياناتSQL rows via Drizzleمستضاف · مشاركة · تعليقات · سجل -
-
- Local File Moderepo files via agent-native.jsonمراجعة Git · تعديلات وكيل الترميز -
-
-
-``` - -```css -.diagram-mode { - display: flex; - align-items: center; - gap: 14px; - flex-wrap: wrap; -} -.diagram-mode .diagram-col { - display: flex; - flex-direction: column; - gap: 10px; -} -.diagram-mode .diagram-arrow { - font-size: 22px; - line-height: 1; -} -.diagram-mode .resolve { - display: flex; - gap: 12px; - flex-wrap: wrap; -} -.diagram-mode .diagram-panel { - display: flex; - flex-direction: column; - gap: 4px; - padding: 12px 14px; -} -``` - -
- -## مثال الريبو - -يمكن أن تكون مساحة عمل المحتوى صغيرة مثل هذا: - - - -في وضع الملف المحلي، يعرض الشريط الجانبي للمحتوى `docs/` و`blog/` و -أشجار `resources/` كصفحات. يؤدي تحديد `docs/getting-started.mdx` إلى فتح ذلك -ملف في محرر المحتوى القياسي؛ التحرير في UI يعيد الكتابة إلى -`docs/getting-started.mdx`. - -`components/` ليس جذر محتوى. إنها مكتبة مكونات المعاينة MDX -يمكن استيراد الملفات أو الرجوع إليها. يمكن للمحرر عرض مكونات MDX محلية بسيطة -دون مطالبتك باستنساخ تطبيق المحتوى بالكامل أو تفرعه. - -`extensions/` ليس جذرًا للمحتوى أيضًا. إنها مكتبة ملحقة محلية: -أدوات صغيرة في وضع الحماية يمكن عرضها في فتحات التطبيق بينما يبقى مصدرها في -الريبو. - -## تثبيت المحتوى في الريبو - -بالنسبة إلى المستندات أو المدونة أو مساحة عمل MDX الموجودة، قم بتثبيت ملفات المحتوى المحلية -المهارة: +## البدء من مستودع ```bash -npx @agent-native/core@latest skills add content --mode local-files --scope project +npx @agent-native/core@latest content . ``` -يؤدي هذا إلى نسخ مهارة `content` إلى مجلدات مهارات وكيل الريبو والكتابة -أو يقوم بتحديث `agent-native.json` باستخدام الإعدادات الافتراضية للمحتوى: - -- `mode: "local-files"` على مستوى مساحة العمل -- `apps.content.mode: "local-files"` -- جذور المحتوى لـ `docs/` و`blog/` و`content/` و`resources/` -- `components/` لمكونات MDX المحلية -- `extensions/` لعناصر واجهة المستخدم المحلية - -تخبر المهارة المثبتة وكلاء البرمجة باستخدام المحتوى actions -(`list-documents`, `get-document`, `edit-document`, `update-document`, -`share-local-file-document`، وملف المكون actions) عند تطبيق محتوى محلي -أو جسر سطح المكتب Agent Native يعرضهم. إذا لم يتم تشغيل أي جسر، فستتم المهارة -يعود إلى تعديلات الريبو المباشرة الآمنة مع الحفاظ على المادة الأمامية، والواردات، JSX، -وMDX غير معروف. - -## التكوين +يشغل الأمر تطبيق Content العادي المدعوم بقاعدة البيانات، ويسجل المجلد كمصدر، +ويفتح شاشة الاتصال. ولا يضبط متغير بيئة خاصًا بالوضع. -أضف `agent-native.json` إلى الريبو أو جذر مساحة العمل: +## الإعداد ```json { "version": 1, "apps": { "content": { - "mode": "local-files", "roots": [ { "name": "Docs", "path": "docs", "kind": "docs", - "extensions": [".md", ".mdx"] - }, - { - "name": "Blog", - "path": "blog", - "kind": "blog", - "extensions": [".md", ".mdx"] - }, - { - "name": "Resources", - "path": "resources", - "kind": "resources", - "extensions": [".md", ".mdx"] + "extensions": [".md", ".mdx"], + "source": { + "type": "local-folder", + "connectionId": "local-folder:", + "truthPolicy": "source_primary" + } } ], "components": "components", - "extensions": "extensions", - "hide": ["**/_*.md", "**/_*.mdx"] + "extensions": "extensions" } } } ``` -يمكنك أيضًا تمكين الملفات المحلية باستخدام `AGENT_NATIVE_MODE=local-files` أو -`AGENT_NATIVE_DATA_MODE=local-files`; the manifest is preferred because it -يوثق عقد المجلد في الريبو نفسه. - -## تنسيق ملف المحتوى - -يقرأ المحتوى Markdown وMDX. يحتفظ Frontmatter بالبيانات التعريفية للصفحة، والنص الأساسي هو -المستند القابل للتحرير: - -```mdx ---- -title: "البدء" -icon: "sparkles" -isFavorite: true -updatedAt: "2026-06-12T20:00:00.000Z" ---- - -# البدء - -Use to show framework-specific code. -``` - -يأتي العنوان من المادة الأمامية `title` في حالة وجوده، وإلا من -اسم الملف. يحتفظ المحرر بمصدر MDX الذي لا يمكنه تحريره بصريًا بعد، لذا -تظل عوامل الترميز ومحررات النصوص العادية بمثابة بوابات هروب آمنة. - -## مكونات MDX المخصصة - -يمكن للمحتوى معاينة المكونات المحلية من المجلد `components` الذي تم تكوينه. -هذا مخصص لمكونات MDX بنمط المستندات مثل علامات التبويب ووسائل الشرح والحزمة -قم بتثبيت المقتطفات أو كتل التعليمات البرمجية الخاصة بإطار العمل. - -على سبيل المثال، أضف مكونًا تفاعليًا بجوار المحتوى الخاص بك: - -```tsx -// components/ImpactCounter.tsx -import { useState } from "react"; - -export function ImpactCounter({ - label = "points", - accent = "blue", - featured = false, -}: { - label?: string; - accent?: "blue" | "green" | "purple"; - featured?: boolean; -}) { - const [count, setCount] = useState(3); - const accentClass = - accent === "green" - ? "border-green-300 bg-green-50" - : accent === "purple" - ? "border-purple-300 bg-purple-50" - : "border-blue-300 bg-blue-50"; - - return ( -
-
Launch impact
-
- {count} {label} -
- {featured ?
Featured metric
: null} - -
- ); -} - -export const ImpactCounterInputs = { - label: { - type: "string", - label: "Metric label", - default: "points", - }, - accent: { - type: "select", - label: "Accent", - options: ["blue", "green", "purple"], - default: "blue", - }, - featured: { - type: "boolean", - label: "Featured", - default: false, - }, -}; -``` - -ثم استخدمه من أي ملف MDX محلي: - -```mdx ---- -title: "إطلاق الملاحظات" ---- - -# إطلاق الملاحظات - - -``` - -يكتشف خادم تطوير المحتوى PascalCase المسمى الصادرات وPascalCase الافتراضي -يتم التصدير من ملفات `.tsx` و`.jsx` و`.ts` و`.js` ضمن `components/`. تلك -يتم عرض المكونات داخل المحرر وتظهر في قائمة الشرطة المائلة ضمن -**المكونات المحلية**. يؤدي إدراج شرطة مائلة إلى إنشاء علامة صغيرة مثل -``؛ أضف الدعائم في مصدر MDX عند الحاجة. - -تنفيذ المكون هو عن قصد إمكانية جسر محلي/سطح المكتب، وليس -الوصول العادي إلى مجلد المتصفح المستضاف. إذا قمت بفتح `content.agent-native.com`، -اختر **الملفات المحلية**، واختر مجلدًا في Chrome، حيث يمكن للتطبيق القراءة والكتابة -الملفات `.md` و`.mdx` من خلال الوصول إلى نظام الملفات في المتصفح API، ولكن -لا يعرض Chrome مسارًا مطلقًا للمجلد لتجميعه Vite -`components/*.tsx`. لمعاينة مكونات React المخصصة وإعادة تحميلها بسرعة، قم بتشغيل -المحتوى محليًا أو استخدم Agent Native Desktop حتى يتمكن الجسر المحلي الموثوق به من -قم بتسجيل مساحة العمل المختارة باستخدام خادم تطوير المحتوى المحلي. في هذا الوضع، -عمليات التحرير على ملفات المكونات الموجودة، إعادة التحميل السريع من خلال Vite، وإضافة أو -تؤدي إزالة ملفات المكونات إلى إعادة تحميل سجل المكونات وقائمة الشرطة المائلة. - -يمكن للوكلاء أيضًا العمل مع ملفات المكونات المسجلة. استخدم -`list-local-component-files` للعثور على معرف مساحة العمل المسجلة، ثم -`write-local-component-file` لإنشاء أو تحديث `.tsx` أو `.jsx` أو `.ts` أو -ملفات `.js` ضمن المجلد `components/` الخاص بمساحة العمل. تظل ملفات MDX هي -مصدر الحقيقة لاستخدام المكونات؛ تظل ملفات المكونات مستودعًا عاديًا -تمت مراجعة الملفات المصدر باستخدام Git. - -إذا قام أحد المكونات بتصدير بيانات التعريف المدخلة، فحدد المكون في المحرر -يُظهر زر التحرير في الزاوية العلوية اليمنى للمكون. أنواع الإدخال المدعومة -هي `string`، و`textarea`، و`number`، و`boolean`، و`select`. يكتب النموذج -مرة أخرى إلى علامة MDX، بحيث تظل الملفات المحلية هي مصدر الحقيقة. ال -يمكن تصدير البيانات الوصفية بتنسيق `ComponentNameInputs`، `ComponentNameConfig.inputs`، -`Component.inputs`، أو `agentNative.inputs`. - -يمكن معاينة علامات المكونات البسيطة ذات الدعائم الحرفية في السطر: - -```mdx - - - - -This setting affects production deploys. - - -``` - -يتم الاحتفاظ بتعبيرات JSX المعقدة في المصدر. إذا لم يتمكن المحرر بأمان -قم بمعاينة خاصية المكون حتى الآن، فهي تعرض عنصرًا نائبًا للتحذير بدلاً من -إسقاط البيانات بصمت. - -## مشاركة الملفات المحلية - -لا تتم مشاركة الملفات المحلية مباشرة لأن المستخدمين الآخرين لا يستطيعون قراءة المسار الموجود على -جهازك. يقوم زر المشاركة في شريط أدوات المحتوى بإنشاء أو تحديث -نسخة مدعومة بقاعدة بيانات من الملف المحدد، وتنتقل إلى تلك النسخة، وتفتح -مشاركة عادية منبثقة. يبقى الملف المحلي الأصلي ضمن الملفات المحلية؛ -تظهر نسخة قاعدة البيانات ضمن النسخ المشتركة في وضع الملف المحلي وتستخدم -النموذج القياسي لمشاركة المستندات. - -## الإضافات المحلية - -يمكن أيضًا أن يقوم وضع الملف المحلي بتحميل الامتدادات المدعومة بالريبو من الملف الذي تم تكوينه -مجلد `extensions`. كل ملحق عبارة عن دليل واحد به `extension.json` -البيان وملف الإدخال HTML: - -```text -extensions/ - doc-status/ - extension.json - index.html -``` - -```json -{ - "id": "doc-status", - "name": "Doc Status", - "description": "Shows metadata for the selected Content file.", - "entry": "index.html", - "slots": ["content.sidebar.bottom"], - "permissions": { - "appActions": ["list-documents"], - "extensionData": true - } -} -``` - -`index.html` هو نفس تنسيق هيكل امتداد Alpine/Tailwind المستخدم في المعتاد -الامتدادات المدعومة بقاعدة البيانات. عندما يرى تطبيق المحتوى امتدادًا محليًا -يُعلن عن `content.sidebar.bottom`، ويعرض هذا الامتداد في الجزء السفلي من -الشريط الجانبي للمحتوى. يمرر المضيف `window.slotContext` مع -معرف المستند، والعنوان، وبيانات تعريف المصدر، وما إذا كان المحتوى في وضع الملف المحلي. - -يقوم التطبيق بمعاينة الإضافات المحلية ولكن يتم تحريرها كملفات. الامتدادات -هذه الملفات بشارة ملف محلي، ويشير عارض الصفحة الكاملة إلى -ملف الإدخال. الامتداد المدعوم SQL actions مثل التحديث والحذف والمشاركة و -لا ينطبق التاريخ؛ استخدم المحرر الخاص بك، Codex، رمز Claude، أو سجل Git لـ -تغييرات المصدر. - -بالنسبة للإصدار 1، تكون الامتدادات المحلية متحفظة عمدًا: - -- يمكنهم استخدام `extensionData` لحالة وقت التشغيل الصغيرة الخاصة بهم -- يمكنهم الاتصال فقط بـ `appAction` المدرجة في `extension.json` -- تم تعطيل مساعدات SQL الأولية و`extensionFetch` الخارجية -- تم الإعلان عن أهداف الفتحات في `extension.json`، ولم يتم تثبيتها من خلال SQL - -يمنح هذا مساحات العمل المحلية سطحًا إضافيًا شبيهًا بسطح Obsidian دون السماح -يرث ملف الريبو التعسفي كل إمكانات الامتداد المدعوم بقاعدة البيانات. - -## كيفية استخدام التطبيقات لها - -يتم تنفيذ وضع الملف المحلي من خلال أدوات المساعدة المحلية لإطار العمل. -يعلن التطبيق عن جذور أنواع العناصر التي يمتلكها، ثم يقرأها ويكتبها -من خلال نفس سطح الإجراء الذي يستخدمه UI والوكيل بالفعل. - -بالنسبة للمحتوى، فهذا يعني: - -- يسرد `list-documents` ملفات `.md` و`.mdx` التي تم تكوينها. -- يقرأ `get-document` ملفًا محليًا محددًا. -- يقوم `update-document` بكتابة الملف المحلي المحدد. -- يقوم `create-document` بإنشاء ملف `.mdx` محلي جديد في المجلد المحدد. -- يحذف `delete-document` الملف المحلي. -- يتم تشغيل البحث عبر الملفات المحلية التي تم تكوينها. - -لا يعد نقل صفحات الملفات المحلية وإعادة تسميتها وإعادة ترتيبها من المحتوى UI -معتمد حتى الآن. قم بإجراء تلك العمليات في مساحة العمل أو باستخدام وكيل ترميز؛ ال -سيعكس الشريط الجانبي للمحتوى شجرة الملفات الناتجة. - -يُبقي هذا عقد الوكيل بسيطًا: يمكن للوكيل الاستمرار في استخدام المحتوى actions، -ويقرر هؤلاء actions ما إذا كان الهدف مدعومًا بـ SQL أو مدعومًا بالملف. - -يمكن للتطبيقات الأخرى أن تتبنى نفس النمط مع مرور الوقت. يمكن لتطبيق العروض التقديمية تعيين -`slides/*.mdx` إلى المجموعات، ويمكن لتطبيق الخطط تعيين `plans/*` لتخطيط المستندات، و -يمكن لتطبيق لوحات المعلومات تعيين `dashboards/*.mdx` إلى لوحات المعلومات. تلك الخاصة بالتطبيق -المجلدات عبارة عن اتفاقيات موضوعة فوق نفس عقد المنتج المحلي. - -## الملفات المحلية مقابل التصدير/الاستيراد - -يحتوي المحتوى على سير عمل ملفين مختلفين: - -| سير العمل | ماذا يحدث | -| ---------------------------- | ------------------------------------------------------------------------------------------------------------------- | -| تصدير/استيراد `/local-files` | يظل وضع قاعدة البيانات هو مصدر الحقيقة. تعد الملفات بمثابة سطح مزامنة واضح يمكنك تصديره وتحريره ومعاينته واستيراده. | -| وضع الملف المحلي | الملفات هي مصدر الحقيقة. يعمل الشريط الجانبي للمحتوى والمحرر مباشرة على الملفات المحلية. | - -استخدم التصدير/الاستيراد عندما تريد مراجعة الملفات من حين لآخر حول مساحة عمل مستضافة. -استخدم وضع الملف المحلي عندما يكون الريبو نفسه هو مساحة العمل. - -## التاريخ والتعاون - -يعتمد وضع الملف المحلي على سجل الملف الأصلي: - -- إجراء تغييرات مهمة على Git -- استخدم طلبات السحب للمراجعة -- السماح لوكلاء البرمجة بتحرير نفس الملفات مباشرة -- استخدم اختلافات الملفات العادية لفهم التغييرات - -يظل وضع قاعدة البيانات هو الأفضل لميزات التعاون المستضافة مثل -المشاركة والتعليقات وسجل الإصدارات المدعومة بـ SQL والتحرير المباشر لعدة مستخدمين. - -يمكن وضع مزامنة الموفر في أعلى أي من الوضعين. على سبيل المثال، يمكن لمستودع المستندات -أضف actions الذي يسحب المحتوى من CMS إلى ملفات MDX المحلية أو يدفع المحدد -الملفات المحلية تعود إلى CMS. - -## سلامة الإنتاج - -يمنح وضع الملف المحلي التطبيق actions إمكانية الوصول المباشر للكتابة إلى مساحة العمل التي تم تكوينها -ملفات. وهذا مناسب للتطوير المحلي وملف المستأجر الفردي الموثوق به -الجسور، ولكنها ليست نموذج أمان الإنتاج الافتراضي. - -عند `NODE_ENV=production`، يرفض إطار العمل وضع `local-files` إلا إذا -المجموعة: - -```bash -AGENT_NATIVE_ALLOW_LOCAL_FILES_IN_PRODUCTION=true -``` - -قم بتعيين ذلك فقط لنشر مستأجر واحد موثوق به حيث يمكن لكل من يمكنه استخدامه -يُسمح للتطبيق بقراءة الملفات التي تم تكوينها وكتابتها. بالنسبة للاستضافة العادية، -تطبيقات متعددة المستخدمين، استخدم وضع قاعدة البيانات والمشاركة المدعومة SQL. +للترحيل، احذف `mode: "local-files"` و`AGENT_NATIVE_MODE=local-files`، واحتفظ +بالجذور، وأضف `source`. تحافظ معرفات frontmatter على هوية الصفحة عند إعادة +التسمية أو النقل. وتستمر المكونات والامتدادات المحلية عبر الجسر المحلي الموثوق. diff --git a/packages/core/docs/content/locales/ar-SA/template-content.mdx b/packages/core/docs/content/locales/ar-SA/template-content.mdx index 42d98d0cb8..6c27b2d73f 100644 --- a/packages/core/docs/content/locales/ar-SA/template-content.mdx +++ b/packages/core/docs/content/locales/ar-SA/template-content.mdx @@ -93,9 +93,8 @@ description: "Obsidian مفتوح المصدر لـ MDX: قم بتحرير مل - **تنظيم الصفحات في شجرة** — تداخل بشكل لا نهائي، اسحب لإعادة ترتيب الصفحات المفضلة التي تستخدمها كثيرًا. - **البحث في كل شيء** من خلال البحث في النص الكامل عبر العناوين والمحتوى. - **تحرير ملفات Markdown/MDX المحلية مثل Obsidian.** استخدم طريقة العرض `/local-files` - لتصدير مساحة العمل الخاصة بك إلى الملفات، قم بتحريرها باستخدام أدواتك الخاصة، ثم قم بالمعاينة - التغييرات، ثم قم باستيرادها مرة أخرى. في وضع الملف المحلي، تتم كتابة المحتوى مباشرة إلى - الملف `.md` أو `.mdx` المحدد. + لربط مجلد بـ Files وتحريره بأدواتك ومزامنة التغييرات ومراجعة التعارضات + داخل تطبيق SQL. - **إنشاء كتل مخصصة تفاعلية غنية.** تسجيل مكونات React المحلية، أدخلها كـ MDX، واسمح للوكيل بإنشاء أو تحديث ملفات المكونات لـ مستنداتك. @@ -138,32 +137,19 @@ description: "Obsidian مفتوح المصدر لـ MDX: قم بتحرير مل التعليقات والأذونات والتعاون المباشر؛ المجلد المحلي صريح سطح المزامنة. -يمكن أيضًا تشغيل المحتوى في **وضع الملف المحلي**، حيث تكون الملفات هي مصدر -الحقيقة بدلاً من مستندات SQL. أضف `agent-native.json` إلى الريبو، اضبط -`mode: "local-files"`، وقم بتكوين الجذور مثل `docs/`، `blog/`، -`content/` و`resources/`. يقوم محرر المحتوى القياسي بعد ذلك بملء -الشريط الجانبي الأيسر من ملفات `.md`/`.mdx` المحلية ويكتب التعديلات مرة أخرى إلى -الملف المحدد من خلال المستند العادي actions. استخدم هذا لمستندات الريبو الأولى، -المدونات أو مكتبات الموارد أو المحتوى الشخصي بنمط Obsidian الذي يعمل بنظام MDX -المكونات؛ قم بالتبديل مرة أخرى إلى وضع قاعدة البيانات عندما تريد التعاون المستضاف و -المشاركة المدعومة بـ SQL. راجع [Local File Mode](/docs/local-file-mode) لمعرفة -تخطيط الريبو المستقل، التكوين، مكونات MDX المخصصة، المحلية -أدوات `extensions/` ودليل سلامة الإنتاج. +تستخدم المجلدات المحلية الآن نموذج SQL نفسه الذي تستخدمه كل الصفحات. تنتمي كل +صفحة إلى مساحة وقاعدة Files الخاصة بها؛ والمجلد هو محول مصدر لهذه القاعدة. راجع +[مصادر المجلدات المحلية](/docs/local-file-mode) للسياسات والتعارضات ومكونات MDX +والترحيل من الوضع القديم. -لتثبيت مهارة ملفات المحتوى المحلية في الريبو الحالي: +لفتح Content في مستودع موجود: ```bash -npx @agent-native/core@latest skills add content --mode local-files --scope project +npx @agent-native/core@latest content . ``` -يقوم المثبت بنسخ مهارة `content` لوكيل الترميز الخاص بك ويقوم بكتابة أو -تحديثات `agent-native.json` بجذور المحتوى لـ `docs/`، و`blog/`، و`content/`، -و `resources/`. عندما يكون تطبيق المحتوى المحلي، أو Agent Native Desktop، أو موثوقًا به -الجسر المحلي قيد التشغيل، ويجب على الوكلاء استخدام المحتوى actions مثل -`list-documents`، و`get-document`، و`edit-document`، و`update-document`، و -يكتب `share-local-file-document` بدلاً من نظام الملفات الخام. بدون ذلك المحلي -الجسر، لا تزال المهارة المثبتة تمنح الوكيل عقد تحرير الريبو لـ -تعديلات Markdown/MDX الآمنة. +يشغل CLI تطبيق Content العادي المدعوم بقاعدة البيانات، ويسجل مصدر المجلد، ويفتح +الاتصال من دون ضبط متغير بيئة خاص بالوضع. ## للمطورين diff --git a/packages/core/docs/content/locales/de-DE/local-file-mode.mdx b/packages/core/docs/content/locales/de-DE/local-file-mode.mdx index 7cabd36ca5..c67166f5b4 100644 --- a/packages/core/docs/content/locales/de-DE/local-file-mode.mdx +++ b/packages/core/docs/content/locales/de-DE/local-file-mode.mdx @@ -1,525 +1,71 @@ --- -title: "Lokaler Dateimodus" -description: "Führen Sie agentennative Apps mit lokalen Markdown-, MDX- und anderen Repo-Dateien als Quelle der Wahrheit aus – einschließlich MDX-Dokumenten im Obsidian-Stil mit benutzerdefinierten Komponenten." +title: "Lokale Ordnerquellen" +description: "Markdown- und MDX-Ordner mit den datenbankgestützten Content-Bereichen verbinden." --- -# Lokaler Dateimodus +# Lokale Ordnerquellen -Im lokalen Dateimodus kann eine agentennative App ihre normale UI- und Aktionsoberfläche anhängen -direkt zu Dateien in einem Repository oder Arbeitsbereich. Die App fühlt sich immer noch wie eine gehostete App an -Produkt, aber seine Listenansichten, Editor- und Agent-Tools lesen und schreiben lokale Dateien -anstelle von SQL-gestützten App-Datensätzen. +Content verwendet nur noch ein Datenmodell: Jede Seite liegt in SQL, gehört zu +einem **Bereich** und ist Mitglied der Systemdatenbank **Files** dieses Bereichs. +Ein lokaler Ordner ist eine mit Files verbundene Quelle, kein zweiter App-Modus. +Einen `AGENT_NATIVE_MODE`-Schalter gibt es nicht mehr. -Die erste Implementierung befindet sich in der Inhaltsvorlage: Die linke Seitenleiste ist -wird aus lokalen `.md`- und `.mdx`-Dateien gefüllt. Wenn Sie eine Seite auswählen, wird der Standard geöffnet -Inhaltseditor und Speichern schreibt zurück in die ausgewählte Datei. Dieselben Dateien können -kann auch von Codex, Claude Code, dem Agent-Native-Sidebar-Agenten oder einem normalen bearbeitet werden -Herausgeber. +Jeder Benutzer erhält einen persönlichen Bereich, jede zugängliche Organisation +einen Organisationsbereich. Der persönliche **Workspaces**-Katalog bestimmt, +welche Bereiche erscheinen. Files ist eine normale Content-Datenbank; ihre +Eigenschaften, Filter, Sortierungen, Gruppen und gespeicherten Ansichten können +auch die Seitenleiste darstellen. -Was den Inhalt angeht, fühlt sich das Produkt dadurch wie Open-Source-Obsidian für MDX an: -Ihre Dokumente bleiben als Dateien bestehen, während die App einen visuellen Editor, Agentenaktionen, hinzufügt -gemeinsame Kopien und umfangreiche interaktive MDX-Komponenten. +## Ordner verbinden -Verwenden Sie den lokalen Dateimodus, wenn Sie einen Repo-First-Workflow wünschen: +Wählen Sie den Ordner im Browser oder in Agent Native Desktop. Verbinden Sie ihn +mit einem vorhandenen Bereich oder erstellen Sie einen privaten ordnergestützten +Bereich. SQL speichert nur die undurchsichtige Verbindungs-ID, relative Pfade, +Hashes und Quellmetadaten — nie absoluten Pfad, Browser-Handle oder Dateitext. -- ein Dokumenten-Repo mit `docs/*.mdx` -- ein Blog mit `blog/*.mdx` -- Ressourcen wie Positionierung, Nachrichten oder Teamnotizen in `resources/*.md` -- eine persönliche Wissensdatenbank im Obsidian-Stil mit einem umfangreicheren MDX-Editor -- Dokumente, die interaktive benutzerdefinierte MDX-Blöcke benötigen, die aus lokalem React-Code generiert werden -- App-Artefakte, die für Programmierer leicht zu prüfen und zu patchen sein sollten +Verfügbar sind `database_primary`, `source_primary` und +`reviewed_bidirectional`. Die Synchronisierung materialisiert Dateien als +normale SQL-Seiten. Gleichzeitige Änderungen werden zur Prüfung erfasst, statt +eine Seite zu überschreiben. Beim Trennen bleiben Seiten und Dateien erhalten. -Verwenden Sie den Datenbankmodus, wenn Sie die gehostete kollaborative App-Erfahrung wünschen: -Mehrbenutzerfreigabe, SQL-gestützte Berechtigungen, Kommentare, Versionsverlauf und -Produktionshosting ohne Zugriff auf das lokale Dateisystem. - -## Das mentale Modell - -Es gibt zwei Source-of-Truth-Modi: - -| Modus | Quelle der Wahrheit | Am besten für | -| ------------------ | ----------------------------------------------- | ------------------------------------------------------------------------------------------ | -| Datenbankmodus | SQL Zeilen bis Drizzle | Gehostete Apps, Zusammenarbeit, Freigabe, Kommentare, Versionsverlauf | -| Lokaler Dateimodus | Repo-Dateien deklariert von `agent-native.json` | Lokale/Entwickler-Workflows, Git-Überprüfung, Coding-Agent-Änderungen, dateinativer Inhalt | - -UI und Agentenaktionen sollten in beiden Modi die gleiche Form behalten. Ein Inhalt -Editor bearbeitet weiterhin Dokumente; Der Unterschied besteht darin, ob diese Dokumente aufgelöst werden -in SQL Zeilen oder lokale Dateien. - - - -```html -
-
-
Content-UI
-
- Agent + Actions
list/get/update-document -
-
- -
-
- DatenbankmodusSQL rows via Drizzlegehostet · Teilen · Kommentare · Verlauf -
-
- Local File Moderepo files via agent-native.jsonGit-Review · Coding-Agent-Edits -
-
-
-``` - -```css -.diagram-mode { - display: flex; - align-items: center; - gap: 14px; - flex-wrap: wrap; -} -.diagram-mode .diagram-col { - display: flex; - flex-direction: column; - gap: 10px; -} -.diagram-mode .diagram-arrow { - font-size: 22px; - line-height: 1; -} -.diagram-mode .resolve { - display: flex; - gap: 12px; - flex-wrap: wrap; -} -.diagram-mode .diagram-panel { - display: flex; - flex-direction: column; - gap: 4px; - padding: 12px 14px; -} -``` - -
- -## Beispiel-Repo - -Ein Inhaltsarbeitsbereich kann so klein sein: - - - -Im lokalen Dateimodus zeigt die Inhaltsseitenleiste `docs/`, `blog/` und -`resources/` Bäume als Seiten. Wenn Sie `docs/getting-started.mdx` auswählen, wird das geöffnet -Datei im Standard-Inhaltseditor; Bearbeitung im UI schreibt zurück nach -`docs/getting-started.mdx`. - -`components/` ist kein Inhaltsstammverzeichnis. Es handelt sich um eine Vorschau-Komponentenbibliothek, die MDX -Dateien können importiert oder referenziert werden. Der Editor kann einfache lokale MDX-Komponenten rendern -ohne dass Sie die gesamte Content-App klonen oder forken müssen. - -`extensions/` ist ebenfalls kein Inhaltsstammverzeichnis. Es handelt sich um eine lokale Erweiterungsbibliothek: -kleine Sandbox-Widgets, die in App-Slots gerendert werden können, während ihre Quelle drin bleibt -das Repo. - -## Inhalt in ein Repo installieren - -Installieren Sie für vorhandene Dokumente, Blogs oder MDX-Arbeitsbereiche die lokalen Inhaltsdateien -Fähigkeit: +## Aus einem Repository starten ```bash -npx @agent-native/core@latest skills add content --mode local-files --scope project +npx @agent-native/core@latest content . ``` -Dadurch wird der `content`-Skill in die Agent-Skill-Ordner des Repositorys kopiert und geschrieben -oder aktualisiert `agent-native.json` mit den Inhaltsstandards: - -- `mode: "local-files"` auf Arbeitsbereichsebene -- `apps.content.mode: "local-files"` -- Inhaltsstämme für `docs/`, `blog/`, `content/` und `resources/` -- `components/` für lokale MDX-Komponenten -- `extensions/` für lokale Erweiterungs-Widgets - -Der installierte Skill weist Programmieragenten an, Content actions zu verwenden -(`list-documents`, `get-document`, `edit-document`, `update-document`, -`share-local-file-document` und Komponentendatei actions) bei einer lokalen Content-App -oder Agent Native Desktop Bridge macht sie verfügbar. Wenn keine Bridge läuft, wird der Skill -greift auf sichere direkte Repo-Bearbeitungen zurück und behält dabei Frontmatter, Importe, JSX, -und unbekanntes MDX. +Der Befehl startet die normale datenbankgestützte Content-App, registriert den +Ordner als Quelle und öffnet die Verbindung — ohne Modus-Umgebungsvariable. ## Konfiguration -Fügen Sie `agent-native.json` zum Repository oder Arbeitsbereichsstamm hinzu: - ```json { "version": 1, "apps": { "content": { - "mode": "local-files", "roots": [ { "name": "Docs", "path": "docs", "kind": "docs", - "extensions": [".md", ".mdx"] - }, - { - "name": "Blog", - "path": "blog", - "kind": "blog", - "extensions": [".md", ".mdx"] - }, - { - "name": "Resources", - "path": "resources", - "kind": "resources", - "extensions": [".md", ".mdx"] + "extensions": [".md", ".mdx"], + "source": { + "type": "local-folder", + "connectionId": "local-folder:", + "truthPolicy": "source_primary" + } } ], "components": "components", - "extensions": "extensions", - "hide": ["**/_*.md", "**/_*.mdx"] + "extensions": "extensions" } } } ``` -Sie können lokale Dateien auch mit `AGENT_NATIVE_MODE=local-files` oder -`AGENT_NATIVE_DATA_MODE=local-files`; Das Manifest wird bevorzugt, weil es -dokumentiert den Ordnervertrag im Repo selbst. - -## Inhaltsdateiformat - -Der Inhalt lautet Markdown und MDX. Frontmatter enthält Seitenmetadaten und der Text ist -das bearbeitbare Dokument: - -```mdx ---- -title: "Erste Schritte" -icon: "sparkles" -isFavorite: true -updatedAt: "2026-06-12T20:00:00.000Z" ---- - -# Erste Schritte - -Use to show framework-specific code. -``` - -Der Titel stammt von `title` frontmatter, sofern vorhanden, andernfalls von -Dateiname. Der Editor behält die MDX-Quelle bei, die er noch nicht visuell bearbeiten kann, also -Codierungsagenten und normale Texteditoren bleiben sichere Fluchtwege. - -## Benutzerdefinierte MDX-Komponenten - -Inhalte können eine Vorschau lokaler Komponenten aus dem konfigurierten `components`-Ordner anzeigen. -Dies ist für MDX-Komponenten im Dokumentstil wie Registerkarten, Beschriftungen und Pakete gedacht. -Installieren Sie Snippets oder Framework-spezifische Codeblöcke. - -Fügen Sie beispielsweise eine interaktive Komponente neben Ihrem Inhalt hinzu: - -```tsx -// components/ImpactCounter.tsx -import { useState } from "react"; - -export function ImpactCounter({ - label = "points", - accent = "blue", - featured = false, -}: { - label?: string; - accent?: "blue" | "green" | "purple"; - featured?: boolean; -}) { - const [count, setCount] = useState(3); - const accentClass = - accent === "green" - ? "border-green-300 bg-green-50" - : accent === "purple" - ? "border-purple-300 bg-purple-50" - : "border-blue-300 bg-blue-50"; - - return ( -
-
Launch impact
-
- {count} {label} -
- {featured ?
Featured metric
: null} - -
- ); -} - -export const ImpactCounterInputs = { - label: { - type: "string", - label: "Metric label", - default: "points", - }, - accent: { - type: "select", - label: "Accent", - options: ["blue", "green", "purple"], - default: "blue", - }, - featured: { - type: "boolean", - label: "Featured", - default: false, - }, -}; -``` - -Dann verwenden Sie es aus einer beliebigen lokalen MDX-Datei: - -```mdx ---- -title: "Notizen starten" ---- - -# Notizen starten - - -``` - -Der Content-Entwicklungsserver erkennt PascalCase-benannte Exporte und PascalCase-Standardwerte -Exporte aus `.tsx`-, `.jsx`-, `.ts`- und `.js`-Dateien unter `components/`. Diese -Komponenten werden im Editor gerendert und im Slash-Menü darunter angezeigt -**Lokale Komponenten**. Durch das Einfügen eines Schrägstrichs wird ein minimales Tag erstellt, z. B. -``; Fügen Sie bei Bedarf Requisiten zur MDX-Quelle hinzu. - -Die Komponentenausführung ist absichtlich eine lokale Dev/Desktop-Bridge-Funktion, nicht -einfacher Zugriff auf gehostete Browserordner. Wenn Sie `content.agent-native.com` öffnen, -Wählen Sie **Lokale Dateien** und einen Ordner in Chrome, den die App lesen und schreiben kann -die Dateien `.md` und `.mdx` über den Browser-Dateisystemzugriff auf API, aber -Chrome stellt keinen absoluten Ordnerpfad für die Kompilierung durch Vite bereit -`components/*.tsx`. Um benutzerdefinierte React-Komponenten in der Vorschau anzuzeigen und im laufenden Betrieb neu zu laden, führen Sie -Inhalte lokal oder verwenden Sie Agent Native Desktop, damit die vertrauenswürdige lokale Bridge dies tun kann -registrieren Sie den ausgewählten Arbeitsbereich beim lokalen Content-Entwicklungsserver. In diesem Modus -Änderungen an vorhandenen Komponentendateien, Hot-Reload über Vite und Hinzufügen von oder -Durch das Entfernen von Komponentendateien werden die Komponentenregistrierung und das Slash-Menü neu geladen. - -Agenten können auch mit diesen registrierten Komponentendateien arbeiten. Verwenden -`list-local-component-files`, um dann die registrierte Arbeitsbereichs-ID zu finden -`write-local-component-file` zum Erstellen oder Aktualisieren von `.tsx`, `.jsx`, `.ts` oder -`.js`-Dateien im Ordner `components/` des Arbeitsbereichs. Die MDX-Dateien bleiben die -Quelle der Wahrheit für die Komponentenverwendung; Die Komponentendateien bleiben normale Repo -Quelldateien mit Git überprüft. - -Wenn eine Komponente Eingabemetadaten exportiert, wählen Sie die Komponente im Editor aus -zeigt eine Schaltfläche zum Bearbeiten in der oberen rechten Ecke der Komponente an. Unterstützte Eingabetypen -sind `string`, `textarea`, `number`, `boolean` und `select`. Das Formular schreibt -wechselt zurück zum MDX-Tag, sodass lokale Dateien die Quelle der Wahrheit bleiben. Die -Metadaten können als `ComponentNameInputs`, `ComponentNameConfig.inputs`, -`Component.inputs` oder `agentNative.inputs`. - -Einfache Komponenten-Tags mit Literal-Requisiten können inline in der Vorschau angezeigt werden: - -```mdx - - - - -This setting affects production deploys. - - -``` - -Komplexe JSX-Ausdrücke bleiben in der Quelle erhalten. Wenn der Editor dies nicht sicher tun kann -Sehen Sie sich noch eine Vorschau einer Komponenten-Requisite an, es wird ein Warnungsplatzhalter angezeigt und nicht -Daten werden stillschweigend gelöscht. - -## Lokale Dateien teilen - -Lokale Dateien werden nicht direkt freigegeben, da andere Benutzer einen Pfad darauf nicht lesen können -Ihre Maschine. Mit der Schaltfläche „Teilen“ in der Symbolleiste „Inhalt“ wird ein -datenbankgestützte Kopie der ausgewählten Datei, navigiert zu dieser Kopie und öffnet die -normales Freigabe-Popover. Die ursprüngliche lokale Datei verbleibt unter „Lokale Dateien“. die -Datenbankkopie erscheint unter „Freigegebene Kopien“ im lokalen Dateimodus und verwendet die -Standardmodell für die gemeinsame Nutzung von Dokumenten. - -## Lokale Erweiterungen - -Der lokale Dateimodus kann auch Repo-gestützte Erweiterungen aus der konfigurierten Datei laden -`extensions`-Ordner. Jede Erweiterung ist ein Verzeichnis mit einem `extension.json` -Manifest und eine HTML-Eintragsdatei: - -```text -extensions/ - doc-status/ - extension.json - index.html -``` - -```json -{ - "id": "doc-status", - "name": "Doc Status", - "description": "Shows metadata for the selected Content file.", - "entry": "index.html", - "slots": ["content.sidebar.bottom"], - "permissions": { - "appActions": ["list-documents"], - "extensionData": true - } -} -``` - -`index.html` ist das gleiche Alpine/Tailwind-Erweiterungskörperformat, das von Normal verwendet wird -datenbankgestützte Erweiterungen. Wenn die Content-App eine lokale Erweiterung erkennt, -deklariert `content.sidebar.bottom` und rendert diese Erweiterung am Ende von -die Inhaltsseitenleiste. Der Host übergibt `window.slotContext` mit dem ausgewählten -Dokument-ID, Titel, Quellmetadaten und ob sich der Inhalt im lokalen Dateimodus befindet. - -Lokale Erweiterungen werden von der App in der Vorschau angezeigt, aber als Dateien bearbeitet. Die Erweiterungen -Liste zeigt sie mit einem Lokaldatei-Logo an und der Ganzseitenbetrachter verweist darauf -die Eintragsdatei. Von SQL unterstützte Erweiterung actions wie Aktualisieren, Löschen, Teilen und -Verlauf gilt nicht; Verwenden Sie Ihren Editor, Codex-, Claude-Code oder den Git-Verlauf für -Quellenänderungen. - -Für v1 sind lokale Erweiterungen absichtlich konservativ: - -- Sie können `extensionData` für ihren eigenen kleinen Laufzeitstatus verwenden -- Sie können nur die in `extension.json` aufgeführten `appAction`s aufrufen -- rohe SQL-Helfer und externe `extensionFetch` sind deaktiviert -- Slot-Ziele werden in `extension.json` deklariert und nicht über SQL installiert - -Dadurch erhalten lokale Arbeitsbereiche eine Obsidian-ähnliche Plugin-Oberfläche, ohne dass eine -Beliebige Repo-Dateien erben alle Funktionen einer datenbankgestützten Erweiterung. - -## Wie Apps es nutzen - -Der lokale Dateimodus wird durch die lokalen Artefakt-Helfer des Frameworks implementiert. -Eine App deklariert Roots für die Artefakttypen, die sie besitzt, und liest und schreibt dann -über dieselbe Aktionsoberfläche, die UI und Agent bereits verwenden. - -Für Inhalte bedeutet das: - -- `list-documents` listet konfigurierte `.md`- und `.mdx`-Dateien auf. -- `get-document` liest eine ausgewählte lokale Datei. -- `update-document` schreibt die ausgewählte lokale Datei. -- `create-document` erstellt eine neue lokale `.mdx`-Datei im ausgewählten Ordner. -- `delete-document` löscht die lokale Datei. -- Die Suche erfolgt über die konfigurierten lokalen Dateien. - -Das Verschieben, Umbenennen und Neuanordnen lokaler Dateiseiten aus dem Inhalt UI ist nicht möglich -wird noch unterstützt. Führen Sie diese Vorgänge im Arbeitsbereich oder mit einem Codierungsagenten aus. die -Die Inhaltsseitenleiste spiegelt den resultierenden Dateibaum wider. - -Dadurch bleibt der Agentenvertrag einfach: Der Agent kann weiterhin Inhalte actions verwenden, -und diese actions entscheiden, ob das Ziel SQL-backed oder file-backed ist. - -Andere Apps können im Laufe der Zeit dasselbe Muster annehmen. Eine Slides-App kann Karten erstellen -`slides/*.mdx` zu Decks, eine Pläne-App kann `plans/*` zu Plandokumenten zuordnen und ein -Dashboards-App kann `dashboards/*.mdx` Dashboards zuordnen. Diese App-spezifischen -Ordner sind Konventionen, die über demselben lokalen Artefaktvertrag liegen. - -## Lokale Dateien vs. Export/Import - -Inhalt hat zwei verschiedene Datei-Workflows: - -| Workflow | Was passiert | -| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/local-files` Export/Import | Der Datenbankmodus bleibt die Quelle der Wahrheit. Dateien sind eine explizite Synchronisierungsoberfläche, die Sie exportieren, bearbeiten, in der Vorschau anzeigen und importieren können. | -| Lokaler Dateimodus | Dateien sind die Quelle der Wahrheit. Die Inhaltsseitenleiste und der Editor arbeiten direkt mit lokalen Dateien. | - -Verwenden Sie Export/Import, wenn Sie gelegentlich Dateien in einem gehosteten Arbeitsbereich überprüfen möchten. -Verwenden Sie den lokalen Dateimodus, wenn das Repo selbst der Arbeitsbereich ist. - -## Geschichte und Zusammenarbeit - -Der lokale Dateimodus basiert auf dem dateinativen Verlauf: - -- übertragen Sie wichtige Änderungen an Git -- Pull-Anfragen zur Überprüfung verwenden -- Lassen Sie Codierungsagenten dieselben Dateien direkt bearbeiten -- Verwenden Sie normale Dateiunterschiede, um Änderungen zu verstehen - -Der Datenbankmodus eignet sich weiterhin besser für gehostete Kollaborationsfunktionen wie -Freigabe, Kommentare, SQL-gestützter Versionsverlauf und Live-Mehrbenutzerbearbeitung. - -Die Anbietersynchronisierung kann über beide Modi gelegt werden. Beispielsweise kann ein Dokumenten-Repository -Fügen Sie actions hinzu, die Inhalte von einem CMS in lokale MDX-Dateien ziehen oder ausgewählte Dateien per Push übertragen -lokale Dateien zurück zu diesem CMS. - -## Produktionssicherheit - -Der lokale Dateimodus gewährt der App-Actions direkten Schreibzugriff auf den konfigurierten Arbeitsbereich -Dateien. Dies eignet sich für die lokale Entwicklung und vertrauenswürdige Single-Tenant-Dateien -Brücken, aber es ist nicht das Standard-Produktionssicherheitsmodell. - -Bei `NODE_ENV=production` verweigert das Framework den `local-files`-Modus, es sei denn, Sie -set: - -```bash -AGENT_NATIVE_ALLOW_LOCAL_FILES_IN_PRODUCTION=true -``` - -Legen Sie dies nur für eine vertrauenswürdige Einzelmandantenbereitstellung fest, die jeder verwenden kann -Die App darf die konfigurierten Dateien lesen und schreiben. Für normales Hosting: -Mehrbenutzer-Apps, verwenden Sie den Datenbankmodus und SQL-gestützte Freigabe. +Zur Migration `mode: "local-files"` und +`AGENT_NATIVE_MODE=local-files` entfernen, Roots behalten und `source` +ergänzen. Frontmatter-IDs bewahren die Seitenidentität bei Umbenennungen und +Verschiebungen. Lokale Komponenten und Erweiterungen verwenden weiter die +vertrauenswürdige lokale Bridge. diff --git a/packages/core/docs/content/locales/de-DE/template-content.mdx b/packages/core/docs/content/locales/de-DE/template-content.mdx index ef934039df..6a5c26fa08 100644 --- a/packages/core/docs/content/locales/de-DE/template-content.mdx +++ b/packages/core/docs/content/locales/de-DE/template-content.mdx @@ -91,9 +91,8 @@ Wenn Sie die App öffnen, sehen Sie neben dem Editor einen Seitenbaum. Der Agent - **Seiten in einer Baumstruktur organisieren** – unbegrenzt verschachteln, zum Neuanordnen ziehen, Lieblingsseiten, die Sie häufig verwenden. - **Alles durchsuchen** mit Volltextsuche über Titel und Inhalte hinweg. - **Bearbeiten Sie lokale Markdown/MDX-Dateien wie Obsidian.** Verwenden Sie die `/local-files`-Ansicht - Um Ihren Arbeitsbereich in Dateien zu exportieren, diese in Ihren eigenen Tools zu bearbeiten und eine Vorschau anzuzeigen - Änderungen und importieren Sie sie zurück. Im lokalen Dateimodus schreibt der Inhalt direkt nach - die ausgewählte `.md`- oder `.mdx`-Datei. + um einen Ordner mit Files zu verbinden, ihn mit eigenen Werkzeugen zu bearbeiten, + Änderungen zu synchronisieren und Konflikte in der SQL-App zu prüfen. - **Generieren Sie umfangreiche interaktive benutzerdefinierte Blöcke.** Registrieren Sie lokale React-Komponenten, Fügen Sie sie als MDX ein und lassen Sie den Agent Komponentendateien erstellen oder aktualisieren Ihre Dokumente. @@ -136,32 +135,19 @@ als Überprüfungsoberfläche. Die gehostete App bleibt die Quelle der Wahrheit Kommentare, Berechtigungen und Live-Zusammenarbeit; Der lokale Ordner ist ein expliziter Oberfläche synchronisieren. -Inhalte können auch im **Lokalen Dateimodus** ausgeführt werden, wobei Dateien die Quelle sind -Wahrheit statt SQL Dokumente. `agent-native.json` zu einem Repo hinzufügen, set -`mode: "local-files"` und konfigurieren Sie Roots wie `docs/`, `blog/` -`content/` und `resources/`. Der Standard-Inhaltseditor füllt dann dessen -linkte Seitenleiste aus diesen lokalen `.md`/`.mdx`-Dateien und schreibt Änderungen zurück in die -ausgewählte Datei durch das normale Dokument actions. Verwenden Sie dies für Repo-First-Dokumente -Blogs, Ressourcenbibliotheken oder persönliche Inhalte im Obsidian-Stil mit MDX-Unterstützung -Komponenten; Wechseln Sie zurück in den Datenbankmodus, wenn Sie eine gehostete Zusammenarbeit wünschen und -SQL-gestütztes Teilen. Siehe [Local File Mode](/docs/local-file-mode) für -eigenständiges Repo-Layout, Konfiguration, benutzerdefinierte MDX-Komponenten, lokal -`extensions/`-Widgets und Leitfaden zur Produktionssicherheit. +Lokale Ordner verwenden jetzt dasselbe SQL-Modell wie alle Seiten. Jede Seite +gehört zu einem Bereich und seiner Files-Datenbank; der Ordner ist ein +Quelladapter. Siehe [Lokale Ordnerquellen](/docs/local-file-mode) für Richtlinien, +Konflikte, MDX-Komponenten und die Migration vom alten Modus. -So installieren Sie den Content Local-Files-Skill in einem vorhandenen Repository: +So öffnen Sie Content für ein vorhandenes Repository: ```bash -npx @agent-native/core@latest skills add content --mode local-files --scope project +npx @agent-native/core@latest content . ``` -Das Installationsprogramm kopiert den `content`-Skill für Ihren Coding-Agent und schreibt oder -aktualisiert `agent-native.json` mit Inhaltswurzeln für `docs/`, `blog/`, `content/` -und `resources/`. Wenn eine lokale Content-App, Agent Native Desktop oder vertrauenswürdig -Lokale Bridge läuft, Agenten sollten Content actions wie -`list-documents`, `get-document`, `edit-document`, `update-document` und -`share-local-file-document` anstelle von unformatierten Dateisystem-Schreibvorgängen. Ohne dieses lokale -bridge, der installierte Skill gibt dem Agent weiterhin den Repo-Bearbeitungsvertrag für -sichere Markdown/MDX-Änderungen. +Die CLI startet Content mit der normalen Datenbank, registriert die Ordnerquelle +und öffnet die Verbindung, ohne eine Modus-Umgebungsvariable zu setzen. ## Für Entwickler diff --git a/packages/core/docs/content/locales/es-ES/local-file-mode.mdx b/packages/core/docs/content/locales/es-ES/local-file-mode.mdx index 1f182cae05..7d0905212f 100644 --- a/packages/core/docs/content/locales/es-ES/local-file-mode.mdx +++ b/packages/core/docs/content/locales/es-ES/local-file-mode.mdx @@ -1,527 +1,69 @@ --- -title: "Modo de archivo local" -description: "Ejecute aplicaciones nativas del agente con Markdown, MDX y otros archivos de repositorio locales como fuente de verdad, incluidos documentos MDX estilo Obsidian con componentes personalizados." +title: "Fuentes de carpetas locales" +description: "Conecte carpetas Markdown y MDX a los espacios de Content respaldados por base de datos." --- -# Modo de archivo local +# Fuentes de carpetas locales -El modo de archivo local permite que una aplicación nativa del agente adjunte su UI normal y su superficie de acción -directamente a archivos en un repositorio o espacio de trabajo. La aplicación todavía se siente como si estuviera alojada -producto, pero sus vistas de lista, editor y herramientas de agente leen y escriben archivos locales -en lugar de registros de aplicaciones respaldados por SQL. +Content tiene un único modelo de datos: cada página se guarda en SQL, pertenece +a un **espacio** y figura en la base del sistema **Files** de ese espacio. Una +carpeta local es una fuente conectada a Files, no otro modo de la aplicación. Ya +no existe un interruptor `AGENT_NATIVE_MODE`. -La primera implementación está en la plantilla de Contenido: la barra lateral izquierda es -rellenado a partir de archivos locales `.md` y `.mdx`; al seleccionar una página se abre el estándar -Editor de contenido y guardar escrituras en el archivo seleccionado. Los mismos archivos pueden -También puede ser editado por Codex, Código Claude, el agente de la barra lateral Agent-Native o un normal -editor. +Cada usuario tiene un espacio personal, cada organización accesible tiene el +suyo, y el catálogo personal **Workspaces** decide qué espacios aparecen. Files +es una base de Content normal: sus propiedades, filtros, orden, agrupación y +vistas guardadas también pueden representar la barra lateral. -En cuanto al contenido, esto hace que el producto parezca Obsidian de código abierto para MDX: -tus documentos se guardan como archivos, mientras que la aplicación agrega un editor visual, el agente actions -Copias que se pueden compartir y componentes interactivos enriquecidos de MDX. +## Conectar una carpeta -Utilice el modo de archivo local cuando desee un flujo de trabajo basado en el repositorio: +Elija una carpeta mediante el navegador o Agent Native Desktop y conéctela a un +espacio existente, o cree un espacio privado respaldado por esa carpeta. SQL +solo guarda el identificador opaco, rutas relativas, hashes y metadatos; nunca +la ruta absoluta, el identificador del navegador ni el cuerpo bruto del archivo. -- un repositorio de documentos con `docs/*.mdx` -- un blog con `blog/*.mdx` -- recursos como posicionamiento, mensajería o notas del equipo en `resources/*.md` -- una base de conocimientos personal estilo Obsidian con un editor MDX más completo -- Documentos que necesitan bloques MDX personalizados e interactivos generados a partir del código React local -- artefactos de aplicaciones que deberían ser fáciles de inspeccionar y parchar para los agentes de codificación +Las políticas son `database_primary`, `source_primary` y +`reviewed_bidirectional`. La sincronización materializa los archivos como +páginas SQL normales. Los cambios simultáneos crean un cambio revisable en vez +de sobrescribir un lado. Desconectar conserva las páginas y los archivos. -Utilice el modo de base de datos cuando desee disfrutar de la experiencia de la aplicación colaborativa alojada: -Compartir entre varios usuarios, permisos respaldados por SQL, comentarios, historial de versiones y -alojamiento de producción sin acceso al sistema de archivos local. - -## El modelo mental - -Hay dos modos de fuente de verdad: - -| Modo | Fuente de la verdad | Mejor para | -| --------------------- | ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -| Modo de base de datos | Filas SQL hasta Drizzle | Aplicaciones alojadas, colaboración, uso compartido, comentarios, historial de versiones | -| Modo de archivo local | Archivos de repositorio declarados por `agent-native.json` | Flujos de trabajo locales/de desarrollo, revisión de Git, ediciones del agente de codificación, contenido nativo de archivos | - -El UI y el agente actions deben mantener la misma forma en ambos modos. Un contenido -el editor aún edita documentos; la diferencia es si esos documentos resuelven -a filas SQL o archivos locales. - - - -```html -
-
-
UI de contenido
-
- Agente + acciones
list/get/update-document -
-
- -
-
- Modo base de datosSQL rows via Drizzlealojado · compartir · comentarios · historial -
-
- Local File Moderepo files via agent-native.jsonrevisión Git · ediciones del agente de código -
-
-
-``` - -```css -.diagram-mode { - display: flex; - align-items: center; - gap: 14px; - flex-wrap: wrap; -} -.diagram-mode .diagram-col { - display: flex; - flex-direction: column; - gap: 10px; -} -.diagram-mode .diagram-arrow { - font-size: 22px; - line-height: 1; -} -.diagram-mode .resolve { - display: flex; - gap: 12px; - flex-wrap: wrap; -} -.diagram-mode .diagram-panel { - display: flex; - flex-direction: column; - gap: 4px; - padding: 12px 14px; -} -``` - -
- -## Repositorio de ejemplo - -Un espacio de trabajo de contenido puede ser tan pequeño como este: - - - -En el modo de archivo local, la barra lateral de Contenido muestra `docs/`, `blog/` y -`resources/` árboles como páginas. Al seleccionar `docs/getting-started.mdx` se abre eso -archivo en el editor de contenido estándar; la edición en el UI escribe de nuevo en -`docs/getting-started.mdx`. - -`components/` no es una raíz de contenido. Es una biblioteca de componentes de vista previa que MDX -los archivos pueden importarse o hacer referencia. El editor puede renderizar componentes MDX locales simples -sin necesidad de clonar o bifurcar toda la aplicación de contenido. - -`extensions/` tampoco es una raíz de contenido. Es una biblioteca de extensión local: -pequeños widgets aislados que pueden renderizarse en espacios de aplicaciones mientras su fuente permanece en -el repositorio. - -## Instalar contenido en un repositorio - -Para documentos, blogs o espacios de trabajo MDX existentes, instale los archivos locales de contenido -habilidad: +## Iniciar desde un repositorio ```bash -npx @agent-native/core@latest skills add content --mode local-files --scope project +npx @agent-native/core@latest content . ``` -Esto copia la habilidad `content` en las carpetas de habilidades del agente del repositorio y escribe -o actualiza `agent-native.json` con los valores predeterminados de contenido: - -- `mode: "local-files"` a nivel del espacio de trabajo -- `apps.content.mode: "local-files"` -- raíces de contenido para `docs/`, `blog/`, `content/` y `resources/` -- `components/` para componentes locales MDX -- `extensions/` para widgets de extensión local - -La habilidad instalada indica a los agentes de codificación que utilicen Content actions -(`list-documents`, `get-document`, `edit-document`, `update-document`, -`share-local-file-document` y archivo de componente actions) cuando se trata de una aplicación de contenido local -o Agent Native Desktop Bridge los expone. Si no hay ningún puente en funcionamiento, la habilidad -recurre a ediciones directas seguras del repositorio mientras conserva el frontmatter, las importaciones, JSX, -y desconocido MDX. +El comando inicia Content con su base de datos normal, registra la carpeta como +fuente y abre la conexión. No define una variable de modo. ## Configuración -Agregue `agent-native.json` al repositorio o a la raíz del espacio de trabajo: - ```json { "version": 1, "apps": { "content": { - "mode": "local-files", "roots": [ { "name": "Docs", "path": "docs", "kind": "docs", - "extensions": [".md", ".mdx"] - }, - { - "name": "Blog", - "path": "blog", - "kind": "blog", - "extensions": [".md", ".mdx"] - }, - { - "name": "Resources", - "path": "resources", - "kind": "resources", - "extensions": [".md", ".mdx"] + "extensions": [".md", ".mdx"], + "source": { + "type": "local-folder", + "connectionId": "local-folder:", + "truthPolicy": "source_primary" + } } ], "components": "components", - "extensions": "extensions", - "hide": ["**/_*.md", "**/_*.mdx"] + "extensions": "extensions" } } } ``` -También puedes habilitar archivos locales con `AGENT_NATIVE_MODE=local-files` o -`AGENT_NATIVE_DATA_MODE=local-files`; se prefiere el manifiesto porque -documenta el contrato de la carpeta en el propio repositorio. - -## Formato de archivo de contenido - -El contenido dice Markdown y MDX. Frontmatter contiene metadatos de la página y el cuerpo es -el documento editable: - -```mdx ---- -title: "Empezando" -icon: "sparkles" -isFavorite: true -updatedAt: "2026-06-12T20:00:00.000Z" ---- - -# Empezando - -Use to show framework-specific code. -``` - -El título proviene del frontmatter `title` cuando está presente; de lo contrario, del -nombre de archivo. El editor conserva la fuente MDX que aún no puede editar visualmente, por lo que -Los agentes de codificación y los editores de texto normales siguen siendo puertas de escape seguras. - -## Componentes MDX personalizados - -El contenido puede obtener una vista previa de los componentes locales desde la carpeta `components` configurada. -Esto está destinado a componentes MDX de estilo documentos, como pestañas, llamadas y paquetes -instale fragmentos o bloques de código específicos del marco. - -Por ejemplo, agregue un componente interactivo junto a su contenido: - -```tsx -// components/ImpactCounter.tsx -import { useState } from "react"; - -export function ImpactCounter({ - label = "points", - accent = "blue", - featured = false, -}: { - label?: string; - accent?: "blue" | "green" | "purple"; - featured?: boolean; -}) { - const [count, setCount] = useState(3); - const accentClass = - accent === "green" - ? "border-green-300 bg-green-50" - : accent === "purple" - ? "border-purple-300 bg-purple-50" - : "border-blue-300 bg-blue-50"; - - return ( -
-
Launch impact
-
- {count} {label} -
- {featured ?
Featured metric
: null} - -
- ); -} - -export const ImpactCounterInputs = { - label: { - type: "string", - label: "Metric label", - default: "points", - }, - accent: { - type: "select", - label: "Accent", - options: ["blue", "green", "purple"], - default: "blue", - }, - featured: { - type: "boolean", - label: "Featured", - default: false, - }, -}; -``` - -Luego úselo desde cualquier archivo MDX local: - -```mdx ---- -title: "Notas de lanzamiento" ---- - -# Notas de lanzamiento - - -``` - -El servidor de desarrollo de contenido descubre las exportaciones con nombre de PascalCase y el valor predeterminado de PascalCase -exportaciones de archivos `.tsx`, `.jsx`, `.ts` y `.js` en `components/`. Esos -Los componentes se procesan dentro del editor y aparecen en el menú diagonal debajo -**Componentes locales**. La inserción de barra diagonal crea una etiqueta mínima como -``; agregue accesorios en la fuente MDX cuando sea necesario. - -La ejecución de componentes es intencionalmente una capacidad de puente entre desarrollo local y escritorio, no -acceso simple a la carpeta del navegador alojado. Si abres `content.agent-native.com`, -elija **Archivos locales** y elija una carpeta en Chrome, la aplicación puede leer y escribir -los archivos `.md` y `.mdx` a través del navegador File System Access API, pero -Chrome no expone una ruta de carpeta absoluta para que Vite pueda compilar -`components/*.tsx`. Para obtener una vista previa y recargar en caliente componentes React personalizados, ejecute -Contenido local o use Agent Native Desktop para que el puente local confiable pueda -registre el espacio de trabajo elegido con el servidor de desarrollo de contenido local. En ese modo, -ediciones en archivos de componentes existentes, recarga en caliente a través de Vite y adición o -Al eliminar los archivos de componentes se vuelve a cargar el registro de componentes y el menú diagonal. - -Los agentes también pueden trabajar con esos archivos de componentes registrados. Usar -`list-local-component-files` para encontrar la identificación del espacio de trabajo registrado, luego -`write-local-component-file` para crear o actualizar `.tsx`, `.jsx`, `.ts` o -Archivos `.js` en la carpeta `components/` del espacio de trabajo. Los archivos MDX siguen siendo los -fuente de verdad para el uso de componentes; los archivos componentes permanecen en el repositorio normal -archivos fuente revisados con Git. - -Si un componente exporta metadatos de entrada, seleccione el componente en el editor -muestra un botón de edición en la esquina superior derecha del componente. Tipos de entrada admitidos -son `string`, `textarea`, `number`, `boolean` y `select`. El formulario escribe -vuelve a la etiqueta MDX, por lo que los archivos locales siguen siendo la fuente de la verdad. El -Los metadatos se pueden exportar como `ComponentNameInputs`, `ComponentNameConfig.inputs`, -`Component.inputs` o `agentNative.inputs`. - -Las etiquetas de componentes simples con accesorios literales pueden obtener una vista previa en línea: - -```mdx - - - - -This setting affects production deploys. - - -``` - -Las expresiones JSX complejas se conservan en el código fuente. Si el editor no puede hacerlo de forma segura -obtenga una vista previa de la propiedad de un componente todavía, muestra un marcador de posición de advertencia en lugar de -soltar datos silenciosamente. - -## Compartir archivos locales - -Los archivos locales no se comparten directamente porque otros usuarios no pueden leer una ruta -tu máquina. El botón Compartir de la barra de herramientas Contenido crea o actualiza un -copia respaldada por la base de datos del archivo seleccionado, navega hasta esa copia y abre el -ventana emergente de compartir normal. El archivo local original permanece en Archivos locales; el -La copia de la base de datos aparece en Copias compartidas en modo de archivo local y utiliza el -modelo estándar para compartir documentos. - -## Extensiones locales - -El modo de archivo local también puede cargar extensiones respaldadas por repositorio desde el archivo configurado -Carpeta `extensions`. Cada extensión es un directorio con un `extension.json` -manifiesto y un archivo de entrada HTML: - -```text -extensions/ - doc-status/ - extension.json - index.html -``` - -```json -{ - "id": "doc-status", - "name": "Doc Status", - "description": "Shows metadata for the selected Content file.", - "entry": "index.html", - "slots": ["content.sidebar.bottom"], - "permissions": { - "appActions": ["list-documents"], - "extensionData": true - } -} -``` - -`index.html` es el mismo formato de cuerpo de extensión Alpine/Tailwind utilizado por normal -extensiones respaldadas por bases de datos. Cuando la aplicación Contenido ve una extensión local que -declara `content.sidebar.bottom`, representa esa extensión en la parte inferior de -la barra lateral de Contenido. El anfitrión pasa `window.slotContext` con el seleccionado -ID del documento, título, metadatos de origen y si el contenido está en modo de archivo local. - -La aplicación obtiene una vista previa de las extensiones locales, pero las edita como archivos. Las extensiones -la lista los muestra con una insignia de Archivo local y el visor de página completa apunta a -el archivo de entrada. Extensión respaldada por SQL actions, como actualizar, eliminar, compartir y -el historial no se aplica; use su editor, Codex, código Claude o historial de Git para -cambios de fuente. - -Para v1, las extensiones locales son intencionalmente conservadoras: - -- pueden usar `extensionData` para su propio estado de ejecución pequeño -- solo pueden llamar a los `appAction` enumerados en `extension.json` -- Los asistentes SQL sin formato y `extensionFetch` externos están deshabilitados -- Los destinos de ranura se declaran en `extension.json`, no se instalan a través de SQL - -Esto proporciona a los espacios de trabajo locales una superficie de complemento similar a Obsidian sin permitir que -El archivo de repositorio arbitrario hereda todas las capacidades de una extensión respaldada por una base de datos. - -## Cómo lo usan las aplicaciones - -El modo de archivo local se implementa a través de los asistentes de artefactos locales del marco. -Una aplicación declara raíces para los tipos de artefactos que posee, luego lee y escribe -a través de la misma superficie de acción que su UI y su agente ya utilizan. - -Para Contenido, eso significa: - -- `list-documents` enumera los archivos `.md` y `.mdx` configurados. -- `get-document` lee un archivo local seleccionado. -- `update-document` escribe el archivo local seleccionado. -- `create-document` crea un nuevo archivo `.mdx` local en la carpeta seleccionada. -- `delete-document` elimina el archivo local. -- La búsqueda se ejecuta en los archivos locales configurados. - -Mover, renombrar y reordenar páginas de archivos locales desde UI de contenido no lo es -aún es compatible. Realice esas operaciones en el espacio de trabajo o con un agente de codificación; el -La barra lateral de contenido reflejará el árbol de archivos resultante. - -Esto simplifica el contrato del agente: el agente puede seguir usando Content actions, -y esos actions deciden si el objetivo está respaldado por SQL o por archivos. - -Otras aplicaciones pueden adoptar el mismo patrón con el tiempo. Una aplicación de Presentaciones puede mapear -`slides/*.mdx` a cubiertas, una aplicación de Planes puede asignar `plans/*` a documentos de planos y -La aplicación Paneles puede asignar `dashboards/*.mdx` a paneles. Aquellos específicos de la aplicación -Las carpetas son convenciones superpuestas al mismo contrato de artefacto local. - -## Archivos locales frente a exportación/importación - -El contenido tiene dos flujos de trabajo de archivos diferentes: - -| Flujo de trabajo | Qué pasa | -| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Exportación/importación `/local-files` | El modo de base de datos sigue siendo la fuente de la verdad. Los archivos son una superficie de sincronización explícita que puedes exportar, editar, obtener una vista previa e importar. | -| Modo de archivo local | Los archivos son la fuente de la verdad. La barra lateral de contenido y el editor funcionan directamente en archivos locales. | - -Utilice exportar/importar cuando desee revisar archivos ocasionalmente en un espacio de trabajo alojado. -Utilice el modo de archivo local cuando el repositorio en sí sea el espacio de trabajo. - -## Historia y colaboración - -El modo de archivo local se basa en el historial nativo del archivo: - -- realizar cambios importantes en Git -- usar solicitudes de extracción para revisión -- permitir que los agentes de codificación editen los mismos archivos directamente -- use diferencias de archivos normales para comprender los cambios - -El modo de base de datos sigue siendo el más adecuado para funciones de colaboración alojadas como -Compartir, comentarios, historial de versiones respaldado por SQL y edición multiusuario en vivo. - -La sincronización del proveedor se puede superponer a cualquier modo. Por ejemplo, un repositorio de documentos puede -Agregue actions que extraiga contenido de un CMS a archivos MDX locales o envíe los seleccionados -archivos locales de regreso a ese CMS. - -## Seguridad en la producción - -El modo de archivo local le da a la aplicación actions acceso directo de escritura al espacio de trabajo configurado -archivos. Esto es apropiado para el desarrollo local y un archivo de inquilino único confiable -puentes, pero no es el modelo de seguridad de producción predeterminado. - -Cuando `NODE_ENV=production`, el marco rechaza el modo `local-files` a menos que usted -conjunto: - -```bash -AGENT_NATIVE_ALLOW_LOCAL_FILES_IN_PRODUCTION=true -``` - -Establezca esto solo para una implementación confiable de un solo inquilino donde todos los que puedan usarlo -la aplicación puede leer y escribir los archivos configurados. Para alojamiento normal, -aplicaciones multiusuario, uso del modo de base de datos y uso compartido respaldado por SQL. +Para migrar, elimine `mode: "local-files"` y +`AGENT_NATIVE_MODE=local-files`, conserve las raíces y añada `source`. Los IDs +del frontmatter mantienen la identidad al renombrar o mover una página. Los +componentes y extensiones locales siguen usando el puente local de confianza. diff --git a/packages/core/docs/content/locales/es-ES/template-content.mdx b/packages/core/docs/content/locales/es-ES/template-content.mdx index 3e3e275a9d..209a0018b3 100644 --- a/packages/core/docs/content/locales/es-ES/template-content.mdx +++ b/packages/core/docs/content/locales/es-ES/template-content.mdx @@ -95,9 +95,8 @@ Cuando abras la aplicación, verás un árbol de páginas al lado del editor. El - **Organiza las páginas en un árbol**: anida infinitamente, arrastra para reordenar, páginas favoritas que usas con frecuencia. - **Busca en todo** con búsqueda de texto completo en títulos y contenido. - **Edite archivos locales Markdown/MDX como Obsidian.** Utilice la vista `/local-files` - para exportar su espacio de trabajo a archivos, edítelos en sus propias herramientas, obtenga una vista previa - cambios e importarlos nuevamente. En el modo de archivo local, el contenido se escribe directamente en - el archivo `.md` o `.mdx` seleccionado. + para conectar una carpeta a Files, editarla con sus herramientas, sincronizar + cambios y revisar conflictos dentro de la aplicación SQL. - **Genere bloques personalizados interactivos enriquecidos.** Registre componentes React locales, insértelos como MDX y permita que el agente cree o actualice archivos de componentes para tus documentos. @@ -140,32 +139,19 @@ como superficie de revisión. La aplicación alojada sigue siendo la fuente de v comentarios, permisos y colaboración en vivo; la carpeta local es explícita superficie de sincronización. -El contenido también se puede ejecutar en **Modo de archivo local**, donde los archivos son la fuente de -verdad en lugar de documentos SQL. Agregue `agent-native.json` a un repositorio, configure -`mode: "local-files"` y configurar raíces como `docs/`, `blog/`, -`content/` y `resources/`. Luego, el editor de contenido estándar completa su -barra lateral izquierda de esos archivos locales `.md`/`.mdx` y escribe las ediciones en el -archivo seleccionado a través del documento normal actions. Utilice esto para documentos de repositorio primero, -blogs, bibliotecas de recursos o contenido personal estilo Obsidian con tecnología MDX -componentes; volver al modo de base de datos cuando desee colaboración alojada y -Compartición respaldada por SQL. Consulte [Local File Mode](/docs/local-file-mode) para -diseño de repositorio independiente, configuración, componentes personalizados MDX, local -Widgets `extensions/` y guía de seguridad de producción. +Las carpetas locales ahora usan el mismo modelo SQL que todas las páginas. Cada +página pertenece a un espacio y a su base Files; la carpeta es un adaptador de +fuente. Consulte [Fuentes de carpetas locales](/docs/local-file-mode) para las +políticas, conflictos, componentes MDX y la migración del modo anterior. -Para instalar la habilidad de archivos locales de contenido en un repositorio existente: +Para abrir Content en un repositorio existente: ```bash -npx @agent-native/core@latest skills add content --mode local-files --scope project +npx @agent-native/core@latest content . ``` -El instalador copia la habilidad `content` para su agente de codificación y escribe o -actualiza `agent-native.json` con raíces de contenido para `docs/`, `blog/`, `content/`, -y `resources/`. Cuando una aplicación de contenido local, Agent Native Desktop o de confianza -El puente local se está ejecutando, los agentes deben usar Contenido actions como -`list-documents`, `get-document`, `edit-document`, `update-document` y -`share-local-file-document` en lugar de escrituras en el sistema de archivos sin formato. Sin ese local -puente, la habilidad instalada aún le otorga al agente el contrato de edición de repositorio para -ediciones seguras de Markdown/MDX. +La CLI inicia Content con su base normal, registra la carpeta como fuente y abre +la conexión sin definir una variable de modo. ## Para desarrolladores diff --git a/packages/core/docs/content/locales/fr-FR/local-file-mode.mdx b/packages/core/docs/content/locales/fr-FR/local-file-mode.mdx index 482f4bad92..2898680424 100644 --- a/packages/core/docs/content/locales/fr-FR/local-file-mode.mdx +++ b/packages/core/docs/content/locales/fr-FR/local-file-mode.mdx @@ -1,525 +1,72 @@ --- -title: "Mode fichier local" -description: "Exécutez des applications natives d'agent avec Markdown, MDX et d'autres fichiers de dépôt locaux comme source de vérité, y compris des documents MDX de style Obsidian avec des composants personnalisés." +title: "Sources de dossiers locaux" +description: "Connectez des dossiers Markdown et MDX aux espaces Content, tous stockés en base de données." --- -# Mode fichier local +# Sources de dossiers locaux -Le mode Fichier local permet à une application native d'agent d'attacher son UI et sa surface d'action normales -directement vers les fichiers d'un dépôt ou d'un espace de travail. L'application ressemble toujours à une application hébergée -produit, mais ses vues de liste, son éditeur et ses outils d'agent lisent et écrivent des fichiers locaux -au lieu des enregistrements d'application basés sur SQL. +Content utilise désormais un seul modèle de données : chaque page est stockée +en SQL, appartient à un **espace** et figure dans la base système **Files** de +cet espace. Un dossier local est une source connectée à Files, pas un second +mode d'application. Il n'existe plus de commutateur `AGENT_NATIVE_MODE`. -La première implémentation se trouve dans le modèle de contenu : la barre latérale gauche est -rempli à partir des fichiers `.md` et `.mdx` locaux, la sélection d'une page ouvre la norme -Éditeur de contenu et enregistrement des écritures dans le fichier sélectionné. Les mêmes fichiers peuvent -être également modifié par Codex, Claude Code, l'agent de la barre latérale Agent-Native ou un utilisateur normal -éditeur. +Chaque utilisateur possède un espace personnel, chaque organisation accessible +possède son espace, et le catalogue personnel **Workspaces** détermine les +espaces affichés. Files est une base Content normale : ses propriétés, filtres, +tris, groupes et vues enregistrées peuvent aussi rendre la barre latérale. -Pour le contenu, cela donne au produit l'impression d'être Obsidian open source pour MDX : -vos documents vivent sous forme de fichiers, tandis que l'application ajoute un éditeur visuel, l'agent actions, -copies partageables et riches composants interactifs MDX. +## Connecter un dossier -Utilisez le mode fichier local lorsque vous souhaitez un workflow axé sur le dépôt : +Choisissez un dossier via le navigateur ou Agent Native Desktop, puis rattachez- +le à un espace existant ou créez un espace privé associé au dossier. SQL ne +conserve que l'identifiant opaque de connexion, les chemins relatifs, les +empreintes et les métadonnées de source — jamais le chemin absolu, le handle du +navigateur ou le corps brut des fichiers. -- un dépôt de documents avec `docs/*.mdx` -- un blog avec `blog/*.mdx` -- ressources telles que le positionnement, la messagerie ou les notes d'équipe dans `resources/*.md` -- une base de connaissances personnelle de style Obsidian avec un éditeur MDX plus riche -- documents nécessitant des blocs MDX personnalisés interactifs générés à partir du code React local -- Artefacts d'application qui devraient être faciles à inspecter et à corriger pour les agents de codage +Les stratégies disponibles sont `database_primary`, `source_primary` et +`reviewed_bidirectional`. La synchronisation matérialise les fichiers comme des +pages SQL ordinaires. En cas de modifications concurrentes, elle crée un +changement à examiner au lieu d'écraser un côté. La déconnexion conserve les +pages et les fichiers locaux. -Utilisez le mode base de données lorsque vous souhaitez bénéficier de l'expérience d'une application collaborative hébergée : -partage multi-utilisateurs, autorisations basées sur SQL, commentaires, historique des versions et -hébergement de production sans accès au système de fichiers local. - -## Le modèle mental - -Il existe deux modes de source de vérité : - -| Mode | Source de vérité | Idéal pour | -| -------------------- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | -| Mode base de données | Lignes SQL jusqu'à Drizzle | Applications hébergées, collaboration, partage, commentaires, historique des versions | -| Mode fichier local | Fichiers Repo déclarés par `agent-native.json` | Workflows locaux/de développement, révision de Git, modifications de l'agent de codage, contenu natif du fichier | - -Le UI et l'agent actions doivent conserver la même forme dans les deux modes. Un contenu -l'éditeur édite toujours les documents ; la différence est de savoir si ces documents résolvent -vers des lignes SQL ou des fichiers locaux. - - - -```html -
-
-
UI de contenu
-
- Agent et actions applicatives
list/get/update-document -
-
- -
-
- Mode base de donnéesSQL rows via Drizzlehébergé · partage · commentaires · historique -
-
- Local File Moderepo files via agent-native.jsonrevue Git · éditions agent de code -
-
-
-``` - -```css -.diagram-mode { - display: flex; - align-items: center; - gap: 14px; - flex-wrap: wrap; -} -.diagram-mode .diagram-col { - display: flex; - flex-direction: column; - gap: 10px; -} -.diagram-mode .diagram-arrow { - font-size: 22px; - line-height: 1; -} -.diagram-mode .resolve { - display: flex; - gap: 12px; - flex-wrap: wrap; -} -.diagram-mode .diagram-panel { - display: flex; - flex-direction: column; - gap: 4px; - padding: 12px 14px; -} -``` - -
- -## Exemple de dépôt - -Un espace de travail de contenu peut être aussi petit que ceci : - - - -En mode fichier local, la barre latérale Contenu affiche les `docs/`, `blog/` et -Arborescences `resources/` sous forme de pages. La sélection de `docs/getting-started.mdx` ouvre cela -fichier dans l'éditeur de contenu standard ; l'édition dans le UI réécrit dans -`docs/getting-started.mdx`. - -`components/` n'est pas une racine de contenu. Il s'agit d'une bibliothèque de composants en avant-première que MDX -les fichiers peuvent être importés ou référencés. L'éditeur peut restituer des composants MDX locaux simples -sans que vous ayez à cloner ou à dupliquer l'intégralité de l'application de contenu. - -`extensions/` n'est pas non plus une racine de contenu. Il s'agit d'une bibliothèque d'extensions locale : -petits widgets en bac à sable qui peuvent s'afficher dans les emplacements d'application tandis que leur source reste dans -le dépôt. - -## Installer le contenu dans un dépôt - -Pour un espace de travail de documents, de blog ou MDX existant, installez les fichiers locaux de contenu -compétence : +## Démarrer depuis un dépôt ```bash -npx @agent-native/core@latest skills add content --mode local-files --scope project +npx @agent-native/core@latest content . ``` -Cela copie la compétence `content` dans les dossiers de compétences d'agent du dépôt et écrit -ou met à jour `agent-native.json` avec les valeurs par défaut du contenu : - -- `mode: "local-files"` au niveau de l'espace de travail -- `apps.content.mode: "local-files"` -- racines de contenu pour `docs/`, `blog/`, `content/` et `resources/` -- `components/` pour les composants locaux MDX -- `extensions/` pour les widgets d'extension locale - -La compétence installée indique aux agents de codage d'utiliser le contenu actions -(`list-documents`, `get-document`, `edit-document`, `update-document`, -`share-local-file-document` et fichier de composant actions) lorsqu'une application de contenu locale -ou le pont Agent Native Desktop les expose. Si aucun pont ne fonctionne, la compétence -revient à des modifications de dépôt directes sécurisées tout en préservant le contenu, les importations, JSX, -et MDX inconnu. +La commande démarre l'application Content normale, enregistre le dossier comme +source et ouvre l'écran de connexion. Elle ne définit aucune variable de mode. ## Configuration -Ajoutez `agent-native.json` à la racine du dépôt ou de l'espace de travail : - ```json { "version": 1, "apps": { "content": { - "mode": "local-files", "roots": [ { "name": "Docs", "path": "docs", "kind": "docs", - "extensions": [".md", ".mdx"] - }, - { - "name": "Blog", - "path": "blog", - "kind": "blog", - "extensions": [".md", ".mdx"] - }, - { - "name": "Resources", - "path": "resources", - "kind": "resources", - "extensions": [".md", ".mdx"] + "extensions": [".md", ".mdx"], + "source": { + "type": "local-folder", + "connectionId": "local-folder:", + "truthPolicy": "source_primary" + } } ], "components": "components", - "extensions": "extensions", - "hide": ["**/_*.md", "**/_*.mdx"] + "extensions": "extensions" } } } ``` -Vous pouvez également activer les fichiers locaux avec `AGENT_NATIVE_MODE=local-files` ou -`AGENT_NATIVE_DATA_MODE=local-files` ; le manifeste est préféré car il -documente le contrat de dossier dans le dépôt lui-même. - -## Format de fichier de contenu - -Le contenu lit Markdown et MDX. Frontmatter contient les métadonnées de la page et le corps est -le document modifiable : - -```mdx ---- -title: "Commencer" -icon: "sparkles" -isFavorite: true -updatedAt: "2026-06-12T20:00:00.000Z" ---- - -# Commencer - -Use to show framework-specific code. -``` - -Le titre vient du frontmatter `title` lorsqu'il est présent, sinon du -nom de fichier. L'éditeur conserve la source MDX qu'il ne peut pas encore modifier visuellement, donc -Les agents de codage et les éditeurs de texte normaux restent des issues de secours sûres. - -## Composants MDX personnalisés - -Le contenu peut prévisualiser les composants locaux du dossier `components` configuré. -Ceci est destiné aux composants MDX de style document tels que les onglets, les légendes, le package -installez des extraits ou des blocs de code spécifiques au framework. - -Par exemple, ajoutez un composant interactif à côté de votre contenu : - -```tsx -// components/ImpactCounter.tsx -import { useState } from "react"; - -export function ImpactCounter({ - label = "points", - accent = "blue", - featured = false, -}: { - label?: string; - accent?: "blue" | "green" | "purple"; - featured?: boolean; -}) { - const [count, setCount] = useState(3); - const accentClass = - accent === "green" - ? "border-green-300 bg-green-50" - : accent === "purple" - ? "border-purple-300 bg-purple-50" - : "border-blue-300 bg-blue-50"; - - return ( -
-
Launch impact
-
- {count} {label} -
- {featured ?
Featured metric
: null} - -
- ); -} - -export const ImpactCounterInputs = { - label: { - type: "string", - label: "Metric label", - default: "points", - }, - accent: { - type: "select", - label: "Accent", - options: ["blue", "green", "purple"], - default: "blue", - }, - featured: { - type: "boolean", - label: "Featured", - default: false, - }, -}; -``` - -Utilisez-le ensuite à partir de n'importe quel fichier MDX local : - -```mdx ---- -title: "Notes de lancement" ---- - -# Notes de lancement - - -``` - -Le serveur de développement de contenu découvre les exportations nommées PascalCase et PascalCase par défaut -exporte à partir de fichiers `.tsx`, `.jsx`, `.ts` et `.js` sous `components/`. Ceux -les composants s'affichent dans l'éditeur et apparaissent dans le menu barre oblique sous -**Composants locaux**. L'insertion d'une barre oblique crée une balise minimale telle que -`` ; ajoutez des accessoires dans la source MDX si nécessaire. - -L'exécution des composants est intentionnellement une fonctionnalité de pont de développement local/de bureau, et non -accès simple au dossier du navigateur hébergé. Si vous ouvrez `content.agent-native.com`, -choisissez **Fichiers locaux** et choisissez un dossier dans Chrome, l'application peut lire et écrire -les fichiers `.md` et `.mdx` via le système de fichiers du navigateur Accédez à API, mais -Chrome n'expose pas de chemin de dossier absolu à compiler par Vite -`components/*.tsx`. Pour prévisualiser et recharger à chaud les composants React personnalisés, exécutez -Contenu localement ou utilisez Agent Native Desktop pour que le pont local de confiance puisse -enregistrez l'espace de travail sélectionné auprès du serveur de développement de contenu local. Dans ce mode, -modifications des fichiers de composants existants, rechargement à chaud via Vite et ajout de ou -la suppression des fichiers de composants recharge le registre des composants et le menu barre oblique. - -Les agents peuvent également travailler avec ces fichiers de composants enregistrés. Utiliser -`list-local-component-files` pour trouver l'identifiant de l'espace de travail enregistré, puis -`write-local-component-file` pour créer ou mettre à jour `.tsx`, `.jsx`, `.ts` ou -Fichiers `.js` dans le dossier `components/` de l'espace de travail. Les fichiers MDX restent les -source de vérité pour l'utilisation des composants ; les fichiers de composants restent un dépôt normal -fichiers sources examinés avec Git. - -Si un composant exporte des métadonnées d'entrée, sélection du composant dans l'éditeur -affiche un bouton d'édition dans le coin supérieur droit du composant. Types d'entrée pris en charge -sont `string`, `textarea`, `number`, `boolean` et `select`. Le formulaire écrit -revient à la balise MDX, les fichiers locaux restent donc la source de vérité. Le -les métadonnées peuvent être exportées au format `ComponentNameInputs`, `ComponentNameConfig.inputs`, -`Component.inputs` ou `agentNative.inputs`. - -Les balises de composants simples avec des accessoires littéraux peuvent être prévisualisées en ligne : - -```mdx - - - - -This setting affects production deploys. - - -``` - -Les expressions complexes JSX sont conservées dans la source. Si l'éditeur ne peut pas en toute sécurité -prévisualisez encore un accessoire de composant, il affiche un espace réservé d'avertissement plutôt que -supprimer silencieusement des données. - -## Partage de fichiers locaux - -Les fichiers locaux ne sont pas partagés directement car les autres utilisateurs ne peuvent pas lire de chemin -votre machine. Le bouton Partager de la barre d'outils Contenu crée ou actualise un -copie sauvegardée dans la base de données du fichier sélectionné, accède à cette copie et ouvre le -popover de partage normal. Le fichier local d'origine reste sous Fichiers locaux ; le -la copie de la base de données apparaît sous Copies partagées en mode fichier local et utilise le -modèle standard de partage de documents. - -## Extensions locales - -Le mode fichier local peut également charger des extensions sauvegardées en dépôt à partir du fichier configuré -Dossier `extensions`. Chaque extension est un répertoire avec un `extension.json` -manifeste et un fichier d'entrée HTML : - -```text -extensions/ - doc-status/ - extension.json - index.html -``` - -```json -{ - "id": "doc-status", - "name": "Doc Status", - "description": "Shows metadata for the selected Content file.", - "entry": "index.html", - "slots": ["content.sidebar.bottom"], - "permissions": { - "appActions": ["list-documents"], - "extensionData": true - } -} -``` - -`index.html` est le même format de corps d'extension Alpine/Tailwind utilisé par la normale -extensions basées sur une base de données. Lorsque l'application de contenu détecte une extension locale qui -déclare `content.sidebar.bottom`, il affiche cette extension en bas de -la barre latérale Contenu. L'hôte transmet `window.slotContext` avec le -ID du document, titre, métadonnées sources et si le contenu est en mode fichier local. - -Les extensions locales sont prévisualisées par l'application mais modifiées sous forme de fichiers. Les extensions -la liste les affiche avec un badge de fichier local et la visionneuse pleine page renvoie à -le fichier d'entrée. Extension actions basée sur SQL, telle que la mise à jour, la suppression, le partage et -l'historique ne s'applique pas ; utilisez votre éditeur, le code Codex, le code Claude ou l'historique Git pour -modifications de la source. - -Pour la version v1, les extensions locales sont intentionnellement conservatrices : - -- ils peuvent utiliser `extensionData` pour leur propre petit état d'exécution -- ils ne peuvent appeler que les `appAction` répertoriés dans `extension.json` -- Les assistants SQL bruts et les `extensionFetch` externes sont désactivés -- les emplacements cibles sont déclarés dans `extension.json`, non installés via SQL - -Cela donne aux espaces de travail locaux une surface de plugin de type Obsidian sans laisser passer -un fichier de dépôt arbitraire hérite de toutes les fonctionnalités d'une extension basée sur une base de données. - -## Comment les applications l'utilisent - -Le mode fichier local est implémenté via les assistants d'artefacts locaux du framework. -Une application déclare les racines des types d'artefacts qu'elle possède, puis lit et écrit -via la même surface d'action, son UI et son agent l'utilisent déjà. - -Pour le contenu, cela signifie : - -- `list-documents` répertorie les fichiers `.md` et `.mdx` configurés. -- `get-document` lit un fichier local sélectionné. -- `update-document` écrit le fichier local sélectionné. -- `create-document` crée un nouveau fichier `.mdx` local dans le dossier sélectionné. -- `delete-document` supprime le fichier local. -- la recherche s'exécute sur les fichiers locaux configurés. - -Le déplacement, le renommage et la réorganisation des pages de fichiers locaux à partir du contenu UI ne sont pas autorisés -pas encore pris en charge. Effectuer ces opérations dans l'espace de travail ou avec un agent de codage ; le -La barre latérale de contenu reflétera l'arborescence de fichiers résultante. - -Le contrat de l'agent reste ainsi simple : l'agent peut continuer à utiliser Content actions, -et ces actions décident si la cible est sauvegardée sur SQL ou sur fichier. - -D'autres applications peuvent adopter le même modèle au fil du temps. Une application Slides peut cartographier -`slides/*.mdx` aux decks, une application Plans peut mapper `plans/*` aux documents de plan, et -L'application Dashboards peut mapper `dashboards/*.mdx` aux tableaux de bord. Ceux spécifiques à l'application -les dossiers sont des conventions superposées au même contrat d'artefact local. - -## Fichiers locaux vs exportation/importation - -Le contenu comporte deux flux de travail de fichiers différents : - -| Flux de travail | Que se passe-t-il | -| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Export/importation `/local-files` | Le mode base de données reste la source de vérité. Les fichiers constituent une surface de synchronisation explicite que vous exportez, modifiez, prévisualisez et importez. | -| Mode fichier local | Les fichiers sont la source de la vérité. La barre latérale de contenu et l'éditeur fonctionnent directement sur les fichiers locaux. | - -Utilisez l'exportation/importation lorsque vous souhaitez examiner occasionnellement des fichiers dans un espace de travail hébergé. -Utilisez le mode fichier local lorsque le dépôt lui-même est l'espace de travail. - -## Historique et collaboration - -Le mode fichier local s'appuie sur l'historique natif des fichiers : - -- valider les modifications importantes dans Git -- utiliser les demandes d'extraction pour examen -- laisser les agents de codage modifier directement les mêmes fichiers -- utiliser les différences de fichiers normales pour comprendre les changements - -Le mode base de données reste le meilleur choix pour les fonctionnalités de collaboration hébergées telles que -partage, commentaires, historique des versions soutenu par SQL et édition multi-utilisateurs en direct. - -La synchronisation du fournisseur peut être superposée à l'un ou l'autre mode. Par exemple, un dépôt de documents peut -ajouter des actions qui extraient le contenu d'un CMS vers des fichiers MDX locaux ou poussent la sélection -fichiers locaux vers ce CMS. - -## Sécurité de la production - -Le mode fichier local donne à l'application actions un accès direct en écriture à l'espace de travail configuré -fichiers. Cela est approprié pour le développement local et les fichiers à locataire unique de confiance -des ponts, mais ce n'est pas le modèle de sécurité de production par défaut. - -Lorsque `NODE_ENV=production`, le framework refuse le mode `local-files` sauf si vous -définir : - -```bash -AGENT_NATIVE_ALLOW_LOCAL_FILES_IN_PRODUCTION=true -``` - -Définissez cela uniquement pour un déploiement fiable à locataire unique où tous ceux qui peuvent l'utiliser -l'application est autorisée à lire et à écrire les fichiers configurés. Pour un hébergement normal, -Applications multi-utilisateurs, utilisez le mode base de données et le partage basé sur SQL. +Pour migrer, supprimez `mode: "local-files"` et +`AGENT_NATIVE_MODE=local-files`, conservez les racines et ajoutez la section +`source`. Les identifiants de frontmatter préservent l'identité d'une page lors +d'un renommage ou déplacement. Les composants et extensions locaux continuent +de passer par le pont local de confiance. diff --git a/packages/core/docs/content/locales/fr-FR/template-content.mdx b/packages/core/docs/content/locales/fr-FR/template-content.mdx index c6699f705f..4245c5dce7 100644 --- a/packages/core/docs/content/locales/fr-FR/template-content.mdx +++ b/packages/core/docs/content/locales/fr-FR/template-content.mdx @@ -95,9 +95,8 @@ Lorsque vous ouvrez l'application, vous verrez une arborescence de pages à côt - **Organisez les pages dans une arborescence** : emboîtez-les à l'infini, faites-les glisser pour réorganiser les pages favorites que vous utilisez souvent. - **Recherchez partout** avec la recherche en texte intégral dans les titres et le contenu. - **Modifiez les fichiers Markdown/MDX locaux comme Obsidian.** Utilisez la vue `/local-files` - pour exporter votre espace de travail vers des fichiers, modifiez-les dans vos propres outils, prévisualisez - modifications et réimportez-les. En mode fichier local, le contenu écrit directement dans - le fichier `.md` ou `.mdx` sélectionné. + pour connecter un dossier à Files, le modifier avec vos outils, synchroniser + les changements et examiner les conflits dans l'application SQL. - **Générez des blocs personnalisés interactifs riches.** Enregistrez les composants React locaux, insérez-les en tant que MDX et laissez l'agent créer ou mettre à jour les fichiers de composants pour vos documents. @@ -140,32 +139,19 @@ comme surface de révision. L'application hébergée reste la source de vérité commentaires, autorisations et collaboration en direct ; le dossier local est un explicite surface de synchronisation. -Le contenu peut également s'exécuter en **mode fichier local**, où les fichiers sont la source de -la vérité au lieu des documents SQL. Ajoutez `agent-native.json` à un dépôt, définissez -`mode: "local-files"` et configurez les racines telles que `docs/`, `blog/`, -`content/` et `resources/`. L'éditeur de contenu standard remplit ensuite son -barre latérale gauche de ces fichiers `.md`/`.mdx` locaux et réécrit les modifications dans -fichier sélectionné via le document normal actions. Utilisez-le pour les documents repo-first, -blogs, bibliothèques de ressources ou contenu personnel de style Obsidian avec MDX -composants ; revenez en mode base de données lorsque vous souhaitez une collaboration hébergée et -Partage soutenu par SQL. Voir [Local File Mode](/docs/local-file-mode) pour le -Disposition du dépôt autonome, configuration, composants MDX personnalisés, local -Widgets `extensions/` et guide de sécurité de production. +Les dossiers locaux utilisent maintenant le même modèle SQL que toutes les +pages. Chaque page appartient à un espace et à sa base Files ; le dossier est +un adaptateur de source sur cette base. Voir [Sources de dossiers locaux](/docs/local-file-mode) +pour les stratégies, conflits, composants MDX et la migration depuis l'ancien mode. -Pour installer la compétence Content local-files dans un dépôt existant : +Pour ouvrir Content sur un dépôt existant : ```bash -npx @agent-native/core@latest skills add content --mode local-files --scope project +npx @agent-native/core@latest content . ``` -Le programme d'installation copie la compétence `content` pour votre agent de codage et écrit ou -met à jour `agent-native.json` avec les racines de contenu pour `docs/`, `blog/`, `content/`, -et `resources/`. Lorsqu'une application de contenu locale, un bureau Agent Native ou une application approuvée -le pont local est en cours d'exécution, les agents doivent utiliser le contenu actions tel que -`list-documents`, `get-document`, `edit-document`, `update-document` et -`share-local-file-document` au lieu d'écritures brutes sur le système de fichiers. Sans ce local -pont, la compétence installée donne toujours à l'agent le contrat de repo-édition pour -Modifications sécurisées de Markdown/MDX. +La CLI démarre Content avec sa base normale, enregistre la source du dossier et +ouvre la connexion sans définir de variable d'environnement de mode. ## Pour les développeurs diff --git a/packages/core/docs/content/locales/hi-IN/local-file-mode.mdx b/packages/core/docs/content/locales/hi-IN/local-file-mode.mdx index bbc6e114fb..034149d77c 100644 --- a/packages/core/docs/content/locales/hi-IN/local-file-mode.mdx +++ b/packages/core/docs/content/locales/hi-IN/local-file-mode.mdx @@ -1,525 +1,66 @@ --- -title: "स्थानीय फ़ाइल मोड" -description: "सच्चाई के स्रोत के रूप में स्थानीय Markdown, MDX और अन्य रेपो फ़ाइलों के साथ एजेंट-मूल ऐप्स चलाएं - जिसमें कस्टम घटकों के साथ ओब्सीडियन-शैली MDX दस्तावेज़ शामिल हैं।" +title: "लोकल फ़ोल्डर स्रोत" +description: "Markdown और MDX फ़ोल्डर को डेटाबेस-समर्थित Content स्पेस से जोड़ें।" --- -# स्थानीय फ़ाइल मोड +# लोकल फ़ोल्डर स्रोत -स्थानीय फ़ाइल मोड एक एजेंट-मूल ऐप को उसकी सामान्य UI और क्रिया सतह संलग्न करने देता है -सीधे रेपो या कार्यक्षेत्र में फ़ाइलों तक। ऐप अभी भी होस्ट किया हुआ लगता है -उत्पाद, लेकिन इसके सूची दृश्य, संपादक और एजेंट उपकरण स्थानीय फ़ाइलें पढ़ते और लिखते हैं -SQL-समर्थित ऐप रिकॉर्ड के बजाय। +Content में अब एक ही डेटा मॉडल है: हर पेज SQL में रहता है, किसी **स्पेस** से +जुड़ा होता है और उस स्पेस के सिस्टम **Files** डेटाबेस का आइटम होता है। लोकल +फ़ोल्डर Files से जुड़ा स्रोत है, ऐप का दूसरा मोड नहीं। `AGENT_NATIVE_MODE` +स्विच अब नहीं है। -पहला कार्यान्वयन सामग्री टेम्पलेट में है: बायां साइडबार है -स्थानीय `.md` और `.mdx` फ़ाइलों से भरा हुआ, एक पृष्ठ का चयन करने से मानक खुल जाता है -सामग्री संपादक, और बचत चयनित फ़ाइल पर वापस लिखता है। वही फ़ाइलें -Codex, Claude कोड, Agent-Native साइडबार एजेंट या सामान्य द्वारा भी संपादित किया जा सकता है -संपादक. +हर उपयोगकर्ता का निजी स्पेस, हर उपलब्ध संगठन का संगठन स्पेस और दिखाई देने वाले +स्पेस चुनने के लिए निजी **Workspaces** कैटलॉग होता है। Files एक सामान्य Content +डेटाबेस है; उसके गुण, फ़िल्टर, क्रम, समूह और सहेजे गए व्यू साइडबार भी बना सकते हैं। -सामग्री के लिए, यह उत्पाद को MDX के लिए ओपन-सोर्स ओब्सीडियन जैसा महसूस कराता है: -आपके दस्तावेज़ फ़ाइलों के रूप में रहते हैं, जबकि ऐप एक विज़ुअल संपादक, एजेंट actions जोड़ता है, -साझा करने योग्य प्रतियां, और समृद्ध इंटरैक्टिव MDX घटक। +## फ़ोल्डर जोड़ें -जब आप रेपो-प्रथम वर्कफ़्लो चाहते हैं तो स्थानीय फ़ाइल मोड का उपयोग करें: +ब्राउज़र या Agent Native Desktop से फ़ोल्डर चुनकर उसे मौजूदा स्पेस में जोड़ें, +या उसका निजी स्पेस बनाएँ। SQL में केवल अपारदर्शी कनेक्शन ID, सापेक्ष पथ, hash और +स्रोत मेटाडेटा रहता है—absolute path, browser handle या फ़ाइल का मूल body नहीं। -- `docs/*.mdx` के साथ एक डॉक्स रेपो -- `blog/*.mdx` के साथ एक ब्लॉग -- `resources/*.md` में पोजिशनिंग, मैसेजिंग या टीम नोट्स जैसे संसाधन -- एक समृद्ध MDX संपादक के साथ एक व्यक्तिगत ओब्सीडियन-शैली ज्ञान का आधार -- ऐसे दस्तावेज़ जिन्हें स्थानीय React कोड से उत्पन्न इंटरैक्टिव कस्टम MDX ब्लॉक की आवश्यकता होती है -- ऐप कलाकृतियाँ जिनका निरीक्षण करना और पैच करना कोडिंग एजेंटों के लिए आसान होना चाहिए +नीतियाँ `database_primary`, `source_primary` और `reviewed_bidirectional` हैं। +Sync फ़ाइलों को सामान्य SQL पेज बनाता है। दोनों तरफ एक साथ बदलाव होने पर किसी +को overwrite करने के बजाय समीक्षा योग्य बदलाव बनता है। Disconnect करने पर पेज +और लोकल फ़ाइलें बनी रहती हैं। -जब आप होस्टेड सहयोगी ऐप अनुभव चाहते हैं तो डेटाबेस मोड का उपयोग करें: -बहु-उपयोगकर्ता साझाकरण, SQL-समर्थित अनुमतियाँ, टिप्पणियाँ, संस्करण इतिहास, और -स्थानीय फ़ाइल सिस्टम पहुंच के बिना उत्पादन होस्टिंग। - -## मानसिक मॉडल - -सत्य के स्रोत के दो तरीके हैं: - -| मोड | सत्य का स्रोत | के लिए सर्वश्रेष्ठ | -| ----------------- | --------------------------------------------- | ---------------------------------------------------------------------------- | -| डेटाबेस मोड | SQL पंक्तियाँ Drizzle तक | होस्टेड ऐप्स, सहयोग, साझाकरण, टिप्पणियाँ, संस्करण इतिहास | -| स्थानीय फ़ाइल मोड | रेपो फ़ाइलें `agent-native.json` द्वारा घोषित | स्थानीय/डेवलप वर्कफ़्लो, Git समीक्षा, कोडिंग-एजेंट संपादन, फ़ाइल-मूल सामग्री | - -UI और एजेंट actions को दोनों मोड में एक ही आकार में रहना चाहिए। एक सामग्री -संपादक अभी भी दस्तावेज़ों का संपादन करता है; अंतर यह है कि क्या वे दस्तावेज़ हल हो जाते हैं -SQL पंक्तियों या स्थानीय फ़ाइलों तक। - - - -```html -
-
-
सामग्री UI
-
- एजेंट + actions
list/get/update-document -
-
- -
-
- डेटाबेस मोडSQL rows via Drizzleहोस्टेड · साझाकरण · टिप्पणियां · इतिहास -
-
- Local File Moderepo files via agent-native.jsonGit समीक्षा · कोडिंग-एजेंट संपादन -
-
-
-``` - -```css -.diagram-mode { - display: flex; - align-items: center; - gap: 14px; - flex-wrap: wrap; -} -.diagram-mode .diagram-col { - display: flex; - flex-direction: column; - gap: 10px; -} -.diagram-mode .diagram-arrow { - font-size: 22px; - line-height: 1; -} -.diagram-mode .resolve { - display: flex; - gap: 12px; - flex-wrap: wrap; -} -.diagram-mode .diagram-panel { - display: flex; - flex-direction: column; - gap: 4px; - padding: 12px 14px; -} -``` - -
- -## उदाहरण रेपो - -एक सामग्री कार्यक्षेत्र इतना छोटा हो सकता है: - - - -स्थानीय फ़ाइल मोड में, सामग्री साइडबार `docs/`, `blog/`, और -`resources/` पेड़ पृष्ठों के रूप में। `docs/getting-started.mdx` का चयन करने से वह खुल जाता है -मानक सामग्री संपादक में फ़ाइल; UI में संपादन करने पर वापस लिखता है -`docs/getting-started.mdx`. - -`components/` एक सामग्री रूट नहीं है. यह एक पूर्वावलोकन घटक लाइब्रेरी है जो MDX -फ़ाइलें आयात या संदर्भित कर सकती हैं। संपादक सरल स्थानीय MDX घटकों को प्रस्तुत कर सकता है -संपूर्ण सामग्री ऐप को क्लोन या फोर्क करने की आवश्यकता के बिना। - -`extensions/` भी एक कंटेंट रूट नहीं है। यह एक स्थानीय विस्तार पुस्तकालय है: -छोटे सैंडबॉक्स वाले विजेट जो अपने स्रोत के रहते हुए ऐप स्लॉट में रेंडर कर सकते हैं -रेपो. - -## सामग्री को एक रेपो में स्थापित करें - -मौजूदा डॉक्स, ब्लॉग, या MDX कार्यक्षेत्र के लिए, सामग्री स्थानीय-फ़ाइलें स्थापित करें -कौशल: +## Repository से शुरू करें ```bash -npx @agent-native/core@latest skills add content --mode local-files --scope project +npx @agent-native/core@latest content . ``` -यह `content` कौशल को रेपो के एजेंट कौशल फ़ोल्डर में कॉपी करता है और लिखता है -या सामग्री डिफ़ॉल्ट के साथ `agent-native.json` को अपडेट करता है: - -- कार्यस्थान स्तर पर `mode: "local-files"` -- `apps.content.mode: "local-files"` -- `docs/`, `blog/`, `content/`, और `resources/` के लिए सामग्री जड़ें -- स्थानीय MDX घटकों के लिए `components/` -- स्थानीय एक्सटेंशन विजेट के लिए `extensions/` - -स्थापित कौशल कोडिंग एजेंटों को सामग्री actions का उपयोग करने के लिए कहता है -(`list-documents`, `get-document`, `edit-document`, `update-document`, -`share-local-file-document`, और घटक-फ़ाइल actions) जब एक स्थानीय सामग्री ऐप -या Agent Native डेस्कटॉप ब्रिज उन्हें उजागर करता है। यदि कोई पुल नहीं चल रहा है, तो कौशल -फ्रंटमैटर, आयात, JSX को संरक्षित करते हुए सुरक्षित प्रत्यक्ष रेपो संपादन पर वापस आ जाता है -और अज्ञात MDX. - -## कॉन्फ़िगरेशन +यह सामान्य database-backed Content शुरू करता है, फ़ोल्डर को स्रोत के रूप में +दर्ज करता है और कनेक्शन स्क्रीन खोलता है। कोई mode environment variable नहीं लगता। -`agent-native.json` को रेपो या वर्कस्पेस रूट में जोड़ें: +## Configuration ```json { "version": 1, "apps": { "content": { - "mode": "local-files", "roots": [ { "name": "Docs", "path": "docs", "kind": "docs", - "extensions": [".md", ".mdx"] - }, - { - "name": "Blog", - "path": "blog", - "kind": "blog", - "extensions": [".md", ".mdx"] - }, - { - "name": "Resources", - "path": "resources", - "kind": "resources", - "extensions": [".md", ".mdx"] + "extensions": [".md", ".mdx"], + "source": { + "type": "local-folder", + "connectionId": "local-folder:", + "truthPolicy": "source_primary" + } } ], "components": "components", - "extensions": "extensions", - "hide": ["**/_*.md", "**/_*.mdx"] + "extensions": "extensions" } } } ``` -आप `AGENT_NATIVE_MODE=local-files` या -`AGENT_NATIVE_DATA_MODE=local-files`; मेनिफेस्ट को प्राथमिकता दी जाती है क्योंकि यह -फ़ोल्डर अनुबंध को रेपो में ही दस्तावेज़ित करता है। - -## सामग्री फ़ाइल स्वरूप - -सामग्री Markdown और MDX पढ़ती है। फ्रंटमैटर पेज मेटाडेटा रखता है, और बॉडी -संपादन योग्य दस्तावेज़: - -```mdx ---- -title: "आरंभ करना" -icon: "sparkles" -isFavorite: true -updatedAt: "2026-06-12T20:00:00.000Z" ---- - -# आरंभ करना - -Use to show framework-specific code. -``` - -उपस्थित होने पर शीर्षक `title` फ्रंटमैटर से आता है, अन्यथा -फ़ाइल नाम. संपादक MDX स्रोत को सुरक्षित रखता है जिसे वह अभी तक दृश्य रूप से संपादित नहीं कर सकता है, इसलिए -कोडिंग एजेंट और सामान्य टेक्स्ट संपादक सुरक्षित बच निकलते हैं। - -## कस्टम MDX घटक - -सामग्री कॉन्फ़िगर किए गए `components` फ़ोल्डर से स्थानीय घटकों का पूर्वावलोकन कर सकती है। -यह दस्तावेज़-शैली MDX घटकों जैसे टैब, कॉलआउट, पैकेज के लिए है -स्निपेट, या फ़्रेमवर्क-विशिष्ट कोड ब्लॉक स्थापित करें। - -उदाहरण के लिए, अपनी सामग्री के आगे एक इंटरैक्टिव घटक जोड़ें: - -```tsx -// components/ImpactCounter.tsx -import { useState } from "react"; - -export function ImpactCounter({ - label = "points", - accent = "blue", - featured = false, -}: { - label?: string; - accent?: "blue" | "green" | "purple"; - featured?: boolean; -}) { - const [count, setCount] = useState(3); - const accentClass = - accent === "green" - ? "border-green-300 bg-green-50" - : accent === "purple" - ? "border-purple-300 bg-purple-50" - : "border-blue-300 bg-blue-50"; - - return ( -
-
Launch impact
-
- {count} {label} -
- {featured ?
Featured metric
: null} - -
- ); -} - -export const ImpactCounterInputs = { - label: { - type: "string", - label: "Metric label", - default: "points", - }, - accent: { - type: "select", - label: "Accent", - options: ["blue", "green", "purple"], - default: "blue", - }, - featured: { - type: "boolean", - label: "Featured", - default: false, - }, -}; -``` - -फिर इसे किसी भी स्थानीय MDX फ़ाइल से उपयोग करें: - -```mdx ---- -title: "नोट्स लॉन्च करें" ---- - -# नोट्स लॉन्च करें - - -``` - -कंटेंट डेव सर्वर पास्कलकेस नाम के एक्सपोर्ट और पास्कलकेस डिफ़ॉल्ट का पता लगाता है -`components/` के अंतर्गत `.tsx`, `.jsx`, `.ts`, और `.js` फ़ाइलों से निर्यात। वे -घटक संपादक के अंदर प्रस्तुत होते हैं और नीचे स्लैश मेनू में दिखाई देते हैं -**स्थानीय घटक**. स्लैश प्रविष्टि एक न्यूनतम टैग बनाती है जैसे -``; आवश्यकता पड़ने पर MDX स्रोत में प्रॉप्स जोड़ें। - -घटक निष्पादन जानबूझकर एक स्थानीय-डेव/डेस्कटॉप ब्रिज क्षमता है, नहीं -सादा होस्ट किया गया ब्राउज़र फ़ोल्डर एक्सेस। यदि आप `content.agent-native.com` खोलते हैं, -**स्थानीय फ़ाइलें** चुनें, और Chrome में एक फ़ोल्डर चुनें, ऐप पढ़ और लिख सकता है -ब्राउज़र फ़ाइल सिस्टम एक्सेस API के माध्यम से `.md` और `.mdx` फ़ाइलें, लेकिन -क्रोम Vite को संकलित करने के लिए एक पूर्ण फ़ोल्डर पथ को उजागर नहीं करता है -`components/*.tsx`. कस्टम React घटकों का पूर्वावलोकन और हॉट रीलोड करने के लिए, चलाएँ -स्थानीय रूप से सामग्री या Agent Native डेस्कटॉप का उपयोग करें ताकि विश्वसनीय स्थानीय ब्रिज का उपयोग किया जा सके -चुने गए कार्यक्षेत्र को स्थानीय सामग्री विकास सर्वर के साथ पंजीकृत करें। उस मोड में, -मौजूदा घटक फ़ाइलों में संपादन Vite के माध्यम से हॉट रीलोड, और जोड़ना -घटक फ़ाइलों को हटाने से घटक रजिस्ट्री और स्लैश मेनू पुनः लोड होता है। - -एजेंट उन पंजीकृत घटक फ़ाइलों के साथ भी काम कर सकते हैं। उपयोग करें -`list-local-component-files` पंजीकृत कार्यक्षेत्र आईडी खोजने के लिए, फिर -`.tsx`, `.jsx`, `.ts`, या बनाने या अपडेट करने के लिए `write-local-component-file` -कार्यस्थान के `components/` फ़ोल्डर के अंतर्गत `.js` फ़ाइलें। MDX फ़ाइलें -घटक उपयोग के लिए सत्य का स्रोत; घटक फ़ाइलें सामान्य रेपो -स्रोत फ़ाइलों की Git के साथ समीक्षा की गई। - -यदि कोई घटक इनपुट मेटाडेटा निर्यात करता है, तो संपादक में घटक का चयन करें -घटक के ऊपरी-दाएँ कोने में एक संपादन बटन दिखाता है। समर्थित इनपुट प्रकार -`string`, `textarea`, `number`, `boolean`, और `select` हैं। फॉर्म लिखता है -वापस MDX टैग में बदल जाता है, इसलिए स्थानीय फ़ाइलें सत्य का स्रोत बनी रहती हैं। द -मेटाडेटा को `ComponentNameInputs`, `ComponentNameConfig.inputs`, के रूप में निर्यात किया जा सकता है -`Component.inputs`, या `agentNative.inputs`. - -शाब्दिक प्रॉप्स के साथ सरल घटक टैग इनलाइन पूर्वावलोकन कर सकते हैं: - -```mdx - - - - -This setting affects production deploys. - - -``` - -जटिल JSX अभिव्यक्तियाँ स्रोत में संरक्षित हैं। यदि संपादक सुरक्षित रूप से -अभी तक एक घटक प्रोप का पूर्वावलोकन करें, यह इसके बजाय एक चेतावनी प्लेसहोल्डर दिखाता है -चुपचाप डेटा छोड़ रहा है। - -## स्थानीय फ़ाइलें साझा करना - -स्थानीय फ़ाइलें सीधे साझा नहीं की जाती हैं क्योंकि अन्य उपयोगकर्ता पथ नहीं पढ़ सकते हैं -आपकी मशीन। सामग्री टूलबार का शेयर बटन एक -चयनित फ़ाइल की डेटाबेस-समर्थित प्रतिलिपि, उस प्रतिलिपि पर नेविगेट करती है, और खोलती है -सामान्य शेयर पॉपओवर। मूल स्थानीय फ़ाइल स्थानीय फ़ाइलों के अंतर्गत रहती है; द -डेटाबेस कॉपी स्थानीय फ़ाइल मोड में साझा प्रतियों के अंतर्गत दिखाई देती है और -मानक दस्तावेज़ साझाकरण मॉडल। - -## स्थानीय एक्सटेंशन - -स्थानीय फ़ाइल मोड कॉन्फ़िगर किए गए से रेपो-समर्थित एक्सटेंशन भी लोड कर सकता है -`extensions` फ़ोल्डर. प्रत्येक एक्सटेंशन एक `extension.json` -मैनिफ़ेस्ट और एक HTML प्रविष्टि फ़ाइल: - -```text -extensions/ - doc-status/ - extension.json - index.html -``` - -```json -{ - "id": "doc-status", - "name": "Doc Status", - "description": "Shows metadata for the selected Content file.", - "entry": "index.html", - "slots": ["content.sidebar.bottom"], - "permissions": { - "appActions": ["list-documents"], - "extensionData": true - } -} -``` - -`index.html` वही अल्पाइन/Tailwind एक्सटेंशन बॉडी फॉर्मेट है जिसका उपयोग सामान्य लोग करते हैं -डेटाबेस-समर्थित एक्सटेंशन। जब कंटेंट ऐप एक स्थानीय एक्सटेंशन देखता है तो -`content.sidebar.bottom` घोषित करता है, यह उस एक्सटेंशन को नीचे प्रस्तुत करता है -the Content sidebar. The host passes `window.slotContext` with the selected -दस्तावेज़ आईडी, शीर्षक, स्रोत मेटाडेटा, और क्या सामग्री स्थानीय फ़ाइल मोड में है। - -स्थानीय एक्सटेंशन का पूर्वावलोकन ऐप द्वारा किया जाता है लेकिन फ़ाइलों के रूप में संपादित किया जाता है। एक्सटेंशन -सूची उन्हें स्थानीय फ़ाइल बैज के साथ दिखाती है, और पूर्ण-पृष्ठ व्यूअर वापस इंगित करता है -प्रविष्टि फ़ाइल. SQL-समर्थित एक्सटेंशन actions जैसे अपडेट, डिलीट, शेयर और -इतिहास लागू नहीं होता; इसके लिए अपने संपादक, Codex, Claude कोड, या Git इतिहास का उपयोग करें -स्रोत परिवर्तन। - -v1 के लिए, स्थानीय एक्सटेंशन जानबूझकर रूढ़िवादी हैं: - -- वे अपनी छोटी रनटाइम स्थिति के लिए `extensionData` का उपयोग कर सकते हैं -- वे केवल `extension.json` में सूचीबद्ध `appAction`s को कॉल कर सकते हैं -- कच्चे SQL सहायक और बाहरी `extensionFetch` अक्षम हैं -- स्लॉट लक्ष्य `extension.json` में घोषित किए गए हैं, SQL के माध्यम से स्थापित नहीं किए गए हैं - -यह स्थानीय कार्यस्थानों को बिना किसी अनुमति के ओब्सीडियन जैसी प्लगइन सतह देता है -मनमाना रेपो फ़ाइल डेटाबेस-समर्थित एक्सटेंशन की प्रत्येक क्षमता को प्राप्त करती है। - -## ऐप्स इसका उपयोग कैसे करते हैं - -स्थानीय फ़ाइल मोड को फ़्रेमवर्क के स्थानीय आर्टिफैक्ट सहायकों के माध्यम से कार्यान्वित किया जाता है। -एक ऐप अपने स्वामित्व वाले आर्टिफैक्ट प्रकारों के लिए रूट घोषित करता है, फिर पढ़ता और लिखता है -उसी क्रिया के माध्यम से इसकी सतह UI और एजेंट पहले से ही उपयोग करते हैं। - -सामग्री के लिए, इसका अर्थ है: - -- `list-documents` सूचीबद्ध `.md` और `.mdx` फ़ाइलें। -- `get-document` एक चयनित स्थानीय फ़ाइल को पढ़ता है। -- `update-document` चयनित स्थानीय फ़ाइल लिखता है। -- `create-document` चयनित फ़ोल्डर में एक नई स्थानीय `.mdx` फ़ाइल बनाता है। -- `delete-document` स्थानीय फ़ाइल को हटा देता है। -- खोज कॉन्फ़िगर की गई स्थानीय फ़ाइलों पर चलती है। - -सामग्री UI से स्थानीय-फ़ाइल पृष्ठों को स्थानांतरित करना, नाम बदलना और पुन: व्यवस्थित करना -अभी तक समर्थित है। उन कार्यों को कार्यक्षेत्र में या कोडिंग एजेंट के साथ करें; द -सामग्री साइडबार परिणामी फ़ाइल ट्री को प्रतिबिंबित करेगा। - -यह एजेंट अनुबंध को सरल रखता है: एजेंट सामग्री actions का उपयोग जारी रख सकता है, -और वे actions तय करते हैं कि लक्ष्य SQL-समर्थित है या फ़ाइल-समर्थित है। - -अन्य ऐप्स समय के साथ समान पैटर्न अपना सकते हैं। एक स्लाइड ऐप मैप कर सकता है -`slides/*.mdx` को डेक पर, एक प्लान ऐप दस्तावेजों को प्लान करने के लिए `plans/*` को मैप कर सकता है, और एक -डैशबोर्ड ऐप `dashboards/*.mdx` को डैशबोर्ड पर मैप कर सकता है। वे ऐप-विशिष्ट -फ़ोल्डर समान स्थानीय आर्टिफैक्ट अनुबंध के शीर्ष पर स्तरित कन्वेंशन हैं। - -## स्थानीय फ़ाइलें बनाम निर्यात/आयात - -सामग्री में दो भिन्न फ़ाइल वर्कफ़्लो हैं: - -| वर्कफ़्लो | क्या होता है | -| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | -| `/local-files` निर्यात/आयात | डेटाबेस मोड सत्य का स्रोत बना हुआ है। फ़ाइलें एक स्पष्ट सिंक सतह हैं जिन्हें आप निर्यात, संपादित, पूर्वावलोकन और आयात करते हैं। | -| स्थानीय फ़ाइल मोड | फ़ाइलें सत्य का स्रोत हैं। सामग्री साइडबार और संपादक सीधे स्थानीय फ़ाइलों पर काम करते हैं। | - -जब आप होस्ट किए गए कार्यक्षेत्र के आसपास कभी-कभी फ़ाइल समीक्षा चाहते हैं तो निर्यात/आयात का उपयोग करें। -स्थानीय फ़ाइल मोड का उपयोग करें जब रेपो स्वयं कार्यक्षेत्र हो। - -## इतिहास और सहयोग - -स्थानीय फ़ाइल मोड फ़ाइल-मूल इतिहास पर निर्भर करता है: - -- Git में महत्वपूर्ण परिवर्तन करें -- समीक्षा के लिए पुल अनुरोधों का उपयोग करें -- कोडिंग एजेंटों को उन्हीं फ़ाइलों को सीधे संपादित करने दें -- परिवर्तनों को समझने के लिए सामान्य फ़ाइल अंतर का उपयोग करें - -डेटाबेस मोड होस्ट की गई सहयोग सुविधाओं जैसे -साझाकरण, टिप्पणियाँ, SQL-समर्थित संस्करण इतिहास, और लाइव बहु-उपयोगकर्ता संपादन। - -प्रदाता सिंक को किसी भी मोड के शीर्ष पर स्तरित किया जा सकता है। उदाहरण के लिए, एक डॉक्स रेपो -actions जोड़ें जो CMS से सामग्री को स्थानीय MDX फ़ाइलों में खींचता है या चयनित पुश करता है -स्थानीय फ़ाइलें उस CMS पर वापस आती हैं। - -## उत्पादन सुरक्षा - -स्थानीय फ़ाइल मोड ऐप actions को कॉन्फ़िगर किए गए कार्यक्षेत्र तक सीधी लेखन पहुंच प्रदान करता है -फ़ाइलें। यह स्थानीय विकास और विश्वसनीय एकल-किरायेदार फ़ाइल -पुल, लेकिन यह डिफ़ॉल्ट उत्पादन सुरक्षा मॉडल नहीं है। - -जब `NODE_ENV=production`, फ्रेमवर्क `local-files` मोड को अस्वीकार कर देता है जब तक कि आप -सेट: - -```bash -AGENT_NATIVE_ALLOW_LOCAL_FILES_IN_PRODUCTION=true -``` - -इसे केवल विश्वसनीय एकल-किरायेदार परिनियोजन के लिए सेट करें जहां हर कोई इसका उपयोग कर सके -ऐप को कॉन्फ़िगर की गई फ़ाइलों को पढ़ने और लिखने की अनुमति है। सामान्य होस्ट के लिए, -बहु-उपयोगकर्ता ऐप्स, डेटाबेस मोड और SQL-समर्थित साझाकरण का उपयोग करें। +Migration में `mode: "local-files"` और `AGENT_NATIVE_MODE=local-files` हटाएँ, +roots रखें और `source` जोड़ें। Frontmatter ID rename या move पर पेज की पहचान +बचाता है। लोकल components और extensions trusted local bridge से ही लोड होते हैं। diff --git a/packages/core/docs/content/locales/hi-IN/template-content.mdx b/packages/core/docs/content/locales/hi-IN/template-content.mdx index 3234d4c838..667449c6d9 100644 --- a/packages/core/docs/content/locales/hi-IN/template-content.mdx +++ b/packages/core/docs/content/locales/hi-IN/template-content.mdx @@ -93,9 +93,8 @@ description: "MDX के लिए ओपन-सोर्स ओब्सीड - **पेड़ों को एक पेड़ में व्यवस्थित करें** - असीमित रूप से घोंसला बनाएं, पुन: व्यवस्थित करने के लिए खींचें, पसंदीदा पृष्ठ जिनका आप अक्सर उपयोग करते हैं। - **सभी चीज़ों में खोजें** शीर्षकों और सामग्री में पूर्ण-पाठ खोज के साथ। - **ओब्सीडियन की तरह स्थानीय Markdown/MDX फ़ाइलों को संपादित करें।** `/local-files` दृश्य का उपयोग करें - अपने कार्यक्षेत्र को फ़ाइलों में निर्यात करने के लिए, उन्हें अपने टूल में संपादित करें, पूर्वावलोकन करें - परिवर्तन करें, और उन्हें वापस आयात करें। स्थानीय फ़ाइल मोड में, सामग्री सीधे - चयनित `.md` या `.mdx` फ़ाइल। + फ़ोल्डर को Files से जोड़ें, अपने टूल में संपादित और sync करें, और SQL ऐप में + टकराव की समीक्षा करें। - **समृद्ध इंटरैक्टिव कस्टम ब्लॉक उत्पन्न करें।** स्थानीय React घटकों को पंजीकृत करें, उन्हें MDX के रूप में डालें, और एजेंट को घटक फ़ाइलें बनाने या अपडेट करने दें आपके दस्तावेज़. @@ -138,32 +137,19 @@ description: "MDX के लिए ओपन-सोर्स ओब्सीड टिप्पणियाँ, अनुमतियाँ, और लाइव सहयोग; स्थानीय फ़ोल्डर एक स्पष्ट सिंक सतह। -सामग्री **स्थानीय फ़ाइल मोड** में भी चल सकती है, जहाँ फ़ाइलें स्रोत हैं -SQL दस्तावेज़ों के बजाय सत्य। रेपो में `agent-native.json` जोड़ें, सेट करें -`mode: "local-files"`, और `docs/`, `blog/`, जैसे रूट कॉन्फ़िगर करें -`content/`, और `resources/`। मानक सामग्री संपादक तब इसे पॉप्युलेट करता है -उन स्थानीय `.md`/`.mdx` फ़ाइलों से बायाँ साइडबार और संपादन वापस लिखता है -सामान्य दस्तावेज़ actions के माध्यम से चयनित फ़ाइल। रेपो-प्रथम दस्तावेज़ों के लिए इसका उपयोग करें, -ब्लॉग, संसाधन लाइब्रेरी, या MDX-संचालित के साथ ओब्सीडियन-शैली की व्यक्तिगत सामग्री -घटक; जब आप होस्टेड सहयोग चाहते हैं तो डेटाबेस मोड पर वापस जाएँ और -SQL-backed sharing. See [Local File Mode](/docs/local-file-mode) for the -स्टैंडअलोन रेपो लेआउट, कॉन्फ़िगरेशन, कस्टम MDX घटक, स्थानीय -`extensions/` विजेट, और उत्पादन सुरक्षा गाइड। +लोकल फ़ोल्डर अब सभी पेजों जैसा SQL मॉडल उपयोग करते हैं। हर पेज किसी स्पेस और +उसके Files डेटाबेस में रहता है; फ़ोल्डर उसका source adapter है। नीतियों, टकराव, +MDX components और पुराने मोड से migration के लिए +[लोकल फ़ोल्डर स्रोत](/docs/local-file-mode) देखें। -सामग्री स्थानीय-फ़ाइल कौशल को मौजूदा रेपो में स्थापित करने के लिए: +मौजूदा repository में Content खोलने के लिए: ```bash -npx @agent-native/core@latest skills add content --mode local-files --scope project +npx @agent-native/core@latest content . ``` -इंस्टॉलर आपके कोडिंग एजेंट के लिए `content` कौशल की प्रतिलिपि बनाता है और लिखता है या -`docs/`, `blog/`, `content/` के लिए कंटेंट रूट्स के साथ `agent-native.json` को अपडेट करता है -और `resources/`। जब कोई स्थानीय सामग्री ऐप, Agent Native डेस्कटॉप, या विश्वसनीय -स्थानीय ब्रिज चल रहा है, एजेंटों को सामग्री actions जैसे -`list-documents`, `get-document`, `edit-document`, `update-document`, और -`share-local-file-document` कच्चे फ़ाइल सिस्टम के बजाय लिखता है। उस लोकल के बिना -ब्रिज, स्थापित कौशल अभी भी एजेंट को -सुरक्षित Markdown/MDX संपादन। +CLI सामान्य database-backed Content शुरू करता है, फ़ोल्डर स्रोत दर्ज करता है और +बिना mode environment variable के कनेक्शन स्क्रीन खोलता है। ## डेवलपर्स के लिए diff --git a/packages/core/docs/content/locales/ja-JP/local-file-mode.mdx b/packages/core/docs/content/locales/ja-JP/local-file-mode.mdx index aaab236ecb..c823be8568 100644 --- a/packages/core/docs/content/locales/ja-JP/local-file-mode.mdx +++ b/packages/core/docs/content/locales/ja-JP/local-file-mode.mdx @@ -1,525 +1,69 @@ --- -title: "ローカル ファイル モード" -description: "ローカルの Markdown、MDX、およびその他のリポジトリ ファイルを信頼できるソースとして使用して、エージェント ネイティブ アプリを実行します。これには、カスタム コンポーネントを含む Obsidian スタイルの MDX ドキュメントが含まれます。" +title: "ローカルフォルダーソース" +description: "Markdown / MDX フォルダーを、データベースで管理される Content スペースへ接続します。" --- -# ローカル ファイル モード +# ローカルフォルダーソース -ローカル ファイル モードにより、エージェント ネイティブ アプリは通常の UI とアクション サーフェスをアタッチできます -リポジトリまたはワークスペース内のファイルに直接。アプリは依然としてホストされているように感じます -製品ですが、そのリスト ビュー、エディタ、エージェント ツールはローカル ファイルの読み取りと書き込みを行います -SQL がサポートするアプリ レコードの代わり。 +Content のデータモデルは 1 つです。すべてのページは SQL に保存され、 +**スペース**に属し、そのスペースのシステムデータベース **Files** の項目に +なります。ローカルフォルダーは Files に接続するソースであり、別のアプリ +モードではありません。`AGENT_NATIVE_MODE` 切り替えは廃止されました。 -最初の実装はコンテンツ テンプレート内にあります。左側のサイドバーは -ローカルの `.md` および `.mdx` ファイルから設定され、ページを選択すると標準ファイルが開きます -コンテンツ エディター、および保存すると、選択したファイルに書き戻されます。同じファイルは -Codex、Claude コード、Agent-Native サイドバー エージェント、または通常のエージェントでも編集できます -編集者。 +ユーザーには個人スペース、アクセス可能な組織には組織スペースがあり、個人の +**Workspaces** カタログが表示対象を決めます。Files は通常の Content +データベースなので、プロパティ、フィルター、並べ替え、グループ、保存ビューを +サイドバー表示にも利用できます。 -コンテンツに関しては、これにより製品が MDX のオープンソース Obsidian のように感じられます: -ドキュメントはファイルとして保存されますが、アプリにはビジュアル エディター、エージェント actions が追加されます。 -共有可能なコピー、および豊富なインタラクティブな MDX コンポーネント。 +## フォルダーを接続する -リポジトリ優先のワークフローが必要な場合は、ローカル ファイル モードを使用します。 +ブラウザーまたは Agent Native Desktop でフォルダーを選び、既存スペースへ +接続するか、専用の非公開スペースを作成します。SQL に保存されるのは不透明な +接続 ID、相対パス、ハッシュ、ソースメタデータのみです。絶対パス、ブラウザー +ハンドル、ファイル本文は保存されません。 -- `docs/*.mdx` のドキュメント リポジトリ -- `blog/*.mdx` のブログ -- `resources/*.md` のポジショニング、メッセージング、チームメモなどのリソース -- より豊富な MDX エディターを備えた個人用の Obsidian スタイルのナレッジ ベース -- ローカルの React コードから生成されたインタラクティブなカスタム MDX ブロックを必要とするドキュメント -- コーディング エージェントが簡単に検査してパッチを適用できるアプリ アーティファクト +ポリシーは `database_primary`、`source_primary`、 +`reviewed_bidirectional` です。同期はファイルを通常の SQL ページとして +実体化します。両側が同時に変わった場合は上書きせず、レビュー対象の変更を +作成します。切断してもページとローカルファイルは残ります。 -ホストされたコラボレーション アプリのエクスペリエンスが必要な場合は、データベース モードを使用します。 -マルチユーザー共有、SQL による権限、コメント、バージョン履歴、および -ローカル ファイル システムにアクセスしない運用ホスティング。 - -## メンタルモデル - -2 つの信頼できる情報源モードがあります: - -| モード | 真実の源 | こんな人に最適 | -| ------------------------ | --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | -| データベースモード | SQL 行から Drizzle | ホストされているアプリ、コラボレーション、共有、コメント、バージョン履歴 | -| ローカル ファイル モード | `agent-native.json` によって宣言されたリポジトリ ファイル | ローカル/開発ワークフロー、Git レビュー、コーディング エージェント編集、ファイルネイティブ コンテンツ | - -UI とエージェント actions は、両方のモードで同じ形状のままである必要があります。コンテンツ -エディターは引き続きドキュメントを編集します。違いは、それらの文書が解決されるかどうかです -SQL 行またはローカル ファイルへ。 - - - -```html -
-
-
コンテンツUI
-
- エージェント + actions
list/get/update-document -
-
- -
-
- データベースモードSQL rows via Drizzleホスト版 · 共有 · コメント · 履歴 -
-
- Local File Moderepo files via agent-native.jsonGitレビュー · コーディングエージェント編集 -
-
-
-``` - -```css -.diagram-mode { - display: flex; - align-items: center; - gap: 14px; - flex-wrap: wrap; -} -.diagram-mode .diagram-col { - display: flex; - flex-direction: column; - gap: 10px; -} -.diagram-mode .diagram-arrow { - font-size: 22px; - line-height: 1; -} -.diagram-mode .resolve { - display: flex; - gap: 12px; - flex-wrap: wrap; -} -.diagram-mode .diagram-panel { - display: flex; - flex-direction: column; - gap: 4px; - padding: 12px 14px; -} -``` - -
- -## リポジトリの例 - -コンテンツ ワークスペースは次のように小さくすることができます: - - - -ローカル ファイル モードでは、コンテンツ サイドバーに `docs/`、`blog/`、および -`resources/` ツリーをページとして表示します。 `docs/getting-started.mdx` を選択すると、 -ファイル。 UI で編集すると -`docs/getting-started.mdx`. - -`components/` はコンテンツ ルートではありません。 MDX -ファイルはインポートまたは参照できます。エディターは単純なローカル MDX コンポーネントをレンダリングできます -コンテンツ アプリ全体を複製したりフォークしたりする必要はありません。 - -`extensions/` もコンテンツ ルートではありません。これはローカル拡張ライブラリです: -ソースをアプリ スロットに保持したままアプリ スロットでレンダリングできる小さなサンドボックス ウィジェット -リポジトリ。 - -## コンテンツをリポジトリにインストールする - -既存のドキュメント、ブログ、または MDX ワークスペースの場合は、コンテンツ ローカル ファイルをインストールします -スキル: +## リポジトリから起動 ```bash -npx @agent-native/core@latest skills add content --mode local-files --scope project +npx @agent-native/core@latest content . ``` -これにより、`content` スキルがリポジトリのエージェント スキル フォルダにコピーされ、書き込まれます -または、コンテンツのデフォルトで `agent-native.json` を更新します。 - -- ワークスペース レベルの `mode: "local-files"` -- `apps.content.mode: "local-files"` -- `docs/`、`blog/`、`content/`、および `resources/` のコンテンツ ルート -- ローカル MDX コンポーネントの `components/` -- ローカル拡張ウィジェット用の `extensions/` - -インストールされたスキルは、コーディング エージェントにコンテンツ actions を使用するよう指示します -(`list-documents`, `get-document`, `edit-document`, `update-document`, -`share-local-file-document`、およびコンポーネント ファイル actions) (ローカル コンテンツ アプリの場合) -または Agent Native デスクトップ ブリッジがそれらを公開します。橋が稼働していない場合、スキル -フロントマター、インポート、JSX を保持しながら、安全な直接リポジトリ編集にフォールバックします。 -そして不明な MDX。 - -## 構成 +通常のデータベース版 Content が起動し、フォルダーをソースとして登録して接続 +画面を開きます。モード用環境変数は設定しません。 -`agent-native.json` をリポジトリまたはワークスペースのルートに追加します。 +## 設定 ```json { "version": 1, "apps": { "content": { - "mode": "local-files", "roots": [ { "name": "Docs", "path": "docs", "kind": "docs", - "extensions": [".md", ".mdx"] - }, - { - "name": "Blog", - "path": "blog", - "kind": "blog", - "extensions": [".md", ".mdx"] - }, - { - "name": "Resources", - "path": "resources", - "kind": "resources", - "extensions": [".md", ".mdx"] + "extensions": [".md", ".mdx"], + "source": { + "type": "local-folder", + "connectionId": "local-folder:", + "truthPolicy": "source_primary" + } } ], "components": "components", - "extensions": "extensions", - "hide": ["**/_*.md", "**/_*.mdx"] + "extensions": "extensions" } } } ``` -`AGENT_NATIVE_MODE=local-files` または -`AGENT_NATIVE_DATA_MODE=local-files`;マニフェストが優先されるのは、 -リポジトリ自体のフォルダー コントラクトを文書化します。 - -## コンテンツ ファイル形式 - -コンテンツは Markdown および MDX を読み取ります。 Frontmatter はページのメタデータを保持し、本文は -編集可能なドキュメント: - -```mdx ---- -title: "はじめに" -icon: "sparkles" -isFavorite: true -updatedAt: "2026-06-12T20:00:00.000Z" ---- - -# はじめに - -Use to show framework-specific code. -``` - -タイトルは、存在する場合は `title` フロントマターから取得され、それ以外の場合は、 -ファイル名。エディターは、まだ視覚的に編集できない MDX ソースを保持します。そのため -コーディング エージェントと通常のテキスト エディタは安全な避難口のままです。 - -## カスタム MDX コンポーネント - -コンテンツは、構成された `components` フォルダーからローカル コンポーネントをプレビューできます。 -これは、タブ、コールアウト、パッケージなどのドキュメント スタイルの MDX コンポーネントを対象としています -スニペット、またはフレームワーク固有のコード ブロックをインストールします。 - -たとえば、コンテンツの隣にインタラクティブ コンポーネントを追加します。 - -```tsx -// components/ImpactCounter.tsx -import { useState } from "react"; - -export function ImpactCounter({ - label = "points", - accent = "blue", - featured = false, -}: { - label?: string; - accent?: "blue" | "green" | "purple"; - featured?: boolean; -}) { - const [count, setCount] = useState(3); - const accentClass = - accent === "green" - ? "border-green-300 bg-green-50" - : accent === "purple" - ? "border-purple-300 bg-purple-50" - : "border-blue-300 bg-blue-50"; - - return ( -
-
Launch impact
-
- {count} {label} -
- {featured ?
Featured metric
: null} - -
- ); -} - -export const ImpactCounterInputs = { - label: { - type: "string", - label: "Metric label", - default: "points", - }, - accent: { - type: "select", - label: "Accent", - options: ["blue", "green", "purple"], - default: "blue", - }, - featured: { - type: "boolean", - label: "Featured", - default: false, - }, -}; -``` - -次に、ローカルの MDX ファイルからそれを使用します。 - -```mdx ---- -title: "起動ノート" ---- - -# 起動ノート - - -``` - -Content dev サーバーは、PascalCase という名前のエクスポートと PascalCase のデフォルトを検出します -は、`.tsx`、`.jsx`、`.ts`、および `.js` ファイルから `components/` の下にエクスポートします。それら -コンポーネントはエディタ内でレンダリングされ、 -**ローカルコンポーネント**。スラッシュを挿入すると、 -``;必要に応じて、MDX ソースにプロパティを追加します。 - -コンポーネントの実行は、意図的にローカル開発/デスクトップ ブリッジ機能ではなく、 -プレーン ホスト ブラウザー フォルダー アクセス。 `content.agent-native.com` を開くと、 -**ローカル ファイル** を選択し、Chrome でフォルダーを選択します。アプリが読み書きできる -`.md` および `.mdx` ファイルは、ブラウザ ファイル システム アクセス API を介してアクセスされますが、 -Chrome は、Vite をコンパイルするための絶対フォルダー パスを公開しません -`components/*.tsx`。カスタム React コンポーネントをプレビューしてホット リロードするには、 -コンテンツをローカルに保存するか、信頼できるローカル ブリッジができるように Agent Native デスクトップを使用します -選択したワークスペースをローカルのコンテンツ開発サーバーに登録します。そのモードでは、 -Vite を介して既存のコンポーネント ファイルを編集し、ホット リロードし、または追加します。 -コンポーネント ファイルを削除すると、コンポーネント レジストリとスラッシュ メニューが再ロードされます。 - -エージェントは、登録されたコンポーネント ファイルを操作することもできます。使用 -`list-local-component-files` で登録されたワークスペース ID を検索し、 -`write-local-component-file` は、`.tsx`、`.jsx`、`.ts` を作成または更新します。 -ワークスペースの `components/` フォルダーにある `.js` ファイル。 MDX ファイルは -コンポーネントの使用法に関する信頼できる情報源。コンポーネント ファイルは通常のリポジトリのままです -Git でレビューされたソース ファイル。 - -コンポーネントが入力メタデータをエクスポートする場合、エディターでコンポーネントを選択する -は、コンポーネントの右上隅に編集ボタンを表示します。サポートされている入力タイプ -は、`string`、`textarea`、`number`、`boolean`、および `select` です。フォームには -は MDX タグに戻るため、ローカル ファイルが信頼できる情報源のままになります。 -メタデータは `ComponentNameInputs`、`ComponentNameConfig.inputs` としてエクスポートできます -`Component.inputs`、または `agentNative.inputs`。 - -リテラル props を含む単純なコンポーネント タグはインラインでプレビューできます。 - -```mdx - - - - -This setting affects production deploys. - - -``` - -複雑な JSX 式はソースに保存されます。編集者が安全に作業できない場合 -コンポーネント プロップをプレビューしても、警告のプレースホルダーが表示されません。 -サイレントにデータを削除します。 - -## ローカル ファイルの共有 - -他のユーザーはパスを読み取ることができないため、ローカル ファイルは直接共有されません -あなたのマシン。コンテンツ ツールバーの [共有] ボタンは、 -選択したファイルのデータベースにバックアップされたコピー。そのコピーに移動して、 -通常の共有ポップオーバー。元のローカル ファイルはローカル ファイルの下に残ります。 -データベース コピーは、ローカル ファイル モードの共有コピーの下に表示され、 -標準ドキュメント共有モデル。 - -## ローカル内線番号 - -ローカル ファイル モードでは、設定されたファイルからリポジトリにバックアップされた拡張機能をロードすることもできます。 -`extensions` フォルダー。各拡張子は、`extension.json` -マニフェストと HTML エントリ ファイル: - -```text -extensions/ - doc-status/ - extension.json - index.html -``` - -```json -{ - "id": "doc-status", - "name": "Doc Status", - "description": "Shows metadata for the selected Content file.", - "entry": "index.html", - "slots": ["content.sidebar.bottom"], - "permissions": { - "appActions": ["list-documents"], - "extensionData": true - } -} -``` - -`index.html` は、通常で使用されるのと同じ Alpine/Tailwind 拡張本体形式です -データベースベースの拡張機能。コンテンツ アプリがローカル拡張機能を認識すると、 -`content.sidebar.bottom` を宣言すると、 -the Content sidebar. The host passes `window.slotContext` with the selected -ドキュメント ID、タイトル、ソース メタデータ、コンテンツがローカル ファイル モードかどうか。 - -ローカル拡張子はアプリによってプレビューされますが、ファイルとして編集されます。拡張機能 -リストにはローカル ファイル バッジが表示され、フルページ ビューアは に戻ります -エントリファイル。 SQL でサポートされる拡張機能 actions (更新、削除、共有、 -履歴は適用されません。エディタ、Codex、Claude コード、または Git 履歴を使用してください。 -ソースの変更。 - -v1 の場合、ローカル拡張機能は意図的に保守的です: - -- 独自の小さな実行時状態に `extensionData` を使用できます -- `extension.json` にリストされている `appAction` のみを呼び出すことができます -- 生の SQL ヘルパーと外部 `extensionFetch` は無効になります -- スロット ターゲットは `extension.json` で宣言されており、SQL を通じてインストールされていません - -これにより、ローカル ワークスペースに Obsidian のようなプラグイン サーフェスが提供されます。 -任意のリポジトリ ファイルは、データベースベースの拡張機能のすべての機能を継承します。 - -## アプリでの使用方法 - -ローカル ファイル モードは、フレームワークのローカル アーティファクト ヘルパーを通じて実装されます。 -アプリは、所有するアーティファクト タイプのルートを宣言し、読み取りと書き込みを行います -UI とエージェントがすでに使用している同じアクション サーフェスを介して。 - -コンテンツの場合、それは次のことを意味します: - -- `list-documents` には、構成された `.md` および `.mdx` ファイルがリストされます。 -- `get-document` は、選択されたローカル ファイルを読み取ります。 -- `update-document` は、選択されたローカル ファイルを書き込みます。 -- `create-document` は、選択したフォルダーに新しいローカル `.mdx` ファイルを作成します。 -- `delete-document` はローカル ファイルを削除します。 -- 検索は、設定されたローカル ファイル全体で実行されます。 - -コンテンツ UI からのローカル ファイル ページの移動、名前変更、並べ替えはできません -はまだサポートされています。これらの操作はワークスペースまたはコーディング エージェントを使用して実行します。 -コンテンツ サイドバーには、結果のファイル ツリーが反映されます。 - -これにより、エージェント契約がシンプルになります。エージェントはコンテンツ actions を使用し続けることができます。 -これらの actions は、ターゲットが SQL ベースであるかファイルベースであるかを決定します。 - -他のアプリも時間の経過とともに同じパターンを採用する可能性があります。スライド アプリではマッピングが可能 -`slides/*.mdx` をデッキに、計画アプリは `plans/*` を計画ドキュメントにマッピングでき、 -ダッシュボード アプリでは、`dashboards/*.mdx` をダッシュボードにマッピングできます。アプリ固有のもの -フォルダは、同じローカル アーティファクト コントラクトの上に階層化された規則です。 - -## ローカル ファイルとエクスポート/インポート - -コンテンツには 2 つの異なるファイル ワークフローがあります: - -| ワークフロー | 何が起こるか | -| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `/local-files` エクスポート/インポート | データベース モードは依然として信頼できる情報源です。ファイルは、エクスポート、編集、プレビュー、インポートするための明示的な同期面です。 | -| ローカル ファイル モード | ファイルは真実の情報源です。コンテンツ サイドバーとエディタはローカル ファイルで直接動作します。 | - -ホストされたワークスペースでファイルを時折確認したい場合は、エクスポート/インポートを使用します。 -リポジトリ自体がワークスペースである場合は、ローカル ファイル モードを使用します。 - -## 歴史とコラボレーション - -ローカル ファイル モードはファイルネイティブの履歴に基づいています: - -- 重要な変更を Git にコミットする -- プル リクエストをレビューに使用する -- コーディング エージェントが同じファイルを直接編集できるようにする -- 通常のファイルの差分を使用して変更を理解する - -データベース モードは、引き続きホスト型コラボレーション機能に適しています。 -共有、コメント、SQL によるバージョン履歴、ライブ マルチユーザー編集。 - -プロバイダー同期は、どちらのモードの上にも重ねることができます。たとえば、ドキュメント リポジトリでは、 -CMS からローカル MDX ファイルにコンテンツをプルする actions を追加するか、選択したものをプッシュします -ローカル ファイルを CMS に戻します。 - -## 生産の安全性 - -ローカル ファイル モードでは、アプリ actions に構成されたワークスペースへの直接書き込みアクセスが許可されます -ファイル。これは、ローカル開発および信頼できるシングルテナント ファイルに適しています -ブリッジですが、これはデフォルトの実稼働セキュリティ モデルではありません。 - -`NODE_ENV=production` の場合、フレームワークは、次の操作を行わない限り `local-files` モードを拒否します。 -セット: - -```bash -AGENT_NATIVE_ALLOW_LOCAL_FILES_IN_PRODUCTION=true -``` - -誰もが使用できる信頼できるシングルテナント展開に対してのみ設定してください -アプリは構成されたファイルの読み取りと書き込みを許可されます。通常のホストの場合、 -マルチユーザー アプリでは、データベース モードと SQL による共有を使用します。 +移行時は `mode: "local-files"` と `AGENT_NATIVE_MODE=local-files` を +削除し、roots を残して `source` を追加します。frontmatter の ID は名前変更や +移動でもページ識別を保ちます。ローカルコンポーネントと拡張機能は引き続き信頼 +されたローカルブリッジ経由で読み込まれます。 diff --git a/packages/core/docs/content/locales/ja-JP/template-content.mdx b/packages/core/docs/content/locales/ja-JP/template-content.mdx index 828f6cc420..a926f78b7c 100644 --- a/packages/core/docs/content/locales/ja-JP/template-content.mdx +++ b/packages/core/docs/content/locales/ja-JP/template-content.mdx @@ -93,9 +93,8 @@ description: "オープンソースの Obsidian for MDX: ローカルの Markdow - **ページをツリーに整理** — 無限にネストしたり、ドラッグして並べ替えたり、よく使うお気に入りのページを作成したりできます。 - **タイトルやコンテンツ全体の全文検索を使用して、すべてを検索**します。 - **Obsidian などのローカル Markdown/MDX ファイルを編集します。** `/local-files` ビューを使用します - ワークスペースをファイルにエクスポートし、独自のツールで編集し、プレビューするには - 変更を加えてインポートし直します。ローカル ファイル モードでは、コンテンツは - 選択した `.md` または `.mdx` ファイル。 + フォルダーを Files に接続し、独自のツールで編集して変更を同期し、 + SQL アプリ内で競合をレビューできます。 - **リッチでインタラクティブなカスタム ブロックを生成します。** ローカル React コンポーネントを登録します。 それらを MDX として挿入し、エージェントにコンポーネント ファイルを作成または更新させます あなたのドキュメント。 @@ -138,32 +137,19 @@ description: "オープンソースの Obsidian for MDX: ローカルの Markdow コメント、権限、ライブコラボレーション。ローカル フォルダーは明示的です サーフェスを同期します。 -コンテンツは、ファイルがソースとなる **ローカル ファイル モード** でも実行できます。 -SQL 文書の代わりに真実。 `agent-native.json` をリポジトリに追加し、 -`mode: "local-files"`、`docs/`、`blog/` などのルートを構成します -`content/`、および `resources/`。標準のコンテンツ エディタは、 -ローカルの `.md`/`.mdx` ファイルから左側のサイドバーを選択し、編集内容をファイルに書き込みます。 -通常のドキュメント actions を通じて選択されたファイル。これをリポジトリファーストのドキュメントに使用します。 -ブログ、リソース ライブラリ、または MDX を利用した Obsidian スタイルの個人コンテンツ -コンポーネント;ホスト型コラボレーションが必要な場合は、データベース モードに戻してください。 -SQL-backed sharing. See [Local File Mode](/docs/local-file-mode) for the -スタンドアロン リポジトリのレイアウト、構成、カスタム MDX コンポーネント、ローカル -`extensions/` ウィジェット、および本番環境の安全ガイド。 +ローカルフォルダーも、すべてのページと同じ SQL モデルを使います。各ページは +スペースとその Files データベースに属し、フォルダーはそのソースアダプターです。 +ポリシー、競合、MDX コンポーネント、旧モードからの移行については +[ローカルフォルダーソース](/docs/local-file-mode) を参照してください。 -コンテンツ ローカル ファイル スキルを既存のリポジトリにインストールするには: +既存のリポジトリで Content を開くには: ```bash -npx @agent-native/core@latest skills add content --mode local-files --scope project +npx @agent-native/core@latest content . ``` -インストーラーはコーディング エージェントの `content` スキルをコピーし、または -`docs/`、`blog/`、`content/` のコンテンツ ルートで `agent-native.json` を更新します -と `resources/`。ローカル コンテンツ アプリ、Agent Native デスクトップ、または信頼できる場合 -ローカル ブリッジが実行中です。エージェントはコンテンツ actions を使用する必要があります。 -`list-documents`、`get-document`、`edit-document`、`update-document`、 -生のファイルシステム書き込みの代わりに `share-local-file-document`。そのローカルがなければ -ブリッジ、インストールされたスキルは引き続きエージェントにリポジトリ編集契約を与えます -安全な Markdown/MDX 編集。 +CLI は通常のデータベース版 Content を起動し、フォルダーソースを登録して、 +モード環境変数を設定せずに接続画面を開きます。 ## 開発者向け diff --git a/packages/core/docs/content/locales/ko-KR/local-file-mode.mdx b/packages/core/docs/content/locales/ko-KR/local-file-mode.mdx index b082dc0297..02e0012f1b 100644 --- a/packages/core/docs/content/locales/ko-KR/local-file-mode.mdx +++ b/packages/core/docs/content/locales/ko-KR/local-file-mode.mdx @@ -1,523 +1,68 @@ --- -title: "로컬 파일 모드" -description: "로컬 Markdown, MDX 및 기타 repo 파일을 정보 소스로 사용하여 에이전트 네이티브 앱을 실행합니다. 여기에는 맞춤 구성요소가 포함된 Obsidian 스타일 MDX 문서도 포함됩니다." +title: "로컬 폴더 소스" +description: "Markdown 및 MDX 폴더를 데이터베이스 기반 Content 공간에 연결합니다." --- -# 로컬 파일 모드 +# 로컬 폴더 소스 -로컬 파일 모드를 사용하면 에이전트 네이티브 앱이 일반 UI 및 작업 표면을 연결할 수 있습니다. -저장소나 작업공간의 파일에 직접 연결됩니다. 앱이 여전히 호스팅된 것처럼 느껴집니다. -제품, 목록 보기, 편집기 및 에이전트 도구는 로컬 파일을 읽고 씁니다 -SQL 지원 앱 기록 대신 +Content에는 하나의 데이터 모델만 있습니다. 모든 페이지는 SQL에 저장되고 +**공간**에 속하며 해당 공간의 시스템 **Files** 데이터베이스 항목이 됩니다. +로컬 폴더는 Files에 연결되는 소스이지 별도의 앱 모드가 아닙니다. +`AGENT_NATIVE_MODE` 전환은 더 이상 사용하지 않습니다. -첫 번째 구현은 콘텐츠 템플릿에 있습니다. 왼쪽 사이드바는 다음과 같습니다. -로컬 `.md` 및 `.mdx` 파일로 채워지며, 페이지를 선택하면 표준이 열립니다. -컨텐츠 편집기 및 저장하면 선택한 파일에 다시 기록됩니다. 동일한 파일은 다음과 같습니다. -Codex, Claude 코드, Agent-Native 사이드바 에이전트 또는 일반 사용자로도 편집 가능 -편집자. +사용자마다 개인 공간이 있고 접근 가능한 조직마다 조직 공간이 있습니다. 개인 +**Workspaces** 카탈로그가 앱에 표시할 공간을 정합니다. Files는 일반 Content +데이터베이스이므로 속성, 필터, 정렬, 그룹 및 저장된 보기를 사이드바에도 사용할 수 +있습니다. -콘텐츠의 경우 제품이 MDX용 오픈 소스 Obsidian처럼 느껴집니다. -문서는 파일로 저장되고 앱에는 시각적 편집기인 에이전트 actions가 추가됩니다. -공유 가능한 복사본 및 풍부한 대화형 MDX 구성 요소 +## 폴더 연결 -저장소 우선 워크플로를 원할 경우 로컬 파일 모드를 사용하세요. +브라우저 또는 Agent Native Desktop에서 폴더를 선택해 기존 공간에 연결하거나 +전용 비공개 공간을 만듭니다. SQL에는 불투명한 연결 ID, 상대 경로, 해시와 소스 +메타데이터만 저장합니다. 절대 경로, 브라우저 핸들, 원본 파일 본문은 저장하지 +않습니다. -- `docs/*.mdx`를 사용한 문서 저장소 -- `blog/*.mdx`가 있는 블로그 -- `resources/*.md`의 포지셔닝, 메시지 또는 팀 메모와 같은 리소스 -- 풍부한 MDX 편집기를 갖춘 개인 흑요석 스타일 지식 기반 -- 로컬 React 코드에서 생성된 대화형 사용자 정의 MDX 블록이 필요한 문서 -- 코딩 에이전트가 쉽게 검사하고 패치할 수 있는 앱 아티팩트 +정책은 `database_primary`, `source_primary`, `reviewed_bidirectional`입니다. +동기화는 파일을 일반 SQL 페이지로 구체화합니다. 양쪽이 동시에 바뀌면 덮어쓰는 +대신 검토할 변경을 만듭니다. 연결을 끊어도 페이지와 로컬 파일은 유지됩니다. -호스팅된 협업 앱 경험을 원할 때 데이터베이스 모드를 사용하세요: -다중 사용자 공유, SQL 지원 권한, 댓글, 버전 기록 및 -로컬 파일 시스템 액세스가 없는 프로덕션 호스팅 - -## 정신 모델 - -두 가지 정보 소스 모드가 있습니다: - -| 모드 | 진실의 근원 | 최적의 용도 | -| ----------------- | -------------------------------------- | ------------------------------------------------------------------ | -| 데이터베이스 모드 | Drizzle를 통한 SQL 행 | 호스팅된 앱, 공동 작업, 공유, 댓글, 버전 기록 | -| 로컬 파일 모드 | `agent-native.json`가 선언한 Repo 파일 | 로컬/개발 워크플로, Git 검토, 코딩 에이전트 편집, 파일 기본 콘텐츠 | - -UI와 에이전트 actions는 두 모드 모두에서 동일한 모양을 유지해야 합니다. 내용 -편집자는 여전히 문서를 편집합니다. 차이점은 해당 문서가 해결되는지 여부입니다. -SQL 행 또는 로컬 파일 - - - -```html -
-
-
콘텐츠 UI
-
- 에이전트 + actions
list/get/update-document -
-
- -
-
- 데이터베이스 모드SQL rows via Drizzle호스팅 · 공유 · 댓글 · 기록 -
-
- Local File Moderepo files via agent-native.jsonGit 리뷰 · 코딩 에이전트 편집 -
-
-
-``` - -```css -.diagram-mode { - display: flex; - align-items: center; - gap: 14px; - flex-wrap: wrap; -} -.diagram-mode .diagram-col { - display: flex; - flex-direction: column; - gap: 10px; -} -.diagram-mode .diagram-arrow { - font-size: 22px; - line-height: 1; -} -.diagram-mode .resolve { - display: flex; - gap: 12px; - flex-wrap: wrap; -} -.diagram-mode .diagram-panel { - display: flex; - flex-direction: column; - gap: 4px; - padding: 12px 14px; -} -``` - -
- -## 예시 저장소 - -콘텐츠 작업공간은 다음과 같이 작을 수 있습니다: - - - -로컬 파일 모드에서 콘텐츠 사이드바에는 `docs/`, `blog/` 및 -`resources/` 트리를 페이지로 표시합니다. `docs/getting-started.mdx`를 선택하면 다음이 열립니다. -표준 컨텐츠 편집기의 파일; UI에서 편집하면 -`docs/getting-started.mdx`. - -`components/`는 콘텐츠 루트가 아닙니다. MDX가 제공하는 미리보기 구성요소 라이브러리입니다 -파일을 가져오거나 참조할 수 있습니다. 편집기는 간단한 로컬 MDX 구성요소를 렌더링할 수 있습니다 -전체 콘텐츠 앱을 복제하거나 포크할 필요가 없습니다. - -`extensions/`도 콘텐츠 루트가 아닙니다. 로컬 확장 라이브러리입니다: -소스가 그대로 유지되는 동안 앱 슬롯에서 렌더링할 수 있는 작은 샌드박스 위젯 -저장소. - -## 저장소에 콘텐츠 설치 - -기존 문서, 블로그 또는 MDX 작업공간의 경우 콘텐츠 로컬 파일을 설치하세요. -스킬: +## 저장소에서 시작 ```bash -npx @agent-native/core@latest skills add content --mode local-files --scope project +npx @agent-native/core@latest content . ``` -`content` 스킬을 저장소의 에이전트 스킬 폴더에 복사하고 씁니다. -또는 콘텐츠 기본값으로 `agent-native.json`를 업데이트합니다. - -- 작업 공간 수준의 `mode: "local-files"` -- `apps.content.mode: "local-files"` -- `docs/`, `blog/`, `content/` 및 `resources/`의 콘텐츠 루트 -- 로컬 MDX 구성요소용 `components/` -- 로컬 확장 위젯용 `extensions/` - -설치된 스킬은 코딩 에이전트에게 콘텐츠 actions를 사용하도록 지시합니다 -(`list-documents`, `get-document`, `edit-document`, `update-document`, -`share-local-file-document` 및 구성 요소 파일 actions)(로컬 콘텐츠 앱인 경우) -또는 Agent Native 데스크탑 브리지가 이를 노출합니다. 다리가 달리고 있지 않으면 스킬 -머리말, 가져오기, JSX를 보존하면서 안전한 직접 저장소 편집으로 대체 -그리고 알 수 없는 MDX. - -## 구성 +이 명령은 일반 데이터베이스 기반 Content를 시작하고 폴더를 소스로 등록한 뒤 연결 +화면을 엽니다. 모드 환경 변수는 설정하지 않습니다. -저장소 또는 작업공간 루트에 `agent-native.json`를 추가하세요. +## 설정 ```json { "version": 1, "apps": { "content": { - "mode": "local-files", "roots": [ { "name": "Docs", "path": "docs", "kind": "docs", - "extensions": [".md", ".mdx"] - }, - { - "name": "Blog", - "path": "blog", - "kind": "blog", - "extensions": [".md", ".mdx"] - }, - { - "name": "Resources", - "path": "resources", - "kind": "resources", - "extensions": [".md", ".mdx"] + "extensions": [".md", ".mdx"], + "source": { + "type": "local-folder", + "connectionId": "local-folder:", + "truthPolicy": "source_primary" + } } ], "components": "components", - "extensions": "extensions", - "hide": ["**/_*.md", "**/_*.mdx"] + "extensions": "extensions" } } } ``` -`AGENT_NATIVE_MODE=local-files` 또는 -`AGENT_NATIVE_DATA_MODE=local-files`; 매니페스트가 선호되는 이유는 -저장소 자체에 폴더 계약을 문서화합니다. - -## 콘텐츠 파일 형식 - -콘텐츠는 Markdown 및 MDX를 읽습니다. Frontmatter는 페이지 메타데이터를 담고 있으며 본문은 -편집 가능한 문서: - -```mdx ---- -title: "시작하기" -icon: "sparkles" -isFavorite: true -updatedAt: "2026-06-12T20:00:00.000Z" ---- - -# 시작하기 - -Use to show framework-specific code. -``` - -제목은 `title` 머리말에서 나오며, 그렇지 않으면 -파일 이름. 편집기는 아직 시각적으로 편집할 수 없는 MDX 소스를 보존하므로 -코딩 에이전트와 일반 텍스트 편집기는 안전한 탈출구로 남아 있습니다. - -## 사용자 정의 MDX 구성 요소 - -콘텐츠는 구성된 `components` 폴더에서 로컬 구성 요소를 미리 볼 수 있습니다. -이는 탭, 콜아웃, 패키지와 같은 문서 스타일 MDX 구성요소를 위한 것입니다. -스니펫 또는 프레임워크별 코드 블록을 설치합니다. - -예를 들어 콘텐츠 옆에 대화형 구성요소를 추가하세요. - -```tsx -// components/ImpactCounter.tsx -import { useState } from "react"; - -export function ImpactCounter({ - label = "points", - accent = "blue", - featured = false, -}: { - label?: string; - accent?: "blue" | "green" | "purple"; - featured?: boolean; -}) { - const [count, setCount] = useState(3); - const accentClass = - accent === "green" - ? "border-green-300 bg-green-50" - : accent === "purple" - ? "border-purple-300 bg-purple-50" - : "border-blue-300 bg-blue-50"; - - return ( -
-
Launch impact
-
- {count} {label} -
- {featured ?
Featured metric
: null} - -
- ); -} - -export const ImpactCounterInputs = { - label: { - type: "string", - label: "Metric label", - default: "points", - }, - accent: { - type: "select", - label: "Accent", - options: ["blue", "green", "purple"], - default: "blue", - }, - featured: { - type: "boolean", - label: "Featured", - default: false, - }, -}; -``` - -그런 다음 로컬 MDX 파일에서 사용하십시오. - -```mdx ---- -title: "출시 노트" ---- - -# 출시 노트 - - -``` - -콘텐츠 개발 서버는 내보내기 및 PascalCase 기본값이라는 PascalCase를 검색합니다. -`components/` 아래의 `.tsx`, `.jsx`, `.ts` 및 `.js` 파일에서 내보냅니다. 그 -구성요소는 편집기 내에서 렌더링되고 아래의 슬래시 메뉴에 나타납니다 -**로컬 구성요소**. 슬래시를 삽입하면 다음과 같은 최소한의 태그가 생성됩니다. -``; 필요한 경우 MDX 소스에 소품을 추가하세요. - -구성요소 실행은 의도적으로 로컬 개발/데스크톱 브리지 기능이 아닌 -일반 호스팅 브라우저 폴더 액세스. `content.agent-native.com`를 열면 -**로컬 파일**을 선택하고 Chrome에서 폴더를 선택하면 앱이 읽고 쓸 수 있습니다. -`.md` 및 `.mdx` 파일은 브라우저 파일 시스템 액세스 API를 통해 이루어지지만 -Chrome은 컴파일할 Vite의 절대 폴더 경로를 노출하지 않습니다. -`components/*.tsx`. 사용자 정의 React 구성 요소를 미리 보고 핫 리로드하려면 다음을 실행하세요. -신뢰할 수 있는 로컬 브리지에서 콘텐츠를 로컬로 전송하거나 Agent Native 데스크탑을 사용하여 -선택한 작업공간을 로컬 컨텐츠 개발 서버에 등록합니다. 해당 모드에서는 -Vite를 통해 기존 구성 요소 파일 핫 리로드를 편집하고 또는 추가 -구성요소 파일을 제거하면 구성요소 레지스트리와 슬래시 메뉴가 다시 로드됩니다. - -에이전트는 등록된 구성 요소 파일로 작업할 수도 있습니다. 사용 -`list-local-component-files`를 사용하여 등록된 작업공간 ID를 찾은 다음 -`write-local-component-file` - `.tsx`, `.jsx`, `.ts` 생성 또는 업데이트 -작업 공간의 `components/` 폴더 아래에 있는 `.js` 파일. MDX 파일은 그대로 유지됩니다. -구성요소 사용에 대한 정보 소스. 구성 요소 파일은 일반 저장소로 유지됩니다. -Git로 검토한 소스 파일. - -구성 요소가 입력 메타데이터를 내보내는 경우 편집기에서 구성 요소 선택 -구성요소의 오른쪽 상단에 편집 버튼이 표시됩니다. 지원되는 입력 유형 -`string`, `textarea`, `number`, `boolean` 및 `select`입니다. 양식은 다음과 같습니다. -MDX 태그로 다시 변경되므로 로컬 파일은 정보 소스로 유지됩니다. -메타데이터는 `ComponentNameInputs`, `ComponentNameConfig.inputs`로 내보낼 수 있습니다. -`Component.inputs` 또는 `agentNative.inputs`. - -리터럴 소품이 있는 간단한 구성 요소 태그는 인라인으로 미리 볼 수 있습니다. - -```mdx - - - - -This setting affects production deploys. - - -``` - -복잡한 JSX 표현식은 소스에 보존됩니다. 편집자가 안전하게 할 수 없는 경우 -아직 구성 요소 속성을 미리 보면 -데이터를 자동으로 삭제합니다. - -## 로컬 파일 공유 - -다른 사용자가 경로를 읽을 수 없기 때문에 로컬 파일은 직접 공유되지 않습니다. -당신의 기계. 콘텐츠 도구 모음의 공유 버튼은 -선택한 파일의 데이터베이스 기반 복사본을 찾아 해당 복사본을 탐색하고 -일반 공유 팝오버. 원본 로컬 파일은 로컬 파일 아래에 남아 있습니다. -데이터베이스 복사본은 로컬 파일 모드의 공유 복사본 아래에 나타나며 -표준 문서 공유 모델. - -## 지역 확장 - -로컬 파일 모드는 구성된 파일에서 repo 지원 확장을 로드할 수도 있습니다. -`extensions` 폴더. 각 확장은 `extension.json`가 포함된 하나의 디렉터리입니다. -매니페스트 및 HTML 항목 파일: - -```text -extensions/ - doc-status/ - extension.json - index.html -``` - -```json -{ - "id": "doc-status", - "name": "Doc Status", - "description": "Shows metadata for the selected Content file.", - "entry": "index.html", - "slots": ["content.sidebar.bottom"], - "permissions": { - "appActions": ["list-documents"], - "extensionData": true - } -} -``` - -`index.html`는 일반에서 사용하는 것과 동일한 Alpine/Tailwind 확장 본문 형식입니다. -데이터베이스 지원 확장. 콘텐츠 앱이 다음과 같은 로컬 확장을 볼 때 -`content.sidebar.bottom`를 선언하면 해당 확장을 -the Content sidebar. The host passes `window.slotContext` with the selected -문서 ID, 제목, 소스 메타데이터 및 콘텐츠가 로컬 파일 모드에 있는지 여부 - -로컬 확장자는 앱에서 미리 볼 수 있지만 파일로 편집됩니다. 확장 프로그램 -목록에는 로컬 파일 배지가 표시되며 전체 페이지 뷰어는 다음을 가리킵니다 -항목 파일입니다. 업데이트, 삭제, 공유 및 -이력은 적용되지 않습니다. 편집기, Codex, Claude 코드 또는 Git 기록을 사용하여 -소스 변경. - -v1의 경우 로컬 확장은 의도적으로 보수적입니다. - -- 그들은 자신의 작은 런타임 상태를 위해 `extensionData`를 사용할 수 있습니다 -- `extension.json`에 나열된 `appAction`만 호출할 수 있습니다 -- 원시 SQL 도우미 및 외부 `extensionFetch`가 비활성화되었습니다. -- 슬롯 대상은 SQL를 통해 설치되지 않은 `extension.json`에서 선언되었습니다. - -이것은 로컬 작업 공간에 Obsidian과 유사한 플러그인 표면을 제공하지만 -임의의 repo 파일은 데이터베이스 지원 확장 프로그램의 모든 기능을 상속합니다. - -## 앱이 이를 사용하는 방법 - -로컬 파일 모드는 프레임워크의 로컬 아티팩트 도우미를 통해 구현됩니다. -앱은 자신이 소유한 아티팩트 유형의 루트를 선언한 다음 읽고 씁니다. -UI와 에이전트가 이미 사용하고 있는 동일한 작업 표면을 통해 - -콘텐츠의 경우 다음을 의미합니다. - -- `list-documents`에는 구성된 `.md` 및 `.mdx` 파일이 나열됩니다. -- `get-document`는 선택한 로컬 파일을 읽습니다. -- `update-document`는 선택한 로컬 파일을 씁니다. -- `create-document`는 선택한 폴더에 새 로컬 `.mdx` 파일을 생성합니다. -- `delete-document`는 로컬 파일을 삭제합니다. -- 검색은 구성된 로컬 파일 전체에서 실행됩니다. - -컨텐츠 UI에서 로컬 파일 페이지를 이동하고, 이름을 바꾸고, 순서를 바꾸는 것은 -아직 지원됩니다. 작업 공간이나 코딩 에이전트를 사용하여 해당 작업을 수행하십시오. -콘텐츠 사이드바에는 결과 파일 트리가 반영됩니다. - -이렇게 하면 에이전트 계약이 단순해집니다. 에이전트는 콘텐츠 actions를 계속 사용할 수 있습니다. -그리고 actions는 대상이 SQL 지원인지 파일 지원인지 결정합니다. - -시간이 지남에 따라 다른 앱도 동일한 패턴을 채택할 수 있습니다. 프레젠테이션 앱은 지도를 작성할 수 있습니다 -`slides/*.mdx`를 데크에 연결하고 Plans 앱은 `plans/*`를 계획 문서에 매핑할 수 있으며 -대시보드 앱은 `dashboards/*.mdx`를 대시보드에 매핑할 수 있습니다. 특정 앱 -폴더는 동일한 로컬 아티팩트 계약 위에 쌓인 규칙입니다. - -## 로컬 파일과 내보내기/가져오기 - -콘텐츠에는 두 가지 파일 작업 흐름이 있습니다: - -| 워크플로우 | 무슨 일이 일어나는지 | -| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | -| `/local-files` 내보내기/가져오기 | 데이터베이스 모드는 여전히 정보의 원천입니다. 파일은 내보내고, 편집하고, 미리 보고, 가져오는 명시적인 동기화 표면입니다. | -| 로컬 파일 모드 | 파일은 정보의 원천입니다. 콘텐츠 사이드바와 편집기는 로컬 파일에서 직접 작동합니다. | - -호스팅된 작업공간 주변에서 가끔 파일을 검토하려는 경우 내보내기/가져오기를 사용하세요. -저장소 자체가 작업공간인 경우 로컬 파일 모드를 사용하세요. - -## 역사와 협력 - -로컬 파일 모드는 파일 기본 기록에 의존합니다: - -- Git에 중요한 변경 사항을 커밋 -- 검토를 위해 풀 요청 사용 -- 코딩 에이전트가 동일한 파일을 직접 편집하도록 허용 -- 변경사항을 이해하려면 일반 파일 비교를 사용하세요 - -데이터베이스 모드는 다음과 같은 호스팅 공동 작업 기능에 더 적합합니다. -공유, 댓글, SQL 지원 버전 기록 및 실시간 다중 사용자 편집 - -공급자 동기화는 두 모드 중 하나 위에 계층화될 수 있습니다. 예를 들어, 문서 저장소는 -CMS의 콘텐츠를 로컬 MDX 파일로 가져오거나 선택한 항목을 푸시하는 actions를 추가하세요. -로컬 파일을 해당 CMS로 되돌립니다. - -## 생산 안전 - -로컬 파일 모드는 앱 actions에 구성된 작업 공간에 대한 직접 쓰기 액세스를 제공합니다. -파일. 로컬 개발 및 신뢰할 수 있는 단일 테넌트 파일에 적합합니다. -브리지이지만 기본 프로덕션 보안 모델은 아닙니다. - -`NODE_ENV=production`일 때, 프레임워크는 다음을 제외하고 `local-files` 모드를 거부합니다. -설정: - -```bash -AGENT_NATIVE_ALLOW_LOCAL_FILES_IN_PRODUCTION=true -``` - -모든 사람이 사용할 수 있는 신뢰할 수 있는 단일 테넌트 배포에 대해서만 설정하십시오. -앱은 구성된 파일을 읽고 쓸 수 있습니다. 일반 호스팅의 경우 -다중 사용자 앱, 데이터베이스 모드 및 SQL 지원 공유를 사용하세요. +마이그레이션할 때 `mode: "local-files"`와 +`AGENT_NATIVE_MODE=local-files`를 제거하고 roots는 유지한 채 `source`를 +추가합니다. frontmatter ID는 이름 변경과 이동 시 페이지 정체성을 유지합니다. +로컬 컴포넌트와 확장은 계속 신뢰할 수 있는 로컬 브리지를 사용합니다. diff --git a/packages/core/docs/content/locales/ko-KR/template-content.mdx b/packages/core/docs/content/locales/ko-KR/template-content.mdx index 198aa35f42..ee161b98ac 100644 --- a/packages/core/docs/content/locales/ko-KR/template-content.mdx +++ b/packages/core/docs/content/locales/ko-KR/template-content.mdx @@ -91,9 +91,8 @@ description: "MDX용 오픈 소스 Obsidian: 로컬 Markdown/MDX 파일을 편 - **페이지를 트리로 정리** — 자주 사용하는 즐겨찾는 페이지를 무한히 중첩하고 드래그하여 재정렬하세요. - 제목과 콘텐츠 전체에 대한 전체 텍스트 검색으로 **모든 것을 검색**하세요. - **Obsidian과 같은 로컬 Markdown/MDX 파일을 편집합니다.** `/local-files` 보기를 사용하세요. - 작업 공간을 파일로 내보내고, 자신의 도구에서 편집하고, 미리 보려면 - 변경 사항을 다시 가져옵니다. 로컬 파일 모드에서는 콘텐츠가 바로 - 선택한 `.md` 또는 `.mdx` 파일 + 폴더를 Files에 연결하고 자신의 도구로 편집한 다음 변경 사항을 동기화하고 + SQL 앱 안에서 충돌을 검토할 수 있습니다. - **풍부한 대화형 사용자 정의 블록을 생성합니다.** 로컬 React 구성요소를 등록합니다. 이를 MDX로 삽입하고 에이전트가 다음에 대한 구성 요소 파일을 생성하거나 업데이트하도록 합니다. 문서 @@ -136,32 +135,19 @@ description: "MDX용 오픈 소스 Obsidian: 로컬 Markdown/MDX 파일을 편 댓글, 권한 및 실시간 공동 작업 로컬 폴더는 명시적입니다 동기화 표면. -파일이 원본인 **로컬 파일 모드**에서도 콘텐츠를 실행할 수 있습니다. -SQL 문서 대신 진실. 저장소에 `agent-native.json`를 추가하고 설정 -`mode: "local-files"` 및 `docs/`, `blog/`와 같은 루트 구성 -`content/` 및 `resources/`. 그러면 표준 콘텐츠 편집기가 해당 항목을 채웁니다. -해당 로컬 `.md`/`.mdx` 파일의 왼쪽 사이드바를 편집하고 편집 내용을 다시 -일반 문서 actions를 통해 선택한 파일입니다. 저장소 우선 문서에 이것을 사용하세요. -MDX 기반 블로그, 리소스 라이브러리 또는 Obsidian 스타일의 개인 콘텐츠 -구성요소; 호스팅된 공동 작업을 원할 때 데이터베이스 모드로 다시 전환하고 -SQL 지원 공유. 자세한 내용은 [Local File Mode](/docs/local-file-mode)를 참조하세요. -독립형 저장소 레이아웃, 구성, 사용자 정의 MDX 구성 요소, 로컬 -`extensions/` 위젯 및 생산 안전 가이드. +로컬 폴더도 모든 페이지와 같은 SQL 모델을 사용합니다. 각 페이지는 공간과 해당 +Files 데이터베이스에 속하고 폴더는 소스 어댑터가 됩니다. 정책, 충돌, MDX 구성 +요소와 이전 모드에서의 마이그레이션은 [로컬 폴더 소스](/docs/local-file-mode)를 +참조하세요. -기존 저장소에 콘텐츠 로컬 파일 기술을 설치하려면: +기존 저장소에서 Content를 열려면: ```bash -npx @agent-native/core@latest skills add content --mode local-files --scope project +npx @agent-native/core@latest content . ``` -설치 프로그램은 코딩 에이전트에 대한 `content` 기술을 복사하고 쓰기 또는 -`docs/`, `blog/`, `content/`에 대한 콘텐츠 루트로 `agent-native.json`를 업데이트합니다. -및 `resources/`. 로컬 콘텐츠 앱, Agent Native 데스크톱 또는 신뢰할 수 있는 경우 -로컬 브리지가 실행 중입니다. 에이전트는 다음과 같은 Content actions를 사용해야 합니다. -`list-documents`, `get-document`, `edit-document`, `update-document` 및 -원시 파일 시스템 쓰기 대신 `share-local-file-document`. 그 지역이 없으면 -브리지, 설치된 스킬은 여전히 에이전트에게 저장소 편집 계약을 제공합니다 -Markdown/MDX 편집은 안전합니다. +CLI는 일반 데이터베이스 기반 Content를 시작하고 폴더 소스를 등록한 뒤 모드 +환경 변수를 설정하지 않고 연결 화면을 엽니다. ## 개발자용 diff --git a/packages/core/docs/content/locales/pt-BR/local-file-mode.mdx b/packages/core/docs/content/locales/pt-BR/local-file-mode.mdx index efd1744926..44dc82317e 100644 --- a/packages/core/docs/content/locales/pt-BR/local-file-mode.mdx +++ b/packages/core/docs/content/locales/pt-BR/local-file-mode.mdx @@ -1,527 +1,69 @@ --- -title: "Modo de arquivo local" -description: "Execute aplicativos nativos do agente com Markdown, MDX locais e outros arquivos repo como fonte da verdade, incluindo documentos MDX no estilo Obsidian com componentes personalizados." +title: "Fontes de pastas locais" +description: "Conecte pastas Markdown e MDX aos espaços do Content com suporte de banco de dados." --- -# Modo de arquivo local +# Fontes de pastas locais -O modo de arquivo local permite que um aplicativo nativo do agente anexe seu UI normal e sua superfície de ação -diretamente para arquivos em um repositório ou espaço de trabalho. O aplicativo ainda parece hospedado -produto, mas suas visualizações de lista, editor e ferramentas de agente leem e gravam arquivos locais -em vez de registros de aplicativos baseados em SQL. +O Content agora tem um único modelo de dados: cada página fica no SQL, pertence +a um **espaço** e integra o banco de sistema **Files** desse espaço. Uma pasta +local é uma fonte conectada a Files, não um segundo modo do aplicativo. Não há +mais uma chave `AGENT_NATIVE_MODE`. -A primeira implementação está no modelo Conteúdo: a barra lateral esquerda está -preenchido a partir de arquivos locais `.md` e `.mdx`, selecionar uma página abre o padrão -Editor de conteúdo e gravação de gravação no arquivo selecionado. Os mesmos arquivos podem -também pode ser editado por Codex, Claude Code, agente da barra lateral Agent-Native ou um normal -editor. +Cada usuário tem um espaço pessoal, cada organização acessível tem seu espaço, +e o catálogo pessoal **Workspaces** determina quais aparecem. Files é um banco +normal do Content: propriedades, filtros, ordenação, agrupamento e visualizações +salvas também podem renderizar a barra lateral. -Para conteúdo, isso faz com que o produto pareça Obsidian de código aberto para MDX: -seus documentos ficam como arquivos, enquanto o aplicativo adiciona um editor visual, o agente actions, -cópias compartilháveis e componentes MDX interativos avançados. +## Conectar uma pasta -Use o modo de arquivo local quando desejar um fluxo de trabalho com foco no repositório: +Escolha uma pasta pelo navegador ou Agent Native Desktop e conecte-a a um espaço +existente, ou crie um espaço privado baseado nela. O SQL guarda apenas o ID +opaco da conexão, caminhos relativos, hashes e metadados — nunca o caminho +absoluto, o identificador do navegador ou o conteúdo bruto do arquivo. -- um repositório de documentos com `docs/*.mdx` -- um blog com `blog/*.mdx` -- recursos como posicionamento, mensagens ou notas da equipe em `resources/*.md` -- uma base de conhecimento pessoal no estilo Obsidian com um editor MDX mais rico -- documentos que precisam de blocos MDX personalizados interativos gerados a partir do código React local -- artefatos de aplicativos que devem ser fáceis de serem inspecionados e corrigidos pelos agentes de codificação +As políticas são `database_primary`, `source_primary` e +`reviewed_bidirectional`. A sincronização materializa os arquivos como páginas +SQL normais. Alterações simultâneas geram uma mudança para revisão, em vez de +sobrescrever um lado. Desconectar preserva páginas e arquivos. -Use o modo de banco de dados quando desejar a experiência de aplicativo colaborativo hospedado: -compartilhamento multiusuário, permissões baseadas em SQL, comentários, histórico de versões e -hospedagem de produção sem acesso ao sistema de arquivos local. - -## O modelo mental - -Existem dois modos de fonte da verdade: - -| Modo | Fonte da verdade | Melhor para | -| --------------------- | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | -| Modo banco de dados | Linhas SQL até Drizzle | Aplicativos hospedados, colaboração, compartilhamento, comentários, histórico de versões | -| Modo de arquivo local | Arquivos repositórios declarados por `agent-native.json` | Fluxos de trabalho locais/de desenvolvimento, revisão do Git, edições do agente de codificação, conteúdo nativo de arquivo | - -O UI e o agente actions devem permanecer na mesma forma em ambos os modos. Um conteúdo -editor ainda edita documentos; a diferença é se esses documentos resolvem -para linhas SQL ou arquivos locais. - - - -```html -
-
-
UI de conteúdo
-
- Agente + ações
list/get/update-document -
-
- -
-
- Modo banco de dadosSQL rows via Drizzlehospedado · compartilhamento · comentários · histórico -
-
- Local File Moderepo files via agent-native.jsonrevisão Git · edições do agente de código -
-
-
-``` - -```css -.diagram-mode { - display: flex; - align-items: center; - gap: 14px; - flex-wrap: wrap; -} -.diagram-mode .diagram-col { - display: flex; - flex-direction: column; - gap: 10px; -} -.diagram-mode .diagram-arrow { - font-size: 22px; - line-height: 1; -} -.diagram-mode .resolve { - display: flex; - gap: 12px; - flex-wrap: wrap; -} -.diagram-mode .diagram-panel { - display: flex; - flex-direction: column; - gap: 4px; - padding: 12px 14px; -} -``` - -
- -## Exemplo de repositório - -Um espaço de trabalho Conteúdo pode ser tão pequeno quanto isto: - - - -No modo de arquivo local, a barra lateral de conteúdo mostra `docs/`, `blog/` e -Árvores `resources/` como páginas. Selecionar `docs/getting-started.mdx` abre isso -arquivo no editor de conteúdo padrão; a edição no UI grava de volta em -`docs/getting-started.mdx`. - -`components/` não é uma raiz de conteúdo. É uma biblioteca de componentes de visualização que MDX -arquivos podem ser importados ou referenciados. O editor pode renderizar componentes MDX locais simples -sem precisar clonar ou bifurcar todo o aplicativo Content. - -`extensions/` também não é uma raiz de conteúdo. É uma biblioteca de extensão local: -pequenos widgets em sandbox que podem ser renderizados em slots de aplicativos enquanto sua fonte permanece em -o repositório. - -## Instalar conteúdo em um repositório - -Para documentos, blogs ou espaço de trabalho MDX existentes, instale os arquivos locais de conteúdo -habilidade: +## Iniciar em um repositório ```bash -npx @agent-native/core@latest skills add content --mode local-files --scope project +npx @agent-native/core@latest content . ``` -Isso copia a habilidade `content` nas pastas de habilidades do agente do repositório e grava -ou atualiza `agent-native.json` com padrões de conteúdo: - -- `mode: "local-files"` no nível do espaço de trabalho -- `apps.content.mode: "local-files"` -- raízes de conteúdo para `docs/`, `blog/`, `content/` e `resources/` -- `components/` para componentes MDX locais -- `extensions/` para widgets de extensão local - -A habilidade instalada informa aos agentes de codificação para usarem o Conteúdo actions -(`list-documents`, `get-document`, `edit-document`, `update-document`, -`share-local-file-document` e arquivo de componente actions) quando um aplicativo de conteúdo local -ou Agent Native Desktop bridge os expõe. Se nenhuma ponte estiver funcionando, a habilidade -volta para edições de repositório diretas e seguras, preservando o frontmatter, as importações, JSX, -e MDX desconhecido. +O comando inicia o Content normal com banco de dados, registra a pasta como +fonte e abre a conexão. Nenhuma variável de modo é definida. ## Configuração -Adicione `agent-native.json` ao repositório ou raiz do espaço de trabalho: - ```json { "version": 1, "apps": { "content": { - "mode": "local-files", "roots": [ { "name": "Docs", "path": "docs", "kind": "docs", - "extensions": [".md", ".mdx"] - }, - { - "name": "Blog", - "path": "blog", - "kind": "blog", - "extensions": [".md", ".mdx"] - }, - { - "name": "Resources", - "path": "resources", - "kind": "resources", - "extensions": [".md", ".mdx"] + "extensions": [".md", ".mdx"], + "source": { + "type": "local-folder", + "connectionId": "local-folder:", + "truthPolicy": "source_primary" + } } ], "components": "components", - "extensions": "extensions", - "hide": ["**/_*.md", "**/_*.mdx"] + "extensions": "extensions" } } } ``` -Você também pode ativar arquivos locais com `AGENT_NATIVE_MODE=local-files` ou -`AGENT_NATIVE_DATA_MODE=local-files`; o manifesto é preferido porque -documenta o contrato da pasta no próprio repositório. - -## Formato de arquivo de conteúdo - -O conteúdo diz Markdown e MDX. Frontmatter contém metadados da página e o corpo é -o documento editável: - -```mdx ---- -title: "Primeiros passos" -icon: "sparkles" -isFavorite: true -updatedAt: "2026-06-12T20:00:00.000Z" ---- - -# Primeiros passos - -Use to show framework-specific code. -``` - -O título vem do frontmatter `title` quando presente, caso contrário, do -nome do arquivo. O editor preserva a fonte MDX que ainda não pode ser editada visualmente, portanto -agentes de codificação e editores de texto normais permanecem como saídas de escape seguras. - -## Componentes MDX personalizados - -O conteúdo pode visualizar componentes locais da pasta `components` configurada. -Isso se destina a componentes MDX de estilo de documento, como guias, textos explicativos, pacote -instale snippets ou blocos de código específicos da estrutura. - -Por exemplo, adicione um componente interativo próximo ao seu conteúdo: - -```tsx -// components/ImpactCounter.tsx -import { useState } from "react"; - -export function ImpactCounter({ - label = "points", - accent = "blue", - featured = false, -}: { - label?: string; - accent?: "blue" | "green" | "purple"; - featured?: boolean; -}) { - const [count, setCount] = useState(3); - const accentClass = - accent === "green" - ? "border-green-300 bg-green-50" - : accent === "purple" - ? "border-purple-300 bg-purple-50" - : "border-blue-300 bg-blue-50"; - - return ( -
-
Launch impact
-
- {count} {label} -
- {featured ?
Featured metric
: null} - -
- ); -} - -export const ImpactCounterInputs = { - label: { - type: "string", - label: "Metric label", - default: "points", - }, - accent: { - type: "select", - label: "Accent", - options: ["blue", "green", "purple"], - default: "blue", - }, - featured: { - type: "boolean", - label: "Featured", - default: false, - }, -}; -``` - -Em seguida, use-o em qualquer arquivo MDX local: - -```mdx ---- -title: "Notas de lançamento" ---- - -# Notas de lançamento - - -``` - -O servidor de desenvolvimento de conteúdo descobre exportações nomeadas de PascalCase e padrão de PascalCase -exporta arquivos `.tsx`, `.jsx`, `.ts` e `.js` em `components/`. Aqueles -componentes são renderizados dentro do editor e aparecem no menu de barras em -**Componentes locais**. A inserção de barra cria uma tag mínima como -``; adicione adereços na fonte MDX quando necessário. - -A execução do componente é intencionalmente um recurso de ponte local-dev/Desktop, não -acesso simples à pasta do navegador hospedado. Se você abrir `content.agent-native.com`, -escolha **Arquivos locais** e escolha uma pasta no Chrome, o app pode ler e gravar -os arquivos `.md` e `.mdx` através do navegador Sistema de Arquivos Acesse API, mas -O Chrome não expõe um caminho de pasta absoluto para Vite compilar -`components/*.tsx`. Para visualizar e recarregar componentes React personalizados, execute -Conteúdo localmente ou use o Agent Native Desktop para que a ponte local confiável possa -registre o espaço de trabalho escolhido no servidor de desenvolvimento de conteúdo local. Nesse modo, -edições em arquivos de componentes existentes, recarregamento a quente por meio de Vite e adição de ou -a remoção de arquivos de componentes recarrega o registro do componente e o menu de barras. - -Os agentes também podem trabalhar com esses arquivos de componentes registrados. Usar -`list-local-component-files` para encontrar o ID do espaço de trabalho registrado e, em seguida, -`write-local-component-file` para criar ou atualizar `.tsx`, `.jsx`, `.ts` ou -Arquivos `.js` na pasta `components/` do espaço de trabalho. Os arquivos MDX permanecem os -fonte de verdade para uso de componentes; os arquivos do componente permanecem no repositório normal -arquivos de origem revisados com Git. - -Se um componente exportar metadados de entrada, selecione o componente no editor -mostra um botão de edição no canto superior direito do componente. Tipos de entrada suportados -são `string`, `textarea`, `number`, `boolean` e `select`. O formulário escreve -volta para a tag MDX, então os arquivos locais permanecem a fonte da verdade. O -metadados podem ser exportados como `ComponentNameInputs`, `ComponentNameConfig.inputs`, -`Component.inputs` ou `agentNative.inputs`. - -Tags de componentes simples com adereços literais podem ser visualizados in-line: - -```mdx - - - - -This setting affects production deploys. - - -``` - -Expressões JSX complexas são preservadas na origem. Se o editor não puder com segurança -visualizar um suporte de componente ainda, ele mostra um espaço reservado de aviso em vez de -descartando dados silenciosamente. - -## Compartilhando arquivos locais - -Os arquivos locais não são compartilhados diretamente porque outros usuários não conseguem ler um caminho -sua máquina. O botão Compartilhar da barra de ferramentas Conteúdo cria ou atualiza um -cópia do arquivo selecionado com base no banco de dados, navega até essa cópia e abre o -popover de compartilhamento normal. O arquivo local original permanece em Arquivos locais; o -a cópia do banco de dados aparece em Cópias compartilhadas no modo de arquivo local e usa o -modelo padrão de compartilhamento de documentos. - -## Extensões locais - -O modo de arquivo local também pode carregar extensões apoiadas por repositório a partir do configurado -Pasta `extensions`. Cada extensão é um diretório com um `extension.json` -manifesto e um arquivo de entrada HTML: - -```text -extensions/ - doc-status/ - extension.json - index.html -``` - -```json -{ - "id": "doc-status", - "name": "Doc Status", - "description": "Shows metadata for the selected Content file.", - "entry": "index.html", - "slots": ["content.sidebar.bottom"], - "permissions": { - "appActions": ["list-documents"], - "extensionData": true - } -} -``` - -`index.html` é o mesmo formato de corpo de extensão Alpine/Tailwind usado pelo normal -extensões apoiadas por banco de dados. Quando o aplicativo Content vê uma extensão local que -declara `content.sidebar.bottom`, ele renderiza essa extensão na parte inferior de -a barra lateral de Conteúdo. O host passa `window.slotContext` com o selecionado -ID do documento, título, metadados de origem e se o conteúdo está no modo de arquivo local. - -As extensões locais são visualizadas pelo aplicativo, mas editadas como arquivos. As extensões -a lista mostra-os com um selo de arquivo local, e o visualizador de página inteira aponta de volta para -o arquivo de entrada. Extensão actions apoiada por SQL, como atualizar, excluir, compartilhar e -história não se aplica; use seu editor, código Codex, Claude ou histórico do Git para -alterações na origem. - -Para v1, as extensões locais são intencionalmente conservadoras: - -- eles podem usar `extensionData` para seu próprio estado de tempo de execução pequeno -- eles podem ligar apenas para os `appAction`s listados em `extension.json` -- ajudantes SQL brutos e `extensionFetch` externos estão desabilitados -- os destinos dos slots são declarados em `extension.json`, não instalados por meio de SQL - -Isso dá aos espaços de trabalho locais uma superfície de plug-in semelhante ao Obsidian, sem permitir -O arquivo repositório arbitrário herda todos os recursos de uma extensão baseada em banco de dados. - -## Como os aplicativos usam isso - -O modo de arquivo local é implementado por meio dos auxiliares de artefatos locais da estrutura. -Um aplicativo declara raízes para os tipos de artefatos que possui, depois lê e grava -através da mesma ação, o UI e o agente já usam. - -Para Conteúdo, isso significa: - -- `list-documents` lista os arquivos `.md` e `.mdx` configurados. -- `get-document` lê um arquivo local selecionado. -- `update-document` grava o arquivo local selecionado. -- `create-document` cria um novo arquivo `.mdx` local na pasta selecionada. -- `delete-document` exclui o arquivo local. -- a pesquisa é executada nos arquivos locais configurados. - -Mover, renomear e reordenar páginas de arquivos locais do Conteúdo UI não é -ainda compatível. Faça essas operações no espaço de trabalho ou com um agente de codificação; o -A barra lateral de conteúdo refletirá a árvore de arquivos resultante. - -Isso mantém o contrato do agente simples: o agente pode continuar usando o Conteúdo actions, -e aqueles actions decidem se o alvo é apoiado por SQL ou por arquivo. - -Outros aplicativos podem adotar o mesmo padrão ao longo do tempo. Um aplicativo Apresentações pode mapear -`slides/*.mdx` para decks, um aplicativo de Planos pode mapear `plans/*` para documentos de planejamento e um -O aplicativo Dashboards pode mapear `dashboards/*.mdx` para dashboards. Aqueles específicos do aplicativo -pastas são convenções colocadas em camadas sobre o mesmo contrato de artefato local. - -## Arquivos locais x exportação/importação - -O conteúdo tem dois fluxos de trabalho de arquivo diferentes: - -| Fluxo de trabalho | O que acontece | -| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Exportação/importação `/local-files` | O modo banco de dados continua sendo a fonte da verdade. Os arquivos são uma superfície de sincronização explícita que você exporta, edita, visualiza e importa. | -| Modo de arquivo local | Os arquivos são a fonte da verdade. A barra lateral e o editor de conteúdo operam diretamente em arquivos locais. | - -Use exportar/importar quando quiser revisar arquivos ocasionalmente em um espaço de trabalho hospedado. -Use o modo de arquivo local quando o próprio repositório for o espaço de trabalho. - -## História e colaboração - -O modo de arquivo local depende do histórico nativo do arquivo: - -- confirmar alterações importantes no Git -- use pull requests para revisão -- permitir que os agentes de codificação editem os mesmos arquivos diretamente -- use diferenças normais de arquivo para entender as alterações - -O modo banco de dados continua sendo o mais adequado para recursos de colaboração hospedados, como -compartilhamento, comentários, histórico de versões apoiado por SQL e edição multiusuário ao vivo. - -A sincronização do provedor pode ser colocada em camadas sobre qualquer um dos modos. Por exemplo, um repositório de documentos pode -adicione actions que extrai conteúdo de um CMS para arquivos MDX locais ou envia por push os selecionados -arquivos locais de volta para esse CMS. - -## Segurança de produção - -O modo de arquivo local fornece ao aplicativo actions acesso direto de gravação ao espaço de trabalho configurado -arquivos. Isso é apropriado para desenvolvimento local e arquivo confiável de locatário único -pontes, mas não é o modelo de segurança de produção padrão. - -Quando `NODE_ENV=production`, a estrutura recusa o modo `local-files`, a menos que você -definir: - -```bash -AGENT_NATIVE_ALLOW_LOCAL_FILES_IN_PRODUCTION=true -``` - -Defina isso apenas para uma implantação confiável de locatário único, onde todos que podem usar -o aplicativo tem permissão para ler e gravar os arquivos configurados. Para hospedagem normal, -aplicativos multiusuário, usam modo de banco de dados e compartilhamento apoiado por SQL. +Para migrar, remova `mode: "local-files"` e +`AGENT_NATIVE_MODE=local-files`, mantenha as raízes e adicione `source`. IDs no +frontmatter preservam a identidade ao renomear ou mover uma página. Componentes +e extensões locais continuam usando a ponte local confiável. diff --git a/packages/core/docs/content/locales/pt-BR/template-content.mdx b/packages/core/docs/content/locales/pt-BR/template-content.mdx index 402b06920d..89127566f8 100644 --- a/packages/core/docs/content/locales/pt-BR/template-content.mdx +++ b/packages/core/docs/content/locales/pt-BR/template-content.mdx @@ -95,9 +95,8 @@ Ao abrir o aplicativo, você verá uma árvore de páginas ao lado do editor. O - **Organize as páginas em uma árvore** — aninhe infinitamente, arraste para reordenar, páginas favoritas que você usa com frequência. - **Pesquise tudo** com pesquisa de texto completo em títulos e conteúdo. - **Edite arquivos Markdown/MDX locais como Obsidian.** Use a visualização `/local-files` - para exportar seu espaço de trabalho para arquivos, edite-os em suas próprias ferramentas, visualize - alterações e importe-as de volta. No modo de arquivo local, o conteúdo é gravado diretamente em - o arquivo `.md` ou `.mdx` selecionado. + para conectar uma pasta ao Files, editar com suas ferramentas, sincronizar + mudanças e revisar conflitos dentro do aplicativo SQL. - **Gere blocos personalizados interativos avançados.** Registre componentes React locais, insira-os como MDX e deixe o agente criar ou atualizar arquivos de componentes para seus documentos. @@ -140,32 +139,19 @@ como superfície de revisão. O aplicativo hospedado continua sendo a fonte da v comentários, permissões e colaboração ao vivo; a pasta local é explícita superfície de sincronização. -O conteúdo também pode ser executado no **Modo de arquivo local**, onde os arquivos são a origem de -verdade em vez de documentos SQL. Adicione `agent-native.json` a um repositório, defina -`mode: "local-files"` e configure raízes como `docs/`, `blog/`, -`content/` e `resources/`. O editor de conteúdo padrão preenche seu -barra lateral esquerda dos arquivos `.md`/`.mdx` locais e grava as edições de volta no -arquivo selecionado através do documento normal actions. Use isto para documentos repo-first, -blogs, bibliotecas de recursos ou conteúdo pessoal no estilo Obsidian com tecnologia MDX -componentes; volte para o modo de banco de dados quando desejar colaboração hospedada e -Compartilhamento apoiado por SQL. Consulte [Local File Mode](/docs/local-file-mode) para -layout de repositório independente, configuração, componentes MDX personalizados, local -Widgets `extensions/` e guia de segurança de produção. +Pastas locais agora usam o mesmo modelo SQL que todas as páginas. Cada página +pertence a um espaço e ao banco Files; a pasta é um adaptador de fonte. Consulte +[Fontes de pastas locais](/docs/local-file-mode) para políticas, conflitos, +componentes MDX e a migração do modo antigo. -Para instalar a habilidade Content local-files em um repositório existente: +Para abrir Content em um repositório existente: ```bash -npx @agent-native/core@latest skills add content --mode local-files --scope project +npx @agent-native/core@latest content . ``` -O instalador copia a habilidade `content` para seu agente de codificação e grava ou -atualiza `agent-native.json` com raízes de conteúdo para `docs/`, `blog/`, `content/`, -e `resources/`. Quando um aplicativo de conteúdo local, Agent Native Desktop ou confiável -a ponte local está em execução, os agentes devem usar o Conteúdo actions como -`list-documents`, `get-document`, `edit-document`, `update-document` e -`share-local-file-document` em vez de gravações brutas no sistema de arquivos. Sem aquele local -bridge, a habilidade instalada ainda dá ao agente o contrato de repoediting para -edições seguras de Markdown/MDX. +O CLI inicia Content com seu banco normal, registra a pasta como fonte e abre a +conexão sem definir uma variável de modo. ## Para desenvolvedores diff --git a/packages/core/docs/content/locales/zh-CN/local-file-mode.mdx b/packages/core/docs/content/locales/zh-CN/local-file-mode.mdx index c266dd1bc4..82baa87d62 100644 --- a/packages/core/docs/content/locales/zh-CN/local-file-mode.mdx +++ b/packages/core/docs/content/locales/zh-CN/local-file-mode.mdx @@ -1,523 +1,65 @@ --- -title: "本地文件模式" -description: "使用本地 Markdown、MDX 和其他存储库文件作为事实来源运行代理本机应用 - 包括带有自定义组件的黑曜石风格 MDX 文档。" +title: "本地文件夹源" +description: "将 Markdown 和 MDX 文件夹连接到由数据库支持的 Content 空间。" --- -# 本地文件模式 +# 本地文件夹源 -本地文件模式允许代理本机应用程序附加其正常的 UI 和操作界面 -直接到存储库或工作区中的文件。该应用程序仍然感觉像是托管的 -产品,但其列表视图、编辑器和代理工具可读写本地文件 -而不是 SQL 支持的应用记录。 +Content 现在只有一个数据模型:每个页面都存储在 SQL 中,属于一个**空间**,并且 +是该空间系统 **Files** 数据库的条目。本地文件夹是连接到 Files 的数据源,而不是 +另一种应用模式。`AGENT_NATIVE_MODE` 开关已被移除。 -第一个实现是在内容模板中:左侧边栏是 -从本地 `.md` 和 `.mdx` 文件填充,选择一个页面打开标准 -内容编辑器,并保存写回到所选文件。相同的文件可以 -也可以由 Codex、Claude 代码、Agent-Native 侧边栏代理或普通编辑 -编辑器。 +每位用户有个人空间,每个可访问的组织有组织空间;个人 **Workspaces** 目录决定 +应用中显示哪些空间。Files 是普通的 Content 数据库,因此它的属性、筛选、排序、 +分组和保存视图也可以呈现为侧边栏。 -对于内容,这使得该产品感觉像是 MDX 的开源黑曜石: -您的文档以文件形式存在,而应用程序添加了可视化编辑器、代理 actions, -可共享的副本,以及丰富的交互式 MDX 组件。 +## 连接文件夹 -当您想要回购优先的工作流程时,请使用本地文件模式: +通过浏览器或 Agent Native Desktop 选择文件夹,将它连接到现有空间,或创建一个 +由该文件夹支持的私有空间。SQL 只保存不透明的连接 ID、相对路径、哈希和数据源 +元数据;不保存绝对路径、浏览器句柄或原始文件正文。 -- `docs/*.mdx` 的文档存储库 -- `blog/*.mdx` 的博客 -- `resources/*.md` 中的定位、消息传递或团队笔记等资源 -- 个人黑曜石风格的知识库,具有更丰富的MDX编辑器 -- 需要从本地 React 代码生成的交互式自定义 MDX 块的文档 -- 应用程序工件应该易于编码代理检查和修补 +可选策略为 `database_primary`、`source_primary` 和 +`reviewed_bidirectional`。同步会把文件实体化为普通 SQL 页面。如果文件与 Content +页面同时更改,系统会创建待审核变更,而不会悄悄覆盖任一方。断开连接会保留页面 +和本地文件。 -当您需要托管协作应用程序体验时,请使用数据库模式: -多用户共享、SQL 支持的权限、评论、版本历史记录和 -没有本地文件系统访问权限的生产托管。 - -## 心智模型 - -有两种真相来源模式: - -| 模式 | 事实来源 | 最适合 | -| ------------ | --------------------------------- | ------------------------------------------------------- | -| 数据库模式 | SQL 行至 Drizzle | 托管应用、协作、共享、评论、版本历史记录 | -| 本地文件模式 | `agent-native.json`声明的Repo文件 | 本地/开发工作流程、Git 审核、编码代理编辑、文件本机内容 | - -UI 和特工 actions 在两种模式下都应保持相同的形状。内容 -编辑器仍然编辑文档;区别在于这些文档是否解析 -到 SQL 行或本地文件。 - - - -```html -
-
-
内容 UI
-
- 代理 + actions
list/get/update-document -
-
- -
-
- 数据库模式SQL rows via Drizzle托管 · 分享 · 评论 · 历史 -
-
- Local File Moderepo files via agent-native.jsonGit 评审 · 编码代理编辑 -
-
-
-``` - -```css -.diagram-mode { - display: flex; - align-items: center; - gap: 14px; - flex-wrap: wrap; -} -.diagram-mode .diagram-col { - display: flex; - flex-direction: column; - gap: 10px; -} -.diagram-mode .diagram-arrow { - font-size: 22px; - line-height: 1; -} -.diagram-mode .resolve { - display: flex; - gap: 12px; - flex-wrap: wrap; -} -.diagram-mode .diagram-panel { - display: flex; - flex-direction: column; - gap: 4px; - padding: 12px 14px; -} -``` - -
- -## 示例存储库 - -内容工作区可以像这样小: - - - -在本地文件模式下,内容侧边栏显示 `docs/`、`blog/` 和 -`resources/` 树为页面。选择 `docs/getting-started.mdx` 打开 -标准内容编辑器中的文件;在 UI 中编辑写回 -`docs/getting-started.mdx`. - -`components/` 不是内容根。 MDX -文件可以导入或引用。编辑器可以渲染简单的本地MDX组件 -无需您克隆或分叉整个内容应用。 - -`extensions/` 也不是内容根。它是一个本地扩展库: -小型沙盒小部件,可以在应用程序槽中呈现,同时其源保留在 -存储库。 - -## 将内容安装到存储库中 - -对于现有文档、博客或 MDX 工作区,安装内容本地文件 -技能: +## 从仓库启动 ```bash -npx @agent-native/core@latest skills add content --mode local-files --scope project +npx @agent-native/core@latest content . ``` -这会将 `content` 技能复制到存储库的代理技能文件夹中并写入 -或使用内容默认值更新 `agent-native.json`: - -- 工作区级别的 `mode: "local-files"` -- `apps.content.mode: "local-files"` -- `docs/`、`blog/`、`content/` 和 `resources/` 的内容根 -- `components/` 用于本地 MDX 组件 -- `extensions/` 用于本地扩展小部件 - -安装的技能告诉编码代理使用内容actions -(`list-documents`, `get-document`, `edit-document`, `update-document`, -`share-local-file-document` 和组件文件 actions)(当本地内容应用时) -或 Agent Native 桌面桥公开它们。如果没有桥在运行,则该技能 -退回到安全的直接存储库编辑,同时保留 frontmatter、导入、JSX, -和未知的 MDX。 +此命令启动正常的数据库版 Content,将文件夹注册为数据源并打开连接界面;它不会 +设置模式环境变量。 ## 配置 -将 `agent-native.json` 添加到存储库或工作区根目录: - ```json { "version": 1, "apps": { "content": { - "mode": "local-files", "roots": [ { "name": "Docs", "path": "docs", "kind": "docs", - "extensions": [".md", ".mdx"] - }, - { - "name": "Blog", - "path": "blog", - "kind": "blog", - "extensions": [".md", ".mdx"] - }, - { - "name": "Resources", - "path": "resources", - "kind": "resources", - "extensions": [".md", ".mdx"] + "extensions": [".md", ".mdx"], + "source": { + "type": "local-folder", + "connectionId": "local-folder:", + "truthPolicy": "source_primary" + } } ], "components": "components", - "extensions": "extensions", - "hide": ["**/_*.md", "**/_*.mdx"] + "extensions": "extensions" } } } ``` -您还可以使用 `AGENT_NATIVE_MODE=local-files` 或启用本地文件 -`AGENT_NATIVE_DATA_MODE=local-files`;清单是首选,因为它 -在存储库本身中记录文件夹合同。 - -## 内容文件格式 - -内容为 Markdown 和 MDX。 Frontmatter 保存页面元数据,正文为 -可编辑文档: - -```mdx ---- -title: "开始使用" -icon: "sparkles" -isFavorite: true -updatedAt: "2026-06-12T20:00:00.000Z" ---- - -# 开始使用 - -Use to show framework-specific code. -``` - -标题来自 `title` frontmatter(如果存在),否则来自 -文件名。编辑器保留了 MDX 源代码,但尚无法进行可视化编辑,因此 -编码代理和普通文本编辑器仍然是安全的逃生舱口。 - -## 自定义 MDX 组件 - -内容可以从配置的 `components` 文件夹中预览本地组件。 -这适用于文档样式的 MDX 组件,例如选项卡、标注、包 -安装片段或特定于框架的代码块。 - -例如,在您的内容旁边添加一个交互式组件: - -```tsx -// components/ImpactCounter.tsx -import { useState } from "react"; - -export function ImpactCounter({ - label = "points", - accent = "blue", - featured = false, -}: { - label?: string; - accent?: "blue" | "green" | "purple"; - featured?: boolean; -}) { - const [count, setCount] = useState(3); - const accentClass = - accent === "green" - ? "border-green-300 bg-green-50" - : accent === "purple" - ? "border-purple-300 bg-purple-50" - : "border-blue-300 bg-blue-50"; - - return ( -
-
Launch impact
-
- {count} {label} -
- {featured ?
Featured metric
: null} - -
- ); -} - -export const ImpactCounterInputs = { - label: { - type: "string", - label: "Metric label", - default: "points", - }, - accent: { - type: "select", - label: "Accent", - options: ["blue", "green", "purple"], - default: "blue", - }, - featured: { - type: "boolean", - label: "Featured", - default: false, - }, -}; -``` - -然后从任何本地 MDX 文件使用它: - -```mdx ---- -title: "发布说明" ---- - -# 发布说明 - - -``` - -内容开发服务器发现 PascalCase 命名导出和 PascalCase 默认 -从 `components/` 下的 `.tsx`、`.jsx`、`.ts` 和 `.js` 文件导出。那些 -组件在编辑器内呈现并出现在 -**本地组件**。斜线插入创建一个最小的标签,例如 -``;需要时在 MDX 源中添加 props。 - -组件执行有意成为本地开发/桌面桥接功能,而不是 -普通托管浏览器文件夹访问。如果你打开`content.agent-native.com`, -选择**本地文件**,并在Chrome中选择一个文件夹,应用程序可以读写 -通过浏览器文件系统访问`.md`和`.mdx`文件API,但是 -Chrome 不会公开 Vite 编译的绝对文件夹路径 -`components/*.tsx`。要预览和热重载自定义 React 组件,请运行 -本地内容或使用 Agent Native Desktop,以便受信任的本地网桥可以 -将所选工作区注册到本地内容开发服务器。在该模式下, -通过Vite编辑现有组件文件热重载,并添加或 -删除组件文件会重新加载组件注册表和斜杠菜单。 - -代理还可以使用这些已注册的组件文件。使用 -`list-local-component-files` 找到注册的工作空间id,然后 -`write-local-component-file` 创建或更新 `.tsx`、`.jsx`、`.ts` 或 -`.js` 文件位于工作区的 `components/` 文件夹下。 MDX 文件仍然是 -组件使用的真实来源;组件文件保持正常仓库 -使用 Git 审核源文件。 - -如果组件导出输入元数据,则在编辑器中选择该组件 -在组件的右上角显示一个编辑按钮。支持的输入类型 -为 `string`、`textarea`、`number`、`boolean` 和 `select`。表格写 -更改回 MDX 标签,因此本地文件仍然是事实来源。 -元数据可以导出为 `ComponentNameInputs`、`ComponentNameConfig.inputs`、 -`Component.inputs`,或`agentNative.inputs`。 - -带有文字属性的简单组件标签可以内联预览: - -```mdx - - - - -This setting affects production deploys. - - -``` - -复杂的 JSX 表达式保留在源代码中。如果编辑者不能安全 -预览组件道具,它显示一个警告占位符而不是 -默默地丢弃数据。 - -## 共享本地文件 - -本地文件不会直接共享,因为其他用户无法读取路径 -你的机器。内容工具栏的共享按钮创建或刷新 -所选文件的数据库支持副本,导航到该副本,然后打开 -正常共享弹出窗口。原始本地文件保留在本地文件下; -数据库副本出现在本地文件模式下的共享副本下,并使用 -标准文档共享模型。 - -## 本地扩展 - -本地文件模式还可以从配置中加载存储库支持的扩展 -`extensions` 文件夹。每个扩展都是一个带有 `extension.json` -清单和 HTML 条目文件: - -```text -extensions/ - doc-status/ - extension.json - index.html -``` - -```json -{ - "id": "doc-status", - "name": "Doc Status", - "description": "Shows metadata for the selected Content file.", - "entry": "index.html", - "slots": ["content.sidebar.bottom"], - "permissions": { - "appActions": ["list-documents"], - "extensionData": true - } -} -``` - -`index.html` 与普通使用的 Alpine/Tailwind 扩展主体格式相同 -数据库支持的扩展。当内容应用看到本地扩展程序时 -声明了 `content.sidebar.bottom`,它在底部呈现该扩展 -内容侧边栏。主机通过选择的`window.slotContext` -文档 ID、标题、源元数据以及内容是否处于本地文件模式。 - -本地扩展由应用程序预览,但作为文件进行编辑。扩展 -列表显示它们带有本地文件徽章,整页查看器指向 -入口文件。 SQL 支持的扩展 actions,例如更新、删除、共享和 -历史记录不适用;使用您的编辑器、Codex、Claude 代码或 Git 历史记录 -源代码更改。 - -对于 v1,本地扩展有意保守: - -- 他们可以将 `extensionData` 用于自己的小型运行时状态 -- 他们只能调用`extension.json`中列出的`appAction` -- 原始 SQL 助手和外部 `extensionFetch` 已禁用 -- slot 目标在 `extension.json` 中声明,而不是通过 SQL 安装 - -这为本地工作空间提供了类似黑曜石的插件界面,而无需让 -任意存储库文件继承数据库支持的扩展的所有功能。 - -## 应用程序如何使用它 - -本地文件模式是通过框架的本地工件助手实现的。 -应用程序声明其拥有的工件类型的根,然后读取和写入 -通过 UI 和代理已经使用的相同操作界面。 - -对于内容,这意味着: - -- `list-documents` 列出配置的 `.md` 和 `.mdx` 文件。 -- `get-document` 读取选定的本地文件。 -- `update-document` 写入选定的本地文件。 -- `create-document` 在所选文件夹中创建新的本地 `.mdx` 文件。 -- `delete-document`删除本地文件。 -- 搜索在配置的本地文件中运行。 - -不能从内容 UI 中移动、重命名和重新排序本地文件页面 -尚支持。在工作区或使用编码代理执行这些操作; -内容侧边栏将反映生成的文件树。 - -这使代理合约变得简单:代理可以继续使用内容 actions, -而这些 actions 决定目标是 SQL 支持的还是文件支持的。 - -随着时间的推移,其他应用程序可以采用相同的模式。幻灯片应用程序可以映射 -`slides/*.mdx` 到甲板,计划应用程序可以将 `plans/*` 映射到计划文档,以及 -仪表板应用程序可以将 `dashboards/*.mdx` 映射到仪表板。那些特定于应用程序的 -文件夹是位于同一本地工件合约之上的约定。 - -## 本地文件与导出/导入 - -内容有两种不同的文件工作流程: - -| 工作流程 | 发生了什么 | -| ------------------------ | ------------------------------------------------------------------------ | -| `/local-files` 导出/导入 | 数据库模式仍然是事实来源。文件是您导出、编辑、预览和导入的显式同步表面。 | -| 本地文件模式 | 文件是真相的来源。内容侧边栏和编辑器直接对本地文件进行操作。 | - -当您需要偶尔查看托管工作区的文件时,请使用导出/导入。 -当存储库本身是工作区时,使用本地文件模式。 - -## 历史与合作 - -本地文件模式依赖于文件本机历史记录: - -- 向 Git 提交重要更改 -- 使用拉取请求进行审核 -- 让编码代理直接编辑相同的文件 -- 使用普通文件差异来了解更改 - -数据库模式仍然更适合托管协作功能,例如 -共享、评论、SQL 支持的版本历史记录和实时多用户编辑。 - -提供者同步可以分层在任一模式之上。例如,文档存储库可以 -添加 actions,将内容从 CMS 提取到本地 MDX 文件或推送所选内容 -本地文件返回到那个CMS。 - -## 安全生产 - -本地文件模式为应用程序 actions 提供对配置的工作区的直接写入访问权限 -文件。这适合本地开发和可信单租户文件 -桥梁,但它不是默认的生产安全模型。 - -当 `NODE_ENV=production` 时,框架拒绝 `local-files` 模式,除非您 -设置: - -```bash -AGENT_NATIVE_ALLOW_LOCAL_FILES_IN_PRODUCTION=true -``` - -仅针对受信任的单租户部署进行设置,其中每个人都可以使用 -应用程序可以读取和写入配置的文件。对于普通托管, -多用户应用程序,使用数据库模式和SQL支持的共享。 +迁移时请删除 `mode: "local-files"` 和 `AGENT_NATIVE_MODE=local-files`,保留 +roots 并添加 `source`。frontmatter ID 能在重命名和移动时保持页面身份。本地组件 +和扩展仍通过受信任的本地桥接加载。 diff --git a/packages/core/docs/content/locales/zh-CN/template-content.mdx b/packages/core/docs/content/locales/zh-CN/template-content.mdx index 4ce0f9edcf..465d55a018 100644 --- a/packages/core/docs/content/locales/zh-CN/template-content.mdx +++ b/packages/core/docs/content/locales/zh-CN/template-content.mdx @@ -89,9 +89,8 @@ description: "MDX 的开源 Obsidian:编辑本地 Markdown/MDX 文件,生成 - **在树中组织页面** — 无限嵌套、拖动重新排序、收藏您经常使用的页面。 - **搜索所有内容**,通过标题和内容进行全文搜索。 - **像 Obsidian 一样编辑本地 Markdown/MDX 文件。**使用 `/local-files` 视图 - 将工作区导出到文件,在您自己的工具中编辑它们,预览 - 更改,并将其导入回来。在本地文件模式下,内容直接写入 - 选定的 `.md` 或 `.mdx` 文件。 + 将文件夹连接到 Files,使用自己的工具编辑并同步更改,然后在 SQL 应用中 + 审核冲突。 - **生成丰富的交互式自定义块。**注册本地React组件, 将它们插入为 MDX,并让代理创建或更新组件文件 您的文档。 @@ -134,32 +133,18 @@ Native Desktop,并将当前文档树导出为Markdown/MDX 评论、权限和实时协作;本地文件夹是显式的 同步表面。 -内容还可以在**本地文件模式**下运行,其中文件是内容的来源 -真相而不是SQL文件。将 `agent-native.json` 添加到仓库,设置 -`mode: "local-files"`,并配置`docs/`、`blog/`等根, -`content/` 和 `resources/`。然后标准内容编辑器填充其 -来自本地 `.md`/`.mdx` 文件的左侧边栏,并将编辑写回 -通过普通文件actions选择文件。将其用于回购优先文档, -由 MDX 驱动的博客、资源库或黑曜石风格的个人内容 -组件;当您需要托管协作时切换回数据库模式并且 -SQL 支持的共享。请参阅 [Local File Mode](/docs/local-file-mode) -独立存储库布局、配置、自定义 MDX 组件、本地 -`extensions/`小部件、安全生产指南。 +本地文件夹现在与所有页面使用相同的 SQL 模型。每个页面属于一个空间和它的 +Files 数据库;文件夹是该数据库的数据源适配器。策略、冲突、MDX 组件和旧模式 +迁移请参阅[本地文件夹源](/docs/local-file-mode)。 -要将内容本地文件技能安装到现有存储库中: +要在现有仓库中打开 Content: ```bash -npx @agent-native/core@latest skills add content --mode local-files --scope project +npx @agent-native/core@latest content . ``` -安装程序会复制您的编码代理的 `content` 技能并写入或 -使用 `docs/`、`blog/`、`content/` 的内容根更新 `agent-native.json` -和`resources/`。当本地内容应用、Agent Native 桌面或受信任 -本地网桥正在运行,代理应使用内容actions,例如 -`list-documents`、`get-document`、`edit-document`、`update-document` 和 -`share-local-file-document` 而不是原始文件系统写入。没有那个本地 -bridge,已安装的技能仍然为代理提供了回购编辑合同 -安全 Markdown/MDX 编辑。 +CLI 会启动正常的数据库版 Content,注册文件夹源,并在不设置模式环境变量的 +情况下打开连接界面。 ## 对于开发者 diff --git a/packages/core/docs/content/locales/zh-TW/local-file-mode.mdx b/packages/core/docs/content/locales/zh-TW/local-file-mode.mdx index 977673e69b..88dd45aa2f 100644 --- a/packages/core/docs/content/locales/zh-TW/local-file-mode.mdx +++ b/packages/core/docs/content/locales/zh-TW/local-file-mode.mdx @@ -1,523 +1,65 @@ --- -title: "本機檔案模式" -description: "使用本機 Markdown、MDX 和其他儲存庫檔案作為事實來源執行 Agent-Native 應用 - 包括帶有自訂元件的黑曜石風格 MDX 檔案。" +title: "本機資料夾來源" +description: "將 Markdown 與 MDX 資料夾連接至由資料庫支援的 Content 空間。" --- -# 本機檔案模式 +# 本機資料夾來源 -本機檔案模式允許 Agent-Native 應用程式附加其正常的 UI 和操作介面 -直接到儲存庫或工作區中的檔案。該應用程式仍然感覺像是託管的 -產品,但其清單檢視、編輯器和代理工具可讀寫本機檔案 -而不是 SQL 支援的應用紀錄。 +Content 現在只有一個資料模型:每個頁面都儲存在 SQL 中,屬於一個**空間**,並且 +是該空間系統 **Files** 資料庫的項目。本機資料夾是連接至 Files 的來源,而不是 +另一種應用程式模式。`AGENT_NATIVE_MODE` 切換已移除。 -第一個實現是在內容範本中:左側邊欄是 -從本機 `.md` 和 `.mdx` 檔案填充,選取一個頁面開啟標準 -內容編輯器,並儲存寫回到所選檔案。相同的檔案可以 -也可以由 Codex、Claude 程式碼、Agent-Native 側邊欄代理或普通編輯 -編輯器。 +每位使用者有個人空間,每個可存取的組織有組織空間;個人 **Workspaces** 目錄 +決定應用程式顯示哪些空間。Files 是一般 Content 資料庫,因此它的屬性、篩選、 +排序、分組與已儲存檢視也能呈現為側邊欄。 -對於內容,這使得該產品感覺像是 MDX 的開放原始碼黑曜石: -您的檔案以檔案形式存在,而應用程式新增了視覺化編輯器、代理 actions, -可共用的副本,以及豐富的互動式 MDX 元件。 +## 連接資料夾 -當您想要回購優先的工作流程時,請使用本機檔案模式: +透過瀏覽器或 Agent Native Desktop 選擇資料夾,將它連接至既有空間,或建立一個 +由該資料夾支援的私人空間。SQL 只儲存不透明的連線 ID、相對路徑、雜湊與來源 +中繼資料;不儲存絕對路徑、瀏覽器控制代碼或原始檔案本文。 -- `docs/*.mdx` 的檔案儲存庫 -- `blog/*.mdx` 的部落格 -- `resources/*.md` 中的定位、訊息傳遞或團隊筆記等資源 -- 個人黑曜石風格的知識庫,具有更豐富的 MDX 編輯器 -- 需要從本機 React 程式碼產生的互動式自訂 MDX 塊的檔案 -- 應用程式工件應該易於編碼代理檢查和修補 +可用策略為 `database_primary`、`source_primary` 與 +`reviewed_bidirectional`。同步會將檔案實體化為一般 SQL 頁面。若檔案和 Content +頁面同時變更,系統會建立待審查的變更,而不會悄悄覆寫任一方。中斷連線會保留 +頁面與本機檔案。 -當您需要託管協作應用程式體驗時,請使用資料庫模式: -多使用者共用、SQL 支援的權限、評論、版本歷史紀錄和 -沒有本機檔案系統存取權限的正式環境託管。 - -## 心智模型 - -有兩種真相來源模式: - -| 模式 | 事實來源 | 最適合 | -| ------------ | --------------------------------- | ------------------------------------------------------- | -| 資料庫模式 | SQL 行至 Drizzle | 託管應用、協作、共用、評論、版本歷史紀錄 | -| 本機檔案模式 | `agent-native.json`聲明的Repo檔案 | 本機/開發工作流程、Git 審核、編碼代理編輯、檔案本機內容 | - -UI 和代理 actions 在兩種模式下都應保持相同的形狀。內容 -編輯器仍然編輯檔案;區別在於這些檔案是否解析 -到 SQL 行或本機檔案。 - - - -```html -
-
-
內容 UI
-
- 代理 + actions
list/get/update-document -
-
- -
-
- 資料庫模式SQL rows via Drizzle託管 · 分享 · 評論 · 歷史 -
-
- Local File Moderepo files via agent-native.jsonGit 評審 · 編碼代理編輯 -
-
-
-``` - -```css -.diagram-mode { - display: flex; - align-items: center; - gap: 14px; - flex-wrap: wrap; -} -.diagram-mode .diagram-col { - display: flex; - flex-direction: column; - gap: 10px; -} -.diagram-mode .diagram-arrow { - font-size: 22px; - line-height: 1; -} -.diagram-mode .resolve { - display: flex; - gap: 12px; - flex-wrap: wrap; -} -.diagram-mode .diagram-panel { - display: flex; - flex-direction: column; - gap: 4px; - padding: 12px 14px; -} -``` - -
- -## 範例儲存庫 - -內容工作區可以像這樣小: - - - -在本機檔案模式下,內容側邊欄顯示 `docs/`、`blog/` 和 -`resources/` 樹為頁面。選取 `docs/getting-started.mdx` 開啟 -標準內容編輯器中的檔案;在 UI 中編輯寫回 -`docs/getting-started.mdx`. - -`components/` 不是內容根。 MDX -檔案可以匯入或引用。編輯器可以渲染簡單的本機 MDX 元件 -無需您克隆或分叉整個內容應用。 - -`extensions/` 也不是內容根。它是一個本機擴充功能庫: -小型沙盒小工具,可以在應用程式槽中呈現,同時其來源保留在 -儲存庫。 - -## 將內容安裝到儲存庫中 - -對於現有檔案、部落格或 MDX 工作區,安裝內容本機檔案 -技能: +## 從儲存庫啟動 ```bash -npx @agent-native/core@latest skills add content --mode local-files --scope project +npx @agent-native/core@latest content . ``` -這會將 `content` 技能複製到儲存庫的代理技能資料夾中並寫入 -或使用內容預設值更新 `agent-native.json`: - -- 工作區層級的 `mode: "local-files"` -- `apps.content.mode: "local-files"` -- `docs/`、`blog/`、`content/` 和 `resources/` 的內容根 -- `components/` 用於本機 MDX 元件 -- `extensions/` 用於本機擴充功能小工具 - -安裝的技能告訴編碼代理使用內容actions -(`list-documents`, `get-document`, `edit-document`, `update-document`, -`share-local-file-document` 和元件檔案 actions)(當本機內容應用時) -或 Agent Native 桌面橋公開它們。如果沒有橋在執行,則該技能 -退回到安全的直接儲存庫編輯,同時保留 frontmatter、匯入、JSX, -和未知的 MDX。 +此命令啟動一般的資料庫版 Content,將資料夾註冊為來源並開啟連線畫面;它不會 +設定模式環境變數。 ## 設定 -將 `agent-native.json` 新增到儲存庫或工作區根目錄: - ```json { "version": 1, "apps": { "content": { - "mode": "local-files", "roots": [ { "name": "Docs", "path": "docs", "kind": "docs", - "extensions": [".md", ".mdx"] - }, - { - "name": "Blog", - "path": "blog", - "kind": "blog", - "extensions": [".md", ".mdx"] - }, - { - "name": "Resources", - "path": "resources", - "kind": "resources", - "extensions": [".md", ".mdx"] + "extensions": [".md", ".mdx"], + "source": { + "type": "local-folder", + "connectionId": "local-folder:", + "truthPolicy": "source_primary" + } } ], "components": "components", - "extensions": "extensions", - "hide": ["**/_*.md", "**/_*.mdx"] + "extensions": "extensions" } } } ``` -您還可以使用 `AGENT_NATIVE_MODE=local-files` 或啟用本機檔案 -`AGENT_NATIVE_DATA_MODE=local-files`;清單是首選,因為它 -在儲存庫本身中紀錄資料夾合同。 - -## 內容檔案格式 - -內容為 Markdown 和 MDX。 Frontmatter 儲存頁面中繼資料,內文為 -可編輯檔案: - -```mdx ---- -title: "開始使用" -icon: "sparkles" -isFavorite: true -updatedAt: "2026-06-12T20:00:00.000Z" ---- - -# 開始使用 - -Use to show framework-specific code. -``` - -標題來自 `title` frontmatter(如果存在),否則來自 -檔名。編輯器保留了 MDX 來源程式碼,但尚無法進行視覺化編輯,因此 -編碼代理和普通文字編輯器仍然是安全的逃生艙口。 - -## 自訂 MDX 元件 - -內容可以從設定的 `components` 資料夾中預覽本機元件。 -這適用於檔案樣式的 MDX 元件,例如分頁、標注、包 -安裝片段或特定於框架的程式碼塊。 - -例如,在您的內容旁邊新增一個互動式元件: - -```tsx -// components/ImpactCounter.tsx -import { useState } from "react"; - -export function ImpactCounter({ - label = "points", - accent = "blue", - featured = false, -}: { - label?: string; - accent?: "blue" | "green" | "purple"; - featured?: boolean; -}) { - const [count, setCount] = useState(3); - const accentClass = - accent === "green" - ? "border-green-300 bg-green-50" - : accent === "purple" - ? "border-purple-300 bg-purple-50" - : "border-blue-300 bg-blue-50"; - - return ( -
-
Launch impact
-
- {count} {label} -
- {featured ?
Featured metric
: null} - -
- ); -} - -export const ImpactCounterInputs = { - label: { - type: "string", - label: "Metric label", - default: "points", - }, - accent: { - type: "select", - label: "Accent", - options: ["blue", "green", "purple"], - default: "blue", - }, - featured: { - type: "boolean", - label: "Featured", - default: false, - }, -}; -``` - -然後從任何本機 MDX 檔案使用它: - -```mdx ---- -title: "發布說明" ---- - -# 發布說明 - - -``` - -內容開發伺服器發現 PascalCase 命名匯出和 PascalCase 預設 -從 `components/` 下的 `.tsx`、`.jsx`、`.ts` 和 `.js` 檔案匯出。那些 -元件在編輯器內呈現並出現在 -**本機元件**。斜線插入建立一個最小的標籤,例如 -``;需要時在 MDX 來源中新增 props。 - -元件執行有意成為本機開發/桌面橋接功能,而不是 -普通託管瀏覽器資料夾存取。如果你開啟`content.agent-native.com`, -選取**本機檔案**,並在Chrome中選取一個資料夾,應用程式可以讀寫 -透過瀏覽器檔案系統存取`.md`和`.mdx`檔案 API,但是 -Chrome 不會公開 Vite 編譯的絕對資料夾路徑 -`components/*.tsx`。要預覽和熱重載自訂 React 元件,請執行 -本機內容或使用 Agent Native Desktop,以便受信任的本機網橋可以 -將所選工作區註冊到本機內容開發伺服器。在該模式下, -透過 Vite 編輯現有元件檔案熱重載,並新增或 -刪除元件檔案會重新載入元件註冊表和斜杠選單。 - -代理還可以使用這些已註冊的元件檔案。使用 -`list-local-component-files` 找到註冊的工作空間id,然後 -`write-local-component-file` 建立或更新 `.tsx`、`.jsx`、`.ts` 或 -`.js` 檔案位於工作區的 `components/` 資料夾下。 MDX 檔案仍然是 -元件使用的真實來源;元件檔案保持正常倉庫 -使用 Git 審核來源檔案。 - -如果元件匯出輸入中繼資料,則在編輯器中選取該元件 -在元件的右上角顯示一個編輯按鈕。支援的輸入型別 -為 `string`、`textarea`、`number`、`boolean` 和 `select`。表格寫 -更改回 MDX 標籤,因此本機檔案仍然是事實來源。 -中繼資料可以匯出為 `ComponentNameInputs`、`ComponentNameConfig.inputs`、 -`Component.inputs`,或`agentNative.inputs`。 - -帶有文字屬性的簡單元件標籤可以行內預覽: - -```mdx - - - - -This setting affects production deploys. - - -``` - -複雜的 JSX 表達式保留在來源程式碼中。如果編輯者不能安全 -預覽元件道具,它顯示一個警告預留位置而不是 -默默地丟棄資料。 - -## 共用本機檔案 - -本機檔案不會直接共用,因為其他使用者無法讀取路徑 -你的機器。內容工具列的共用按鈕建立或重新整理 -所選檔案的資料庫支援副本,導覽到該副本,然後開啟 -正常共用快顯視窗。原始本機檔案保留在本機檔案下; -資料庫副本出現在本機檔案模式下的共用副本下,並使用 -標準檔案共用模型。 - -## 本機擴充功能 - -本機檔案模式還可以從設定中載入儲存庫支援的擴充功能 -`extensions` 資料夾。每個擴充功能都是一個帶有 `extension.json` -清單和 HTML 條目檔案: - -```text -extensions/ - doc-status/ - extension.json - index.html -``` - -```json -{ - "id": "doc-status", - "name": "Doc Status", - "description": "Shows metadata for the selected Content file.", - "entry": "index.html", - "slots": ["content.sidebar.bottom"], - "permissions": { - "appActions": ["list-documents"], - "extensionData": true - } -} -``` - -`index.html` 與普通使用的 Alpine/Tailwind 擴充功能主體格式相同 -資料庫支援的擴充功能。當內容應用看到本機擴充功能時 -聲明了 `content.sidebar.bottom`,它在底部呈現該擴充功能 -內容側邊欄。主機透過選取的`window.slotContext` -檔案 ID、標題、來源中繼資料以及內容是否處於本機檔案模式。 - -本機擴充功能由應用程式預覽,但作為檔案進行編輯。擴充功能 -清單顯示它們帶有本機檔案徽章,整頁面檢視器指向 -入口檔案。 SQL 支援的擴充功能 actions,例如更新、刪除、共用和 -歷史紀錄不適用;使用您的編輯器、Codex、Claude 程式碼或 Git 歷史紀錄 -來源程式碼更改。 - -對於 v1,本機擴充功能有意保守: - -- 他們可以將 `extensionData` 用於自己的小型執行時狀態 -- 他們只能呼叫`extension.json`中列出的`appAction` -- 原始 SQL 助手和外部 `extensionFetch` 已停用 -- slot 目標在 `extension.json` 中聲明,而不是透過 SQL 安裝 - -這為本機工作空間提供了類似黑曜石的外掛介面,而無需讓 -任意儲存庫檔案繼承資料庫支援的擴充功能的所有功能。 - -## 應用程式如何使用它 - -本機檔案模式是透過框架的本機工件助手實現的。 -應用程式聲明其擁有的工件型別的根,然後讀取和寫入 -透過 UI 和代理已經使用的相同操作介面。 - -對於內容,這意味著: - -- `list-documents` 列出設定的 `.md` 和 `.mdx` 檔案。 -- `get-document` 讀取選定的本機檔案。 -- `update-document` 寫入選定的本機檔案。 -- `create-document` 在所選資料夾中建立新的本機 `.mdx` 檔案。 -- `delete-document`刪除本機檔案。 -- 搜尋在設定的本機檔案中執行。 - -不能從內容 UI 中行動、重新命名和重新排序本機檔案頁面 -尚支援。在工作區或使用編碼代理執行這些操作; -內容側邊欄將反映產生的檔案樹。 - -這使代理合約變得簡單:代理可以繼續使用內容 actions, -而這些 actions 決定目標是 SQL 支援的還是檔案支援的。 - -隨著時間的推移,其他應用程式可以采用相同的模式。幻燈片應用程式可以對應 -`slides/*.mdx` 到甲板,計畫應用程式可以將 `plans/*` 對應到計畫檔案,以及 -儀表板應用程式可以將 `dashboards/*.mdx` 對應到儀表板。那些特定於應用程式的 -資料夾是位於同一本機工件合約之上的約定。 - -## 本機檔案與匯出/匯入 - -內容有兩種不同的檔案工作流程: - -| 工作流程 | 發生了什麼 | -| ------------------------ | ------------------------------------------------------------------------ | -| `/local-files` 匯出/匯入 | 資料庫模式仍然是事實來源。檔案是您匯出、編輯、預覽和匯入的顯式同步表面。 | -| 本機檔案模式 | 檔案是真相的來源。內容側邊欄和編輯器直接對本機檔案進行操作。 | - -當您需要偶爾檢視託管工作區的檔案時,請使用匯出/匯入。 -當儲存庫本身是工作區時,使用本機檔案模式。 - -## 歷史與合作 - -本機檔案模式依賴於檔案本機歷史紀錄: - -- 向 Git 提交重要更改 -- 使用 pull request 進行審核 -- 讓編碼代理直接編輯相同的檔案 -- 使用普通檔案差異來了解更改 - -資料庫模式仍然更適合託管協作功能,例如 -共用、評論、SQL 支援的版本歷史紀錄和即時多使用者編輯。 - -提供者同步可以分層在任一模式之上。例如,檔案儲存庫可以 -新增 actions,將內容從 CMS 擷取到本機 MDX 檔案或推送所選內容 -本機檔案返回到那個CMS。 - -## 安全正式環境 - -本機檔案模式為應用程式 actions 提供對設定的工作區的直接寫入存取權限 -檔案。這適合本機開發和可信單租戶檔案 -橋梁,但它不是預設的正式環境安全模型。 - -當 `NODE_ENV=production` 時,框架拒絕 `local-files` 模式,除非您 -設定: - -```bash -AGENT_NATIVE_ALLOW_LOCAL_FILES_IN_PRODUCTION=true -``` - -僅針對受信任的單租戶部署進行設定,其中每個人都可以使用 -應用程式可以讀取和寫入設定的檔案。對於普通託管, -多使用者應用程式,使用資料庫模式和 SQL 支援的共用。 +移轉時請刪除 `mode: "local-files"` 與 `AGENT_NATIVE_MODE=local-files`,保留 +roots 並加入 `source`。frontmatter ID 能在重新命名與移動時維持頁面身分。本機 +元件和擴充功能仍透過受信任的本機橋接載入。 diff --git a/packages/core/docs/content/locales/zh-TW/template-content.mdx b/packages/core/docs/content/locales/zh-TW/template-content.mdx index d2da028aff..aa12b7655c 100644 --- a/packages/core/docs/content/locales/zh-TW/template-content.mdx +++ b/packages/core/docs/content/locales/zh-TW/template-content.mdx @@ -89,9 +89,8 @@ description: "MDX 的開放原始碼 Obsidian:編輯本機 Markdown/MDX 檔案 - **在樹中組織頁面** — 無限嵌套、拖動重新排序、釘選您經常使用的頁面。 - **搜尋所有內容**,透過標題和內容進行全文搜尋。 - **像 Obsidian 一樣編輯本機 Markdown/MDX 檔案。**使用 `/local-files` 檢視 - 將工作區匯出到檔案,在您自己的工具中編輯它們,預覽 - 更改,並將其匯入回來。在本機檔案模式下,內容直接寫入 - 選定的 `.md` 或 `.mdx` 檔案。 + 將資料夾連接至 Files,使用自己的工具編輯並同步變更,然後在 SQL 應用程式 + 中審查衝突。 - **生成豐富的互動式自訂塊。**註冊本機 React 元件, 將它們插入為 MDX,並讓代理建立或更新元件檔案 您的檔案。 @@ -133,32 +132,18 @@ description: "MDX 的開放原始碼 Obsidian:編輯本機 Markdown/MDX 檔案 評論、權限和即時協作;本機資料夾是顯式的 同步表面。 -內容還可以在**本機檔案模式**下執行,其中檔案是內容的來源 -真相而不是 SQL 檔案。將 `agent-native.json` 新增到倉庫,設定 -`mode: "local-files"`,並設定`docs/`、`blog/`等根, -`content/` 和 `resources/`。然後標準內容編輯器填充其 -來自本機 `.md`/`.mdx` 檔案的左側邊欄,並將編輯寫回 -透過普通檔案actions選取檔案。將其用於回購優先檔案, -由 MDX 驅動的部落格、資源庫或黑曜石風格的個人內容 -元件;當您需要託管協作時切換回資料庫模式並且 -SQL 支援的共用。請參閱 [Local File Mode](/docs/local-file-mode) -獨立儲存庫布局、設定、自訂 MDX 元件、本機 -`extensions/`小工具、安全正式環境指南。 +本機資料夾現在與所有頁面使用相同的 SQL 模型。每個頁面屬於一個空間和它的 +Files 資料庫;資料夾是該資料庫的來源介面卡。策略、衝突、MDX 元件與舊模式 +移轉請參閱[本機資料夾來源](/docs/local-file-mode)。 -要將內容本機檔案技能安裝到現有儲存庫中: +要在既有儲存庫中開啟 Content: ```bash -npx @agent-native/core@latest skills add content --mode local-files --scope project +npx @agent-native/core@latest content . ``` -安裝程式會複製您的編碼代理的 `content` 技能並寫入或 -使用 `docs/`、`blog/`、`content/` 的內容根更新 `agent-native.json` -和`resources/`。當本機內容應用、Agent Native 桌面或受信任 -本機網橋正在執行,代理應使用內容actions,例如 -`list-documents`、`get-document`、`edit-document`、`update-document` 和 -`share-local-file-document` 而不是原始檔案系統寫入。沒有那個本機 -bridge,已安裝的技能仍然為代理提供了回購編輯合同 -安全 Markdown/MDX 編輯。 +CLI 會啟動一般的資料庫版 Content,註冊資料夾來源,並在不設定模式環境變數的 +情況下開啟連線畫面。 ## 對於開發者 diff --git a/packages/core/docs/content/template-content.mdx b/packages/core/docs/content/template-content.mdx index 23f7f42bbe..2c18710203 100644 --- a/packages/core/docs/content/template-content.mdx +++ b/packages/core/docs/content/template-content.mdx @@ -91,9 +91,8 @@ When you open the app, you'll see a page tree next to the editor. The agent alwa - **Organize pages in a tree** — nest infinitely, drag to reorder, favorite pages you use often. - **Search across everything** with full-text search across titles and content. - **Edit local Markdown/MDX files like Obsidian.** Use the `/local-files` view - to export your workspace to files, edit them in your own tools, preview - changes, and import them back. In Local File Mode, Content writes straight to - the selected `.md` or `.mdx` file. + to connect a folder to a space's Files database, edit with your own tools, + sync changes, and review conflicts without leaving the database-backed app. - **Generate rich interactive custom blocks.** Register local React components, insert them as MDX, and let the agent create or update component files for your docs. @@ -136,31 +135,23 @@ as the review surface. The hosted app remains the source of truth for sharing, comments, permissions, and live collaboration; the local folder is an explicit sync surface. -Content can also run in **Local File Mode**, where files are the source of -truth instead of SQL documents. Add `agent-native.json` to a repo, set -`mode: "local-files"`, and configure roots such as `docs/`, `blog/`, -`content/`, and `resources/`. The standard Content editor then populates its -left sidebar from those local `.md`/`.mdx` files and writes edits back to the -selected file through the normal document actions. Use this for repo-first docs, -blogs, resource libraries, or an Obsidian-style personal MDX-powered notes vault; switch back to -database mode when you want hosted collaboration and SQL-backed sharing. See [Local File Mode](/docs/local-file-mode) for the -standalone repo layout, configuration, custom MDX components, local -`extensions/` widgets, and production safety guide. +Local folders now use the same database model as every other page. Each page +belongs to a space and its system Files database; a folder is a source adapter +on that database. You can attach a folder to an existing space or create a +private folder-backed space, then use database properties and saved sidebar +views on the imported pages. See [Local folder sources](/docs/local-file-mode) +for configuration, truth policies, conflicts, custom MDX components, and local +`extensions/` widgets. -To install the Content local-files skill into an existing repo: +To open Content for an existing repo: ```bash -npx @agent-native/core@latest skills add content --mode local-files --scope project +npx @agent-native/core@latest content . ``` -The installer copies the `content` skill for your coding agent and writes or -updates `agent-native.json` with Content roots for `docs/`, `blog/`, `content/`, -and `resources/`. When a local Content app, Agent Native Desktop, or trusted -local bridge is running, agents should use Content actions such as -`list-documents`, `get-document`, `edit-document`, `update-document`, and -`share-local-file-document` instead of raw filesystem writes. Without that local -bridge, the installed skill still gives the agent the repo-editing contract for -safe Markdown/MDX edits. +The CLI starts normal database-backed Content, registers an opaque local-folder +connection in `agent-native.json`, and opens the connection screen. It does not +set a local-mode environment variable. ## For developers @@ -189,28 +180,21 @@ Open `http://localhost:8083` and create your first page. Then ask the agent to " **Search and comments.** Full-text search, anchored comments, version history, and restore flows are built into the document surface. -**Sync surfaces.** Documents can sync with Notion or local Markdown/MDX folders, with SQL acting as the collaborative cache/history layer. +**Sync surfaces.** Documents can sync with Notion or local Markdown/MDX folders, while SQL remains the canonical collaborative data model. ### Local file sync The protected `/local-files` route uses the browser File System Access API, or a -guarded native folder bridge inside Agent Native Desktop, to read and write -Markdown/MDX files from a user-chosen folder. After the folder is linked and -imported, the selected file is treated as the authority: opening the page reads -the file, and normal editor saves write the file first. SQL is then updated as a -cache/history layer for the existing document UI, search, and version panel, not -as the source of truth. The top-right page menu exposes the local source path: -relative path is always available, absolute path is available in true local-file -mode and Agent Native Desktop, and Reveal in Finder is available through the -desktop bridge or server-backed local-file mode. - -The bulk sync route calls: - -- `export-content-source` — reads the accessible document tree and returns a - deterministic `content/` file bundle. -- `import-content-source` — validates files, creates new private documents, - updates documents where the caller has editor access, preserves version - history, and rejects invalid parent cycles. +guarded native folder bridge inside Agent Native Desktop, to connect a folder to +a Files database. SQL stores only an opaque connection ID, relative paths, +hashes, and source metadata. Sync materializes ordinary database pages and +records concurrent edits as reviewable change sets instead of silently +overwriting either side. Disconnecting preserves both database pages and files. + +The folder workflow calls `connect-local-folder-source`, +`sync-local-folder-source`, `resolve-local-folder-conflict`, and +`disconnect-local-folder-source`. `export-content-source` can export one source +using its original relative paths. The source format lives in `shared/content-source.ts`. Keep that file as the single contract for filenames, frontmatter, parsing, and serialization. @@ -674,6 +658,6 @@ The agent can make all of these changes itself — ask it to "add a tags column - [**Templates**](/docs/cloneable-saas) — the clone-and-own model, and every other domain template to start from - [**Getting Started**](/docs/getting-started) — the framework fundamentals: actions, application state, and live sync -- [**Local File Mode**](/docs/local-file-mode) — the standalone repo layout, config, and production safety guide +- [**Local folder sources**](/docs/local-file-mode) — spaces, Files databases, folder adapters, migration, and safety - [**Real-Time Collaboration**](/docs/real-time-collaboration) — the Yjs pipeline documents share with other collaborative surfaces - [**Sharing**](/docs/sharing) — the share-grant model documents build on diff --git a/packages/core/src/cli/content-local.spec.ts b/packages/core/src/cli/content-local.spec.ts index dc5845d648..718d7b8a6b 100644 --- a/packages/core/src/cli/content-local.spec.ts +++ b/packages/core/src/cli/content-local.spec.ts @@ -29,6 +29,11 @@ afterEach(() => { }); describe("content local CLI", () => { + it("treats explicit help as a successful command", async () => { + const { runContentLocal } = await import("./content-local.js"); + await expect(runContentLocal(["--help"])).resolves.toBe(0); + }); + it("parses local file launch flags", () => { expect( parseContentLocalArgs([ @@ -48,7 +53,7 @@ describe("content local CLI", () => { }); }); - it("writes a local file mode manifest for a target folder", async () => { + it("writes a database-backed local folder source manifest", async () => { const root = tmpDir(); fs.mkdirSync(path.join(root, "docs"), { recursive: true }); @@ -63,16 +68,23 @@ describe("content local CLI", () => { ); expect(plan.rootPath).toBe("docs"); + expect(plan.env).not.toHaveProperty("AGENT_NATIVE_MODE"); + expect(plan.url).toContain("/local-files?connectionId=local-folder%3A"); expect(manifest.apps.content).toMatchObject({ - mode: "local-files", roots: [ { path: "docs", profile: "docs/no-bookkeeping", extensions: [".md", ".mdx"], + source: { + type: "local-folder", + connectionId: plan.connectionId, + truthPolicy: "source_primary", + }, }, ], }); + expect(manifest.apps.content).not.toHaveProperty("mode"); }); it("deep-links to a file without narrowing an existing broad root", async () => { @@ -101,12 +113,44 @@ describe("content local CLI", () => { expect(plan.filePath).toBe("docs/guide.mdx"); expect(plan.url).toBe( - `http://127.0.0.1:9091/page/local-file:${Buffer.from( - "docs/guide.mdx", - "utf8", - ).toString("base64url")}`, + `http://127.0.0.1:9091/local-files?connectionId=${encodeURIComponent( + plan.connectionId, + )}&file=docs%2Fguide.mdx`, ); expect(manifest.apps.content.roots[0].name).toBe("Docs"); expect(manifest.apps.content.roots[0]).not.toHaveProperty("include"); + expect(manifest.apps.content.roots[0].source).toMatchObject({ + type: "local-folder", + connectionId: plan.connectionId, + truthPolicy: "source_primary", + }); + expect(manifest.apps.content).not.toHaveProperty("mode"); + }); + + it("accumulates repeated file targets under a CLI-created root", async () => { + const root = tmpDir(); + fs.mkdirSync(path.join(root, "docs"), { recursive: true }); + fs.writeFileSync(path.join(root, "docs", "one.md"), "# One", "utf8"); + fs.writeFileSync(path.join(root, "docs", "two.md"), "# Two", "utf8"); + + await prepareContentLocalLaunch({ + cwd: root, + target: "docs/one.md", + dryRun: false, + }); + await prepareContentLocalLaunch({ + cwd: root, + target: "docs/two.md", + dryRun: false, + }); + + const manifest = JSON.parse( + fs.readFileSync(path.join(root, "agent-native.json"), "utf8"), + ); + expect(manifest.apps.content.roots).toHaveLength(1); + expect(manifest.apps.content.roots[0].include).toEqual([ + "docs/one.md", + "docs/two.md", + ]); }); }); diff --git a/packages/core/src/cli/content-local.ts b/packages/core/src/cli/content-local.ts index 10ea35995e..8362a55a63 100644 --- a/packages/core/src/cli/content-local.ts +++ b/packages/core/src/cli/content-local.ts @@ -1,4 +1,5 @@ import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; import fsSync from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; @@ -8,7 +9,7 @@ const CONTENT_APP_ID = "content"; const MANIFEST_FILE = "agent-native.json"; const DEFAULT_PORT = 8083; const DEFAULT_PROFILE = "docs/no-bookkeeping"; -const LOCAL_FILE_ID_PREFIX = "local-file:"; +const LOCAL_FOLDER_CONNECTION_PREFIX = "local-folder:"; export interface ContentLocalArgs { target?: string; @@ -25,6 +26,7 @@ export interface ContentLocalLaunchPlan { manifestPath: string; rootPath: string; filePath?: string; + connectionId: string; url: string; command: string; args: string[]; @@ -48,10 +50,12 @@ function titleFromPath(value: string) { ); } -function encodeLocalFileId(filePath: string) { - return `${LOCAL_FILE_ID_PREFIX}${Buffer.from(filePath, "utf8").toString( - "base64url", - )}`; +function localFolderConnectionId(absoluteRootPath: string) { + const digest = createHash("sha256") + .update(fsSync.realpathSync(absoluteRootPath)) + .digest("base64url") + .slice(0, 24); + return `${LOCAL_FOLDER_CONNECTION_PREFIX}${digest}`; } function isRecord(value: unknown): value is Record { @@ -134,6 +138,7 @@ function resolveTarget(target: string, cwd: string) { return { absoluteTarget, + absoluteRootPath: targetDirectory, isFile: stat.isFile(), workspaceRoot, rootPath: normalizeSlash(rootPath), @@ -162,7 +167,11 @@ async function readManifest( function upsertContentManifest( manifest: Record, rootPath: string, - options: { filePath?: string; profile?: string }, + options: { + connectionId: string; + filePath?: string; + profile?: string; + }, ) { const next: Record = { ...manifest, @@ -192,7 +201,26 @@ function upsertContentManifest( ? existingRoot.extensions : [".md", ".mdx"], ...(profile ? { profile } : {}), - ...(include && rootIndex === -1 ? { include } : {}), + ...(include && (rootIndex === -1 || Array.isArray(existingRoot?.include)) + ? { + include: Array.from( + new Set([ + ...(Array.isArray(existingRoot?.include) + ? existingRoot.include.filter( + (value): value is string => typeof value === "string", + ) + : []), + ...include, + ]), + ), + } + : {}), + source: { + ...(isRecord(existingRoot?.source) ? existingRoot.source : {}), + type: "local-folder", + connectionId: options.connectionId, + truthPolicy: "source_primary", + }, }; const roots = rootIndex === -1 @@ -201,9 +229,9 @@ function upsertContentManifest( index === rootIndex ? { ...entry, ...root } : entry, ); + delete existingContent.mode; apps[CONTENT_APP_ID] = { ...existingContent, - mode: "local-files", roots, hide: Array.isArray(existingContent.hide) ? existingContent.hide @@ -234,9 +262,10 @@ export function findContentTemplateDir(cwd = process.cwd()) { ); } -function launchUrl(port: number, filePath?: string) { - const base = `http://127.0.0.1:${port}`; - return filePath ? `${base}/page/${encodeLocalFileId(filePath)}` : base; +function launchUrl(port: number, connectionId: string, filePath?: string) { + const params = new URLSearchParams({ connectionId }); + if (filePath) params.set("file", filePath); + return `http://127.0.0.1:${port}/local-files?${params.toString()}`; } export async function prepareContentLocalLaunch(options: { @@ -249,10 +278,12 @@ export async function prepareContentLocalLaunch(options: { const cwd = path.resolve(options.cwd ?? process.cwd()); const target = resolveTarget(options.target, cwd); const manifestPath = path.join(target.workspaceRoot, MANIFEST_FILE); + const connectionId = localFolderConnectionId(target.absoluteRootPath); const manifest = upsertContentManifest( await readManifest(manifestPath), target.rootPath, { + connectionId, filePath: target.filePath, profile: options.profile, }, @@ -273,7 +304,8 @@ export async function prepareContentLocalLaunch(options: { manifestPath, rootPath: target.rootPath, filePath: target.filePath, - url: launchUrl(options.port ?? DEFAULT_PORT, target.filePath), + connectionId, + url: launchUrl(options.port ?? DEFAULT_PORT, connectionId, target.filePath), command: "corepack", args: [ "pnpm", @@ -287,7 +319,6 @@ export async function prepareContentLocalLaunch(options: { "--strictPort", ], env: { - AGENT_NATIVE_MODE: "local-files", AGENT_NATIVE_MANIFEST_PATH: manifestPath, }, manifest, @@ -316,7 +347,7 @@ function printHelp() { agent-native content [options] Options: - --profile Local file profile, for example docs/no-bookkeeping + --profile Local folder profile, for example docs/no-bookkeeping --port Dev server port (default ${DEFAULT_PORT}) --open Open the target in the browser (default) --no-open Start the server without opening the browser @@ -326,9 +357,13 @@ Options: export async function runContentLocal(argv: string[]) { const parsed = parseContentLocalArgs(argv); - if (parsed.help || !parsed.target) { + if (parsed.help) { + printHelp(); + return 0; + } + if (!parsed.target) { printHelp(); - return parsed.target ? 0 : 1; + return 1; } const plan = await prepareContentLocalLaunch({ @@ -343,7 +378,7 @@ export async function runContentLocal(argv: string[]) { return 0; } - console.log(`Content local files: ${plan.rootPath}`); + console.log(`Content folder source: ${plan.rootPath}`); console.log(`Manifest: ${plan.manifestPath}`); console.log(`Opening: ${plan.url}`); diff --git a/packages/core/src/cli/index.ts b/packages/core/src/cli/index.ts index 5ead000f44..ccd25461c9 100644 --- a/packages/core/src/cli/index.ts +++ b/packages/core/src/cli/index.ts @@ -1098,8 +1098,8 @@ Usage: --with-github-action also writes the PR Visual Recap workflow into .github/workflows/. agent-native content local-files - Launch Content in local-file mode for a local - docs/content folder. Use --no-open, --port N, + Connect a local docs/content folder to normal + database-backed Content. Use --no-open, --port N, or --profile docs/no-bookkeeping as needed. agent-native design connect Start a localhost Design bridge for a running dev server. Use --url, --port, --root, or diff --git a/packages/core/src/local-artifacts/index.spec.ts b/packages/core/src/local-artifacts/index.spec.ts index 9fe06e7e2b..c3230fe9e1 100644 --- a/packages/core/src/local-artifacts/index.spec.ts +++ b/packages/core/src/local-artifacts/index.spec.ts @@ -10,9 +10,11 @@ import { deleteLocalWorkspaceResource, findAgentNativeManifest, getLocalArtifactApp, + listConfiguredLocalArtifactFiles, listLocalWorkspaceResources, listLocalArtifactFiles, readLocalArtifactFile, + readConfiguredLocalArtifactFile, readLocalWorkspaceResource, resolveAgentNativeDataMode, writeLocalArtifactFile, @@ -169,6 +171,51 @@ describe("local artifact helpers", () => { expect(files).toEqual([]); }); + it("reads a declared local-folder source without changing the app data mode", async () => { + const root = tmpDir(); + const manifestPath = path.join(root, "agent-native.json"); + writeJson(manifestPath, { + apps: { + content: { + roots: [ + { + path: "docs", + extensions: [".md"], + source: { + type: "local-folder", + connectionId: "local-folder:opaque", + truthPolicy: "source_primary", + }, + }, + ], + }, + }, + }); + fs.mkdirSync(path.join(root, "docs"), { recursive: true }); + fs.writeFileSync(path.join(root, "docs", "intro.md"), "# Intro", "utf8"); + + const app = await getLocalArtifactApp({ appId: "content", manifestPath }); + expect(app.mode).toBe("database"); + expect(app.roots[0]?.source).toEqual({ + type: "local-folder", + connectionId: "local-folder:opaque", + truthPolicy: "source_primary", + }); + await expect( + listLocalArtifactFiles({ appId: "content", manifestPath }), + ).resolves.toEqual([]); + await expect( + listConfiguredLocalArtifactFiles({ appId: "content", manifestPath }), + ).resolves.toEqual([expect.objectContaining({ path: "docs/intro.md" })]); + await expect( + readConfiguredLocalArtifactFile({ + appId: "content", + manifestPath, + path: "docs/intro.md", + }), + ).resolves.toEqual(expect.objectContaining({ content: "# Intro" })); + }); + it("loads configured local component and extension roots", async () => { const root = tmpDir(); const manifestPath = path.join(root, "agent-native.json"); diff --git a/packages/core/src/local-artifacts/index.ts b/packages/core/src/local-artifacts/index.ts index 11757c300f..f77f4701b9 100644 --- a/packages/core/src/local-artifacts/index.ts +++ b/packages/core/src/local-artifacts/index.ts @@ -16,6 +16,14 @@ export interface AgentNativeManifestRoot { extensions?: string[]; include?: string[]; hide?: string[]; + source?: { + type: "local-folder"; + connectionId: string; + truthPolicy?: + | "database_primary" + | "source_primary" + | "reviewed_bidirectional"; + }; } export interface AgentNativeManifestApp { @@ -73,6 +81,7 @@ export interface LoadedLocalArtifactRoot { extensions: string[]; hide: string[]; include: string[]; + source?: AgentNativeManifestRoot["source"]; } export interface LoadedLocalArtifactApp { @@ -267,6 +276,26 @@ function rootNameFromPath(rootPath: string): string { function normalizeManifestRoot(value: unknown): AgentNativeManifestRoot | null { if (typeof value === "string") return { path: value }; if (!isRecord(value) || typeof value.path !== "string") return null; + const truthPolicy: + | NonNullable["truthPolicy"] + | undefined = + value.source && + isRecord(value.source) && + (value.source.truthPolicy === "database_primary" || + value.source.truthPolicy === "source_primary" || + value.source.truthPolicy === "reviewed_bidirectional") + ? value.source.truthPolicy + : undefined; + const source = + isRecord(value.source) && + value.source.type === "local-folder" && + typeof value.source.connectionId === "string" + ? { + type: "local-folder" as const, + connectionId: value.source.connectionId, + ...(truthPolicy ? { truthPolicy } : {}), + } + : undefined; return { name: typeof value.name === "string" ? value.name : undefined, path: value.path, @@ -275,6 +304,7 @@ function normalizeManifestRoot(value: unknown): AgentNativeManifestRoot | null { extensions: normalizeExtensions(value.extensions), include: asStringArray(value.include), hide: asStringArray(value.hide), + ...(source ? { source } : {}), }; } @@ -483,6 +513,7 @@ export async function getLocalArtifactApp( extensions, hide: [...DEFAULT_HIDE_PATTERNS, ...asStringArray(root.hide)], include: asStringArray(root.include), + source: root.source, }; }); @@ -683,6 +714,16 @@ export async function listLocalArtifactFiles( return files.sort((a, b) => a.path.localeCompare(b.path)); } +export async function listConfiguredLocalArtifactFiles( + options: LocalArtifactOptions, +): Promise { + const app = await getLocalArtifactApp(options); + const files = (await Promise.all(app.roots.map((root) => walkRoot(root)))) + .flat() + .filter((file) => !matchesPatterns(file.path, app.hide)); + return files.sort((a, b) => a.path.localeCompare(b.path)); +} + function rootForArtifactPath( app: LoadedLocalArtifactApp, artifactPath: string, @@ -758,6 +799,30 @@ export async function readLocalArtifactFile( } } +export async function readConfiguredLocalArtifactFile( + options: LocalArtifactOptions & { path: string }, +): Promise { + const app = await getLocalArtifactApp(options); + const { root, safePath, absolutePath } = await resolveArtifactPath( + app, + options.path, + ); + try { + const { content, stat } = readTextFileWithoutSymlink(root, absolutePath); + const meta = await fileMetaForPath( + root, + safePath, + absolutePath, + content, + stat, + ); + return { ...meta, content }; + } catch (error) { + if (errorCode(error) === "ENOENT") return null; + throw error; + } +} + export async function writeLocalArtifactFile( options: WriteLocalArtifactFileOptions & { path: string }, ): Promise { diff --git a/templates/content/.agents/skills/content/SKILL.md b/templates/content/.agents/skills/content/SKILL.md index 9c78bd99c5..2dc90ed6ac 100644 --- a/templates/content/.agents/skills/content/SKILL.md +++ b/templates/content/.agents/skills/content/SKILL.md @@ -3,7 +3,7 @@ name: content description: >- Use Content for repo-backed Markdown/MDX docs, blogs, resources, rich document editing, Notion-style databases and boards, structured intake - forms, local components, shareable copies, and Content local-file workspaces. + forms, local components, shareable copies, and connected local folders. Prefer Content actions over raw filesystem writes when available. metadata: visibility: exported @@ -17,7 +17,7 @@ marketing pages, internal notes, and local MDX components. Also use Content for Notion-style databases, tables, boards, structured request intake, and forms whose submissions become database row pages. Content gives the agent a document tree, a rich editor, structured database properties and views, normal document -actions, and an optional local-file source of truth. +actions, and local folders that sync into the same database model. ## Choose The Path @@ -33,9 +33,9 @@ actions, and an optional local-file source of truth. its exact title, then inspect it with `get-content-database` before creating or submitting anything. Do not create a second database when the canonical one already exists. -- In Local File Mode, Content actions read and write the repo files declared in - `agent-native.json`; SQL remains cache/history/search glue, not the source of - truth for those pages. +- Local folders are sources attached to a space's canonical Files database. + Imported pages are normal SQL-backed Content documents; the trusted local + bridge handles pull, export, stable file identity, and conflict review. - If Content tools are not visible and no local Content app or Desktop bridge is running, treat this skill as repo-editing guidance. Edit configured `.md`/`.mdx` files directly, preserve frontmatter and MDX imports, and tell @@ -47,10 +47,10 @@ Prefer JSON input for action calls: ```bash pnpm action list-documents -pnpm action get-document '{"id":"local-file:..."}' -pnpm action edit-document '{"id":"local-file:...","find":"old copy","replace":"new copy"}' -pnpm action update-document '{"id":"local-file:...","content":"# Updated\n\nBody"}' -pnpm action share-local-file-document '{"id":"local-file:..."}' +pnpm action get-document '{"id":""}' +pnpm action edit-document '{"id":"","find":"old copy","replace":"new copy"}' +pnpm action update-document '{"id":"","content":"# Updated\n\nBody"}' +pnpm action connect-local-folder-source '{"connectionId":"","label":"Docs","createSourceBackedSpace":true,"truthPolicy":"source_primary"}' ``` Run `refresh-list` after create/update/delete operations when you need the open @@ -132,7 +132,7 @@ guesswork: writing. If a named person cannot be resolved unambiguously, clarify in the originating Slack thread; never omit, downgrade, or silently drop the person. -## Local File Mode +## Local Folder Sources Install into an existing repo with: @@ -140,40 +140,27 @@ Install into an existing repo with: npx @agent-native/core@latest skills add content --mode local-files --scope project ``` -The installer copies this skill and writes or updates `agent-native.json` with -Content roots for `docs/`, `blog/`, `content/`, and `resources/`, plus a -`components/` folder for local MDX components. A typical manifest looks like: +The compatibility spelling still installs the skill, but it no longer enables +a separate data mode. The CLI writes or updates `agent-native.json` with +declarative local-folder sources and launches normal database-backed Content. +A typical root looks like: ```json { "version": 1, "apps": { "content": { - "mode": "local-files", "roots": [ { "name": "Docs", "path": "docs", "kind": "docs", - "extensions": [".md", ".mdx"] - }, - { - "name": "Blog", - "path": "blog", - "kind": "blog", - "extensions": [".md", ".mdx"] - }, - { - "name": "Content", - "path": "content", - "kind": "content", - "extensions": [".md", ".mdx"] - }, - { - "name": "Resources", - "path": "resources", - "kind": "resources", - "extensions": [".md", ".mdx"] + "extensions": [".md", ".mdx"], + "source": { + "type": "local-folder", + "connectionId": "local-folder:", + "truthPolicy": "source_primary" + } } ], "components": "components", @@ -184,9 +171,10 @@ Content roots for `docs/`, `blog/`, `content/`, and `resources/`, plus a } ``` -Local File Mode does not make the host language model local, and the hosted -Content app cannot read private repo files by itself. File access requires a -local Content app, Agent Native Desktop, or another trusted local bridge. +Content never stores an absolute local path or raw file body in source metadata. +File access still requires a local Content app, Agent Native Desktop, or another +trusted bridge. Disconnecting a folder leaves the SQL pages and disk files in +place. Concurrent edits and missing source files require explicit review. ## MDX And Components @@ -202,9 +190,8 @@ local Content app, Agent Native Desktop, or another trusted local bridge. ## Boundaries -- Moving, renaming, and reordering local-file pages are not first-class Content - UI operations yet. Use normal file operations when the user asks for those, - then let Content rediscover the file tree. +- Preserve a page's frontmatter `id` when renaming a source file so the next + sync recognizes it as the same global Content page. - Do not push/pull Notion, Builder.io, or other provider-backed content unless the user explicitly asks for provider sync. - Do not paste secrets, private provider data, or credential-looking values into diff --git a/templates/content/AGENTS.md b/templates/content/AGENTS.md index cd35db190c..ab65af6b8d 100644 --- a/templates/content/AGENTS.md +++ b/templates/content/AGENTS.md @@ -96,6 +96,11 @@ cd templates/content && pnpm action [args] | `list-documents` | `[--format json]` | List document metadata/tree; no full bodies | | `export-content-source` | `[--format json]` | Export editable docs as `content/*.mdx` source files | | `import-content-source` | `--files [--dryRun true\|false]` | Import `.md`/`.mdx` source files into editable docs | +| `connect-local-folder-source` | `--connectionId --label [--spaceId \|--databaseId \|--createSourceBackedSpace true] [--truthPolicy database_primary\|source_primary\|reviewed_bidirectional]` | Connect a trusted browser/Desktop folder registry entry to canonical Files without storing its path or handle in SQL | +| `sync-local-folder-source` | `--sourceId --files [--dryRun true\|false]` | Materialize a connected folder through Files source rows and record concurrent changes for review | +| `sync-manifest-local-folder-source` | `--connectionId [--file ] [--spaceId ] [--dryRun true\|false]` | Use the trusted local server bridge to materialize a manifest-declared folder into normal SQL-backed Content | +| `resolve-local-folder-conflict` | `--changeSetId --decision keep_content\|accept_source [--sourceContent ]` | Resolve a reviewed folder conflict; accepting requires the exact hashed folder revision | +| `disconnect-local-folder-source` | `--sourceId ` | Disconnect a folder adapter without deleting local files or its SQL-backed Content pages | | `list-builder-docs` | `[--model docs-content\|blog-article] [--limit ]` | List Builder docs/blog entries available for `.builder.mdx` pull | | `pull-builder-doc` | `--model --entryId [--dryRun true\|false]` | Pull one Builder entry into Content and return `.builder.mdx` plus `content/builder/.raw` sidecar files | | `check-builder-doc` | `--files [--path ]` or `--documentId ` | Validate Builder MDX round-trip, sidecar hashes, and remote conflict status before push | @@ -112,7 +117,10 @@ cd templates/content && pnpm action [args] | `remove-local-file-source` | `[--sourceRootPath ]` | Unlink local-file sources from Content without deleting local Markdown/MDX files | | `list-local-component-files` | | List registered local MDX component source files | | `write-local-component-file` | `--workspaceId --path --content ` | Create or update a file in a registered local `components/` folder | -| `create-content-database` | `[--documentId ] [--parentId ] [--title ] [--description ]` | Create a described database page or convert an existing page into a database | +| `ensure-content-spaces` | | Idempotently provision the signed-in user's personal and current organization Content spaces, system databases, and catalog references | +| `backfill-content-files` | | Assign legacy pages to Content spaces and reconcile exactly one canonical Files database membership per non-system page | +| `list-content-spaces` | | List already-provisioned Content spaces authorized by the signed-in user's current ownership or organization memberships | +| `create-content-database` | `[--documentId ] [--spaceId ] [--parentId ] [--title ] [--description ]` | Create a described database page in a Content space or convert an existing page into a database | | `create-inline-content-database` | `--hostDocumentId [--title ] [--description ]` | Create a described database owned by an inline database block in the host document | | `get-content-database` | `--databaseId ` or `--documentId ` | Get a database with its description, property/option schema guidance, item pages, and computed ancestry context | | `list-trashed-content-databases` | | List soft-deleted databases visible in the sidebar Trash surface | @@ -193,37 +201,24 @@ strip of the markdown. ### Local Source Files -Content has two file workflows: +Content has one database-backed document model with optional folder sources: -- **Database mode local folders:** the `/local-files` view links one or more +- **Local folder sources:** the `/local-files` view links one or more browser or Agent Native Desktop folders to SQL-backed documents. The UI uses - folder rows: **Pull** reads local `.md`/`.mdx` files through - `import-content-source`, **Check** runs the same import as `--dryRun true`, - and **Push** uses `export-content-source` to write Content documents back to - the chosen folder. Files with known `id` values update existing docs only - when the caller has editor access, and files without ids create new private - docs for the current user. Imported rows keep `source.mode: "local-files"` - and `source.path`, so `list-documents`/`get-document` can distinguish them - from ordinary private pages and the sidebar can show them under Local files. - With one linked folder, imported paths stay flat (`content/page.mdx`); with - multiple linked folders, paths are prefixed by folder name so Local files can - group them by folder. Once a source folder is linked, the selected - `.md`/`.mdx` file is authoritative: opening the page reads the file, editor - saves write the file first, and SQL is updated afterward as - cache/history/search glue. Use `--dryRun true` before a large import when the - source folder may contain unexpected files. -- **Local File Mode editing:** when the app runs with `AGENT_NATIVE_MODE=local-files` - or an `agent-native.json` whose app config enables local files, the standard - Content editor reads and writes configured repo files directly. The left - sidebar is populated from local roots such as `docs/`, `blog/`, `content/`, - and `resources/`; selecting a file opens `/page/` and - `update-document` writes the selected `.md`/`.mdx` file. The document id is - derived from the file path, and unknown frontmatter is preserved when title, - content, icon, or favorite state changes. - Repos can opt into `profile: "docs/no-bookkeeping"` to keep docs edits from - adding absent `updatedAt`, `icon`, or `isFavorite` frontmatter. Content-only - edits under that profile preserve existing frontmatter but do not create new - bookkeeping keys; explicit title/icon/favorite edits still persist. + folder rows: **Pull** calls `sync-local-folder-source`, **Check** runs the + same action with `dryRun: true`, and **Push** uses the source-scoped + `export-content-source`. Imported files become ordinary SQL documents in the + target space's canonical Files database. Preserve frontmatter `id` across + renames; missing files and concurrent changes become reviewable incoming + change sets instead of silently deleting or overwriting a page. SQL stores + only opaque connection identity, relative paths, and hashes. Disconnecting a + folder keeps both the Content pages and disk files. +- **Manifest/CLI bridge:** `agent-native content local-files ` remains + as a compatibility spelling, but it launches normal database-backed Content. + `agent-native.json` declares `source.type: "local-folder"` and an opaque + `connectionId`; it does not select a separate application mode. The trusted + local server bridge materializes that source through + `sync-manifest-local-folder-source`. - **Local MDX components:** local file workspaces can expose React components from the configured `components` folder. Export PascalCase components such as `ImpactCounter` from `.tsx` files, then use `` in MDX or pick diff --git a/templates/content/actions/_builder-docs-client.ts b/templates/content/actions/_builder-docs-client.ts index 71a668f3c0..c06f6cdbc5 100644 --- a/templates/content/actions/_builder-docs-client.ts +++ b/templates/content/actions/_builder-docs-client.ts @@ -36,6 +36,12 @@ import { type BuilderCmsSourceEntry, } from "./_builder-cms-source-adapter.js"; import { executeBuilderCmsWrite } from "./_builder-cms-write-client.js"; +import { ensureDocumentFilesMembership } from "./_content-files.js"; +import { + organizationContentSpaceId, + personalContentSpaceId, + provisionContentSpaces, +} from "./_content-spaces.js"; import { flushOpenDocumentEditorToSql } from "./_document-flush.js"; type FetchLike = typeof fetch; @@ -524,6 +530,17 @@ export async function pullBuilderDocIntoContent(args: { if (!ownerEmail) throw new Error("no authenticated user"); const orgId = getRequestOrgId() ?? null; const db = getDb(); + const provisioned = args.dryRun + ? null + : await provisionContentSpaces(db, ownerEmail); + const targetSpaceId = orgId + ? organizationContentSpaceId(orgId) + : personalContentSpaceId(ownerEmail); + if (provisioned && !provisioned.spaceIds.includes(targetSpaceId)) { + throw new Error( + "The active organization does not have a writable Content space.", + ); + } const entry = await readFullBuilderDocsEntry({ model: args.model, entryId: args.entryId, @@ -554,6 +571,9 @@ export async function pullBuilderDocIntoContent(args: { `Requires editor access to update document "${documentId}".`, ); } + if (existing?.spaceId && existing.spaceId !== targetSpaceId) { + throw new Error("Builder document belongs to a different Content space."); + } const sourceFields = builderDocumentSourceFields(bundle, now); @@ -562,6 +582,7 @@ export async function pullBuilderDocIntoContent(args: { await db .update(schema.documents) .set({ + spaceId: existing.spaceId ?? targetSpaceId, title: bundle.mdx.title, content: bundle.mdx.body, updatedAt: now, @@ -571,6 +592,7 @@ export async function pullBuilderDocIntoContent(args: { } else { await db.insert(schema.documents).values({ id: documentId, + spaceId: targetSpaceId, ownerEmail, orgId, parentId: null, @@ -586,6 +608,7 @@ export async function pullBuilderDocIntoContent(args: { ...sourceFields, }); } + await ensureDocumentFilesMembership(db, documentId, now); await replaceBuilderDocumentSidecars({ db, documentId, diff --git a/templates/content/actions/_content-database-source-adapters.test.ts b/templates/content/actions/_content-database-source-adapters.test.ts index 4e4d2e267f..a9566336db 100644 --- a/templates/content/actions/_content-database-source-adapters.test.ts +++ b/templates/content/actions/_content-database-source-adapters.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import { getContentDatabaseSourceAdapter } from "./_content-database-source-adapters.js"; describe("content database source adapter registry", () => { - it("registers Builder, local-table, and read-only Notion adapters", () => { + it("registers remote, local-table, and bridge-mediated adapters", () => { expect(getContentDatabaseSourceAdapter("builder-cms")?.sourceType).toBe( "builder-cms", ); @@ -13,6 +13,9 @@ describe("content database source adapter registry", () => { expect(getContentDatabaseSourceAdapter("notion-database")?.sourceType).toBe( "notion-database", ); + expect(getContentDatabaseSourceAdapter("local-folder")?.sourceType).toBe( + "local-folder", + ); expect(getContentDatabaseSourceAdapter("mock-local")).toBeNull(); }); }); diff --git a/templates/content/actions/_content-database-source-adapters.ts b/templates/content/actions/_content-database-source-adapters.ts index 2740ae3ec1..d803f89e41 100644 --- a/templates/content/actions/_content-database-source-adapters.ts +++ b/templates/content/actions/_content-database-source-adapters.ts @@ -68,13 +68,31 @@ const notionDatabaseAdapter: ContentDatabaseSourceAdapter = { read: readNotionDatabaseSource, }; +const localFolderAdapter: ContentDatabaseSourceAdapter = { + sourceType: "local-folder", + async read() { + return { + state: "error", + entries: [], + fields: [], + fetchedAt: new Date().toISOString(), + message: + "Local folder refresh requires the trusted browser or Desktop bridge.", + metadata: { bridgeRequired: true }, + }; + }, +}; + const adapters = new Map< ContentDatabaseSourceType, ContentDatabaseSourceAdapter >( - [builderCmsAdapter, localTableAdapter, notionDatabaseAdapter].map( - (adapter) => [adapter.sourceType, adapter], - ), + [ + builderCmsAdapter, + localTableAdapter, + notionDatabaseAdapter, + localFolderAdapter, + ].map((adapter) => [adapter.sourceType, adapter]), ); export function getContentDatabaseSourceAdapter( diff --git a/templates/content/actions/_content-files.ts b/templates/content/actions/_content-files.ts new file mode 100644 index 0000000000..601ac87534 --- /dev/null +++ b/templates/content/actions/_content-files.ts @@ -0,0 +1,320 @@ +import { createHash } from "node:crypto"; + +import { accessFilter } from "@agent-native/core/sharing"; +import { and, eq, inArray, isNull, or, sql } from "drizzle-orm"; + +import { schema } from "../server/db/index.js"; +import { + listContentOrganizationMemberships, + normalizeContentSpaceEmail, +} from "./_content-space-access.js"; +import { + organizationContentSpaceId, + personalContentSpaceId, +} from "./_content-spaces.js"; + +type Db = any; + +export type ContentFilesReconciliation = { + assignedSpaces: number; + insertedMemberships: number; + removedMemberships: number; + documents: number; +}; + +export function contentFilesItemId(databaseId: string, documentId: string) { + return `content_database_item_${createHash("sha256") + .update(`${databaseId}:${documentId}`) + .digest("hex") + .slice(0, 32)}`; +} + +async function remapItemReferences(db: Db, replacements: Map) { + if (!replacements.size) return; + const duplicateIds = [...replacements.keys()]; + const remap = (column: any) => { + let expression = sql`CASE ${column}`; + for (const [duplicateId, canonicalId] of replacements) + expression = sql`${expression} WHEN ${duplicateId} THEN ${canonicalId}`; + return sql`${expression} ELSE ${column} END`; + }; + for (const table of [ + schema.contentSpaceCatalogItems, + schema.contentDatabaseBodyHydrationQueue, + schema.contentDatabaseSourceRows, + schema.contentDatabaseSourceChangeSets, + ]) { + await db + .update(table) + .set({ databaseItemId: remap(table.databaseItemId) }) + .where(inArray(table.databaseItemId, duplicateIds)); + } +} + +async function reconcileDocuments(args: { + db: Db; + documents: Array; + filesDatabases: Array; + now: string; +}) { + const filesBySpace = new Map( + args.filesDatabases.map((database) => [database.spaceId, database]), + ); + const filesDatabaseIds = args.filesDatabases.map((database) => database.id); + const filesBackingDocumentIds = new Set( + args.filesDatabases.map((database) => database.documentId), + ); + const existingItems = filesDatabaseIds.length + ? await args.db + .select() + .from(schema.contentDatabaseItems) + .where( + inArray(schema.contentDatabaseItems.databaseId, filesDatabaseIds), + ) + : []; + const itemsByDocument = new Map< + string, + Array + >(); + const nextPosition = new Map(); + for (const item of existingItems) { + const documentItems = itemsByDocument.get(item.documentId) ?? []; + documentItems.push(item); + itemsByDocument.set(item.documentId, documentItems); + nextPosition.set( + item.databaseId, + Math.max(nextPosition.get(item.databaseId) ?? 0, item.position + 1), + ); + } + + const deleteIds = new Set(); + const replacements = new Map(); + const inserts: Array = []; + for (const document of args.documents) { + const existing = itemsByDocument.get(document.id) ?? []; + if (filesBackingDocumentIds.has(document.id)) { + for (const item of existing) deleteIds.add(item.id); + continue; + } + const canonicalDatabase = filesBySpace.get(document.spaceId); + if (!canonicalDatabase) { + throw new Error( + `Content space for document "${document.id}" has no Files database`, + ); + } + const canonicalItems = existing + .filter((item) => item.databaseId === canonicalDatabase.id) + .sort((left, right) => left.id.localeCompare(right.id)); + const canonicalItemId = + canonicalItems[0]?.id ?? + contentFilesItemId(canonicalDatabase.id, document.id); + for (const item of existing) { + if (item.databaseId !== canonicalDatabase.id) { + deleteIds.add(item.id); + replacements.set(item.id, canonicalItemId); + } + } + for (const duplicate of canonicalItems.slice(1)) { + deleteIds.add(duplicate.id); + replacements.set(duplicate.id, canonicalItemId); + } + if (canonicalItems.length === 0) { + const position = nextPosition.get(canonicalDatabase.id) ?? 0; + nextPosition.set(canonicalDatabase.id, position + 1); + inserts.push({ + id: contentFilesItemId(canonicalDatabase.id, document.id), + ownerEmail: document.ownerEmail, + orgId: document.orgId, + databaseId: canonicalDatabase.id, + documentId: document.id, + position, + createdAt: args.now, + updatedAt: args.now, + }); + } + } + await remapItemReferences(args.db, replacements); + if (deleteIds.size) { + await args.db + .delete(schema.contentDatabaseItems) + .where(inArray(schema.contentDatabaseItems.id, [...deleteIds])); + } + if (inserts.length) { + await args.db + .insert(schema.contentDatabaseItems) + .values(inserts) + .onConflictDoNothing(); + } + return { inserted: inserts.length, removed: deleteIds.size }; +} + +export async function ensureDocumentFilesMembership( + db: Db, + documentId: string, + now = new Date().toISOString(), +) { + const [document] = await db + .select() + .from(schema.documents) + .where( + and( + eq(schema.documents.id, documentId), + accessFilter(schema.documents, schema.documentShares), + ), + ); + if (!document) throw new Error(`Document "${documentId}" not found`); + if (!document.spaceId) + throw new Error(`Document "${documentId}" does not have a Content space`); + const filesDatabases = await db + .select() + .from(schema.contentDatabases) + .where(eq(schema.contentDatabases.systemRole, "files")); + return reconcileDocuments({ db, documents: [document], filesDatabases, now }); +} + +export async function ensureDocumentsFilesMembership( + db: Db, + documentIds: string[], + now = new Date().toISOString(), + ownerEmail?: string, +) { + const ids = [...new Set(documentIds)]; + if (ids.length === 0) return { inserted: 0, removed: 0 }; + const documents = await db + .select() + .from(schema.documents) + .where( + and( + inArray(schema.documents.id, ids), + ownerEmail + ? or( + eq(schema.documents.ownerEmail, ownerEmail), + accessFilter(schema.documents, schema.documentShares), + ) + : accessFilter(schema.documents, schema.documentShares), + ), + ); + if (documents.length !== ids.length) { + const found = new Set(documents.map((document: any) => document.id)); + const missing = ids.filter((id) => !found.has(id)); + throw new Error(`Documents not found: ${missing.join(", ")}`); + } + for (const document of documents) { + if (!document.spaceId) { + throw new Error( + `Document "${document.id}" does not have a Content space`, + ); + } + } + const spaceIds: string[] = Array.from( + new Set(documents.map((document: any) => String(document.spaceId))), + ); + const filesDatabases = await db + .select() + .from(schema.contentDatabases) + .where( + and( + eq(schema.contentDatabases.systemRole, "files"), + or( + ...spaceIds.map((spaceId) => + eq(schema.contentDatabases.spaceId, spaceId), + ), + ), + ), + ); + return reconcileDocuments({ db, documents, filesDatabases, now }); +} + +export async function reconcileContentFilesMemberships( + db: Db, + userEmail: string, +): Promise { + const email = normalizeContentSpaceEmail(userEmail); + const memberships = await listContentOrganizationMemberships(email); + const personalSpaceId = personalContentSpaceId(email); + const orgSpaceIds = new Map( + memberships.map((membership) => [ + membership.orgId, + organizationContentSpaceId(membership.orgId), + ]), + ); + const accessibleSpaceIds = [personalSpaceId, ...orgSpaceIds.values()]; + const now = new Date().toISOString(); + const result: ContentFilesReconciliation = { + assignedSpaces: 0, + insertedMemberships: 0, + removedMemberships: 0, + documents: 0, + }; + + await db.transaction(async (tx: Db) => { + const personalLegacyDocuments = await tx + .select({ id: schema.documents.id }) + .from(schema.documents) + .where( + and( + eq(schema.documents.ownerEmail, email), + isNull(schema.documents.orgId), + isNull(schema.documents.spaceId), + ), + ); + if (personalLegacyDocuments.length) { + await tx + .update(schema.documents) + .set({ spaceId: personalSpaceId, updatedAt: now }) + .where( + inArray( + schema.documents.id, + personalLegacyDocuments.map((row: any) => row.id), + ), + ); + result.assignedSpaces += personalLegacyDocuments.length; + } + for (const [orgId, spaceId] of orgSpaceIds) { + const legacyDocuments = await tx + .select({ id: schema.documents.id }) + .from(schema.documents) + .where( + and( + eq(schema.documents.orgId, orgId), + isNull(schema.documents.spaceId), + ), + ); + if (!legacyDocuments.length) continue; + await tx + .update(schema.documents) + .set({ spaceId, updatedAt: now }) + .where( + inArray( + schema.documents.id, + legacyDocuments.map((row: any) => row.id), + ), + ); + result.assignedSpaces += legacyDocuments.length; + } + + const documents = await tx + .select() + .from(schema.documents) + .where(inArray(schema.documents.spaceId, accessibleSpaceIds)); + const filesDatabases = await tx + .select() + .from(schema.contentDatabases) + .where( + and( + eq(schema.contentDatabases.systemRole, "files"), + inArray(schema.contentDatabases.spaceId, accessibleSpaceIds), + ), + ); + const membershipResult = await reconcileDocuments({ + db: tx, + documents, + filesDatabases, + now, + }); + result.documents = documents.length; + result.insertedMemberships = membershipResult.inserted; + result.removedMemberships = membershipResult.removed; + }); + return result; +} diff --git a/templates/content/actions/_content-space-access.ts b/templates/content/actions/_content-space-access.ts new file mode 100644 index 0000000000..0b113fcb65 --- /dev/null +++ b/templates/content/actions/_content-space-access.ts @@ -0,0 +1,135 @@ +import { getDbExec } from "@agent-native/core/db"; +import { getRequestUserEmail } from "@agent-native/core/server/request-context"; +import { eq } from "drizzle-orm"; + +import { getDb, schema } from "../server/db/index.js"; + +export type ContentSpaceRole = "viewer" | "editor" | "owner"; + +export type ContentSpaceAccess = { + space: typeof schema.contentSpaces.$inferSelect; + authority: { userEmail: string; orgId: string | null }; + role: ContentSpaceRole; +}; + +export function normalizeContentSpaceEmail(email: string): string { + const normalized = email.trim().toLowerCase(); + if (!normalized) throw new Error("no authenticated user"); + return normalized; +} + +export async function getContentOrganizationMembership( + orgId: string, + userEmail: string, +): Promise<{ role: string; name: string; createdBy: string } | null> { + const result = await getDbExec().execute({ + sql: `SELECT m.role AS role, o.name AS name, o.created_by AS "createdBy" + FROM org_members m + INNER JOIN organizations o ON o.id = m.org_id + WHERE m.org_id = ? AND LOWER(m.email) = ? + LIMIT 1`, + args: [orgId, normalizeContentSpaceEmail(userEmail)], + }); + const row = result.rows[0] as + | { role?: unknown; name?: unknown; createdBy?: unknown } + | undefined; + if ( + !row || + typeof row.name !== "string" || + typeof row.createdBy !== "string" + ) { + return null; + } + return { + role: String(row.role ?? "member").toLowerCase(), + name: row.name, + createdBy: row.createdBy, + }; +} + +export async function listContentOrganizationMemberships(userEmail: string) { + let result; + try { + result = await getDbExec().execute({ + sql: `SELECT m.org_id AS "orgId", m.role AS role, o.name AS name, + o.created_by AS "createdBy" + FROM org_members m + INNER JOIN organizations o ON o.id = m.org_id + WHERE LOWER(m.email) = ? + ORDER BY m.org_id ASC`, + args: [normalizeContentSpaceEmail(userEmail)], + }); + } catch (error) { + if (error instanceof Error && error.message.includes("no such table")) { + return []; + } + throw error; + } + return result.rows + .map((row: any) => ({ + orgId: typeof row.orgId === "string" ? row.orgId : row.org_id, + role: String(row.role ?? "member").toLowerCase(), + name: row.name, + createdBy: row.createdBy ?? row.created_by, + })) + .filter( + ( + row, + ): row is { + orgId: string; + role: string; + name: string; + createdBy: string; + } => + typeof row.orgId === "string" && + typeof row.name === "string" && + typeof row.createdBy === "string", + ); +} + +export async function resolveContentSpaceAccess( + spaceId: string, + requiredRole: "viewer" | "editor" = "viewer", +): Promise { + const userEmail = getRequestUserEmail(); + if (!userEmail) throw new Error("no authenticated user"); + const normalizedUserEmail = normalizeContentSpaceEmail(userEmail); + const [space] = await getDb() + .select() + .from(schema.contentSpaces) + .where(eq(schema.contentSpaces.id, spaceId)); + if (!space || space.archivedAt) + throw new Error(`Content space "${spaceId}" not found`); + + if (!space.orgId) { + if (normalizeContentSpaceEmail(space.ownerEmail) !== normalizedUserEmail) { + throw new Error(`Not authorized for Content space "${spaceId}"`); + } + return { + space, + authority: { userEmail: normalizedUserEmail, orgId: null }, + role: "owner", + }; + } + + const membership = await getContentOrganizationMembership( + space.orgId, + normalizedUserEmail, + ); + if (!membership) + throw new Error(`Not authorized for Content space "${spaceId}"`); + const role: ContentSpaceRole = + membership.role === "owner" + ? "owner" + : membership.role === "admin" + ? "editor" + : "viewer"; + if (requiredRole === "editor" && role === "viewer") { + throw new Error(`Editor access is required for Content space "${spaceId}"`); + } + return { + space, + authority: { userEmail: normalizedUserEmail, orgId: space.orgId }, + role, + }; +} diff --git a/templates/content/actions/_content-spaces.ts b/templates/content/actions/_content-spaces.ts new file mode 100644 index 0000000000..c58e763f1c --- /dev/null +++ b/templates/content/actions/_content-spaces.ts @@ -0,0 +1,613 @@ +import { createHash } from "node:crypto"; + +import { and, eq, sql } from "drizzle-orm"; + +import { schema } from "../server/db/index.js"; +import { + listContentOrganizationMemberships, + normalizeContentSpaceEmail, +} from "./_content-space-access.js"; +import { + defaultDatabaseViewConfig, + seedDefaultBlocksField, + serializeDatabaseViewConfig, +} from "./_property-utils.js"; + +type Db = any; + +export type ProvisionedContentSpaces = { + personalSpaceId: string; + personalFilesDatabaseId: string; + catalogDatabaseId: string; + spaceIds: string[]; + created: { + spaces: number; + databases: number; + documents: number; + catalogItems: number; + }; +}; + +function opaqueId(kind: string, value: string): string { + return `${kind}_${createHash("sha256").update(value).digest("hex").slice(0, 32)}`; +} + +export function personalContentSpaceId(email: string) { + return opaqueId("content_space_personal", normalizeContentSpaceEmail(email)); +} + +export function organizationContentSpaceId(orgId: string) { + return opaqueId("content_space_org", orgId.trim()); +} + +export function sourceBackedContentSpaceId( + email: string, + connectionId: string, +) { + return opaqueId( + "content_space_source", + `${normalizeContentSpaceEmail(email)}:${connectionId.trim()}`, + ); +} + +export function systemIdsForContentSpace( + scope: string, + role: "files" | "workspaces", +) { + return { + databaseId: opaqueId(`content_database_${role}`, scope), + documentId: opaqueId(`content_document_${role}`, scope), + }; +} + +async function ensureDocument( + db: Db, + values: Omit & { + ownerEmail: string; + }, + created: ProvisionedContentSpaces["created"], +) { + const [existing] = await db + .select({ id: schema.documents.id }) + .from(schema.documents) + .where( + and( + eq(schema.documents.id, values.id), + eq(schema.documents.ownerEmail, values.ownerEmail), + ), + ); + if (existing) return; + await db + .insert(schema.documents) + .values({ ...values, ownerEmail: values.ownerEmail }) + .onConflictDoNothing(); + created.documents += 1; +} + +async function ensureSystemDatabase(args: { + db: Db; + spaceId: string; + ownerEmail: string; + orgId: string | null; + title: string; + role: "files" | "workspaces"; + visibility: "private" | "org"; + now: string; + created: ProvisionedContentSpaces["created"]; +}) { + const ids = systemIdsForContentSpace(args.spaceId, args.role); + await ensureDocument( + args.db, + { + id: ids.documentId, + spaceId: args.spaceId, + ownerEmail: args.ownerEmail, + orgId: args.orgId, + parentId: null, + title: args.title, + content: "", + description: "", + position: 0, + isFavorite: 0, + hideFromSearch: 1, + visibility: args.visibility, + createdAt: args.now, + updatedAt: args.now, + }, + args.created, + ); + const [existing] = await args.db + .select({ id: schema.contentDatabases.id }) + .from(schema.contentDatabases) + .where( + and( + eq(schema.contentDatabases.spaceId, args.spaceId), + eq(schema.contentDatabases.systemRole, args.role), + ), + ); + if (!existing) { + await args.db + .insert(schema.contentDatabases) + .values({ + id: ids.databaseId, + spaceId: args.spaceId, + ownerEmail: args.ownerEmail, + orgId: args.orgId, + documentId: ids.documentId, + title: args.title, + systemRole: args.role, + viewConfigJson: serializeDatabaseViewConfig( + defaultDatabaseViewConfig("sidebar"), + ), + createdAt: args.now, + updatedAt: args.now, + }) + .onConflictDoNothing(); + args.created.databases += 1; + } + const [database] = await args.db + .select() + .from(schema.contentDatabases) + .where( + and( + eq(schema.contentDatabases.spaceId, args.spaceId), + eq(schema.contentDatabases.systemRole, args.role), + ), + ); + if (!database) + throw new Error( + `Unable to provision ${args.role} database for Content space`, + ); + return database; +} + +async function ensureDatabaseItem(args: { + db: Db; + databaseId: string; + documentId: string; + ownerEmail: string; + orgId: string | null; + position: number; + now: string; +}) { + const [existing] = await args.db + .select({ id: schema.contentDatabaseItems.id }) + .from(schema.contentDatabaseItems) + .where( + and( + eq(schema.contentDatabaseItems.databaseId, args.databaseId), + eq(schema.contentDatabaseItems.documentId, args.documentId), + ), + ); + if (existing) return existing.id; + const id = opaqueId( + "content_database_item", + `${args.databaseId}:${args.documentId}`, + ); + await args.db + .insert(schema.contentDatabaseItems) + .values({ + id, + ownerEmail: args.ownerEmail, + orgId: args.orgId, + databaseId: args.databaseId, + documentId: args.documentId, + position: args.position, + createdAt: args.now, + updatedAt: args.now, + }) + .onConflictDoNothing(); + return id; +} + +export async function provisionContentSpaces( + db: Db, + userEmail: string, +): Promise { + const email = normalizeContentSpaceEmail(userEmail); + const memberships = await listContentOrganizationMemberships(email); + const now = new Date().toISOString(); + const personalSpaceId = personalContentSpaceId(email); + const result: ProvisionedContentSpaces = { + personalSpaceId, + personalFilesDatabaseId: systemIdsForContentSpace(personalSpaceId, "files") + .databaseId, + catalogDatabaseId: systemIdsForContentSpace(personalSpaceId, "workspaces") + .databaseId, + spaceIds: [], + created: { spaces: 0, databases: 0, documents: 0, catalogItems: 0 }, + }; + + await db.transaction(async (tx: Db) => { + const personalFiles = await ensureSystemDatabase({ + db: tx, + spaceId: personalSpaceId, + ownerEmail: email, + orgId: null, + title: "Files", + role: "files", + visibility: "private", + now, + created: result.created, + }); + const [personalSpace] = await tx + .select() + .from(schema.contentSpaces) + .where(eq(schema.contentSpaces.id, personalSpaceId)); + if (!personalSpace) { + await tx + .insert(schema.contentSpaces) + .values({ + id: personalSpaceId, + name: "Personal", + kind: "personal", + ownerEmail: email, + orgId: null, + filesDatabaseId: personalFiles.id, + createdBy: email, + createdAt: now, + updatedAt: now, + }) + .onConflictDoNothing(); + result.created.spaces += 1; + } else if (personalSpace.filesDatabaseId !== personalFiles.id) { + await tx + .update(schema.contentSpaces) + .set({ filesDatabaseId: personalFiles.id, updatedAt: now }) + .where(eq(schema.contentSpaces.id, personalSpaceId)); + } + const catalog = await ensureSystemDatabase({ + db: tx, + spaceId: personalSpaceId, + ownerEmail: email, + orgId: null, + title: "Workspaces", + role: "workspaces", + visibility: "private", + now, + created: result.created, + }); + await ensureDatabaseItem({ + db: tx, + databaseId: personalFiles.id, + documentId: catalog.documentId, + ownerEmail: email, + orgId: null, + position: 0, + now, + }); + + const spaces = [ + { + id: personalSpaceId, + name: "Personal", + ownerEmail: email, + orgId: null as string | null, + createdBy: email, + filesDatabaseId: personalFiles.id, + }, + ...memberships.map((membership) => ({ + id: organizationContentSpaceId(membership.orgId), + name: membership.name, + ownerEmail: membership.createdBy, + orgId: membership.orgId, + createdBy: membership.createdBy, + filesDatabaseId: systemIdsForContentSpace( + organizationContentSpaceId(membership.orgId), + "files", + ).databaseId, + })), + ]; + for (const space of spaces.slice(1)) { + const files = await ensureSystemDatabase({ + db: tx, + spaceId: space.id, + ownerEmail: space.ownerEmail, + orgId: space.orgId, + title: "Files", + role: "files", + visibility: "org", + now, + created: result.created, + }); + const [existingSpace] = await tx + .select() + .from(schema.contentSpaces) + .where(eq(schema.contentSpaces.id, space.id)); + if (!existingSpace) { + await tx + .insert(schema.contentSpaces) + .values({ + ...space, + filesDatabaseId: files.id, + kind: "organization", + createdAt: now, + updatedAt: now, + }) + .onConflictDoNothing(); + result.created.spaces += 1; + } else if (existingSpace.filesDatabaseId !== files.id) { + await tx + .update(schema.contentSpaces) + .set({ filesDatabaseId: files.id, updatedAt: now }) + .where(eq(schema.contentSpaces.id, space.id)); + } + } + const accessibleIds = new Set(spaces.map((space) => space.id)); + for (const [index, space] of spaces.entries()) { + if (!accessibleIds.has(space.id)) continue; + const referenceDocumentId = opaqueId( + "content_workspace_reference", + `${email}:${space.id}`, + ); + await ensureDocument( + tx, + { + id: referenceDocumentId, + spaceId: personalSpaceId, + ownerEmail: email, + orgId: null, + parentId: catalog.documentId, + title: space.name, + content: "", + description: "", + position: index, + isFavorite: 0, + hideFromSearch: 0, + visibility: "private", + createdAt: now, + updatedAt: now, + }, + result.created, + ); + const catalogItemId = await ensureDatabaseItem({ + db: tx, + databaseId: catalog.id, + documentId: referenceDocumentId, + ownerEmail: email, + orgId: null, + position: index, + now, + }); + await ensureDatabaseItem({ + db: tx, + databaseId: personalFiles.id, + documentId: referenceDocumentId, + ownerEmail: email, + orgId: null, + position: index + 1, + now, + }); + const [existingCatalogItem] = await tx + .select({ id: schema.contentSpaceCatalogItems.id }) + .from(schema.contentSpaceCatalogItems) + .where( + and( + eq(schema.contentSpaceCatalogItems.catalogDatabaseId, catalog.id), + eq(schema.contentSpaceCatalogItems.spaceId, space.id), + ), + ); + if (!existingCatalogItem) { + await tx + .insert(schema.contentSpaceCatalogItems) + .values({ + id: opaqueId("content_space_catalog", `${email}:${space.id}`), + ownerEmail: email, + catalogDatabaseId: catalog.id, + databaseItemId: catalogItemId, + documentId: referenceDocumentId, + spaceId: space.id, + createdAt: now, + updatedAt: now, + }) + .onConflictDoNothing(); + result.created.catalogItems += 1; + } + } + }); + + // The established seeder uses its own lock/atomic claim, so call it after + // the provisioning transaction rather than nesting transaction machinery. + const records = await db + .select({ + id: schema.contentDatabases.id, + ownerEmail: schema.contentDatabases.ownerEmail, + orgId: schema.contentDatabases.orgId, + }) + .from(schema.contentDatabases) + .where(and(eq(schema.contentDatabases.spaceId, personalSpaceId))); + for (const database of records) + await seedDefaultBlocksField({ + databaseId: database.id, + ownerEmail: database.ownerEmail, + orgId: database.orgId, + now, + db, + }); + for (const membership of memberships) { + const spaceId = organizationContentSpaceId(membership.orgId); + const [database] = await db + .select({ + id: schema.contentDatabases.id, + ownerEmail: schema.contentDatabases.ownerEmail, + orgId: schema.contentDatabases.orgId, + }) + .from(schema.contentDatabases) + .where( + and( + eq(schema.contentDatabases.spaceId, spaceId), + eq(schema.contentDatabases.systemRole, "files"), + ), + ); + if (database) + await seedDefaultBlocksField({ + databaseId: database.id, + ownerEmail: database.ownerEmail, + orgId: database.orgId, + now, + db, + }); + } + result.spaceIds = [ + personalSpaceId, + ...memberships.map((membership) => + organizationContentSpaceId(membership.orgId), + ), + ]; + return result; +} + +export async function provisionSourceBackedContentSpace( + db: Db, + userEmail: string, + input: { connectionId: string; name: string }, +) { + const email = normalizeContentSpaceEmail(userEmail); + const connectionId = input.connectionId.trim(); + const name = input.name.trim() || "Local folder"; + if (!connectionId) throw new Error("Local folder connection ID is required"); + await provisionContentSpaces(db, email); + + const now = new Date().toISOString(); + const spaceId = sourceBackedContentSpaceId(email, connectionId); + const personalSpaceId = personalContentSpaceId(email); + const personalFilesIds = systemIdsForContentSpace(personalSpaceId, "files"); + const catalogIds = systemIdsForContentSpace(personalSpaceId, "workspaces"); + const created: ProvisionedContentSpaces["created"] = { + spaces: 0, + databases: 0, + documents: 0, + catalogItems: 0, + }; + + const files = await db.transaction(async (tx: Db) => { + const sourceFiles = await ensureSystemDatabase({ + db: tx, + spaceId, + ownerEmail: email, + orgId: null, + title: "Files", + role: "files", + visibility: "private", + now, + created, + }); + const [existingSpace] = await tx + .select() + .from(schema.contentSpaces) + .where(eq(schema.contentSpaces.id, spaceId)); + if (!existingSpace) { + await tx + .insert(schema.contentSpaces) + .values({ + id: spaceId, + name, + kind: "source_backed", + ownerEmail: email, + orgId: null, + filesDatabaseId: sourceFiles.id, + createdBy: email, + createdAt: now, + updatedAt: now, + }) + .onConflictDoNothing(); + } else if ( + existingSpace.name !== name || + existingSpace.filesDatabaseId !== sourceFiles.id + ) { + await tx + .update(schema.contentSpaces) + .set({ name, filesDatabaseId: sourceFiles.id, updatedAt: now }) + .where(eq(schema.contentSpaces.id, spaceId)); + } + + const referenceDocumentId = opaqueId( + "content_workspace_reference", + `${email}:${spaceId}`, + ); + await ensureDocument( + tx, + { + id: referenceDocumentId, + spaceId: personalSpaceId, + ownerEmail: email, + orgId: null, + parentId: catalogIds.documentId, + title: name, + content: "", + description: "", + position: 0, + isFavorite: 0, + hideFromSearch: 0, + visibility: "private", + createdAt: now, + updatedAt: now, + }, + created, + ); + await tx + .update(schema.documents) + .set({ title: name, updatedAt: now }) + .where(eq(schema.documents.id, referenceDocumentId)); + + const [maxCatalogPosition] = await tx + .select({ max: sql`COALESCE(MAX(position), -1)` }) + .from(schema.contentDatabaseItems) + .where(eq(schema.contentDatabaseItems.databaseId, catalogIds.databaseId)); + const catalogItemId = await ensureDatabaseItem({ + db: tx, + databaseId: catalogIds.databaseId, + documentId: referenceDocumentId, + ownerEmail: email, + orgId: null, + position: (maxCatalogPosition?.max ?? -1) + 1, + now, + }); + await ensureDatabaseItem({ + db: tx, + databaseId: personalFilesIds.databaseId, + documentId: referenceDocumentId, + ownerEmail: email, + orgId: null, + position: (maxCatalogPosition?.max ?? -1) + 1, + now, + }); + const [mapping] = await tx + .select({ id: schema.contentSpaceCatalogItems.id }) + .from(schema.contentSpaceCatalogItems) + .where( + and( + eq( + schema.contentSpaceCatalogItems.catalogDatabaseId, + catalogIds.databaseId, + ), + eq(schema.contentSpaceCatalogItems.spaceId, spaceId), + ), + ); + if (!mapping) { + await tx + .insert(schema.contentSpaceCatalogItems) + .values({ + id: opaqueId("content_space_catalog", `${email}:${spaceId}`), + ownerEmail: email, + catalogDatabaseId: catalogIds.databaseId, + databaseItemId: catalogItemId, + documentId: referenceDocumentId, + spaceId, + createdAt: now, + updatedAt: now, + }) + .onConflictDoNothing(); + } + return sourceFiles; + }); + + await seedDefaultBlocksField({ + databaseId: files.id, + ownerEmail: files.ownerEmail, + orgId: files.orgId, + now, + db, + }); + return { spaceId, filesDatabaseId: files.id }; +} diff --git a/templates/content/actions/_database-source-utils.ts b/templates/content/actions/_database-source-utils.ts index f440dabe0d..cd8c8ecb97 100644 --- a/templates/content/actions/_database-source-utils.ts +++ b/templates/content/actions/_database-source-utils.ts @@ -81,6 +81,8 @@ import { type ExistingBuilderSourceRowIdentity, } from "./_builder-cms-source-adapter.js"; import { mergeBuilderCmsWriteSettingsIntoJson } from "./_builder-cms-write-settings.js"; +import { ensureDocumentsFilesMembership } from "./_content-files.js"; +import { provisionContentSpaces } from "./_content-spaces.js"; import { databaseItemsPositionScope, documentsPositionScope, @@ -142,6 +144,9 @@ type SourceMetadataRecord = { allowPublicationTransitions?: boolean; notes?: string | null; readMode?: string | null; + connectionId?: string | null; + connectionLabel?: string | null; + truthPolicy?: ContentDatabaseSource["metadata"]["truthPolicy"]; liveReadConfigured?: boolean; lastReadEntryCount?: number; lastReadMatchedRowCount?: number; @@ -213,7 +218,8 @@ function normalizeSourceType( if ( value === "builder-cms" || value === "local-table" || - value === "notion-database" + value === "notion-database" || + value === "local-folder" ) return value; return "mock-local"; @@ -230,12 +236,9 @@ function normalizeChangeKind( } function normalizeChangeDirection( - _value: string | null | undefined, + value: string | null | undefined, ): ContentDatabaseSourceChangeDirection { - // Integrations are the source of truth; change-sets only flow outbound - // (local → provider). Any legacy "incoming" rows coerce to outbound and are - // pruned by the resync cleanup. - return "outbound"; + return value === "incoming" ? "incoming" : "outbound"; } function normalizePushMode( @@ -296,6 +299,9 @@ function normalizeCapabilities( canStageLocalRevision: parsed?.canStageLocalRevision === true, liveWritesEnabled: parsed?.liveWritesEnabled === true, readOnlyRefresh: parsed?.readOnlyRefresh !== false, + canRename: parsed?.canRename === true, + canReveal: parsed?.canReveal === true, + canUseLocalComponents: parsed?.canUseLocalComponents === true, }; } @@ -306,6 +312,7 @@ function sourceMetadataLabel( if (sourceType === "builder-cms") return `builder.cms.${sourceTable}`; if (sourceType === "local-table") return `local.table.${sourceTable}`; if (sourceType === "notion-database") return `notion.database.${sourceTable}`; + if (sourceType === "local-folder") return `local.folder.${sourceTable}`; return `mock-local.${sourceTable}`; } @@ -3710,6 +3717,14 @@ async function loadSourceSnapshot( metadata.allowPublicationTransitions === true, notes: metadata.notes ?? null, readMode: metadata.readMode ?? null, + connectionId: metadata.connectionId ?? null, + connectionLabel: metadata.connectionLabel ?? null, + truthPolicy: + metadata.truthPolicy === "database_primary" || + metadata.truthPolicy === "source_primary" || + metadata.truthPolicy === "reviewed_bidirectional" + ? metadata.truthPolicy + : undefined, liveReadConfigured: metadata.liveReadConfigured === true, lastReadEntryCount: typeof metadata.lastReadEntryCount === "number" @@ -5105,6 +5120,7 @@ export async function importBuilderCmsEntriesAsDatabaseItems(args: { .select() .from(schema.documents) .where(eq(schema.documents.id, args.database.documentId)); + if (!databaseDocument) throw new Error("Database page not found."); const currentItems = await db .select({ item: schema.contentDatabaseItems, @@ -5116,6 +5132,58 @@ export async function importBuilderCmsEntriesAsDatabaseItems(args: { eq(schema.documents.id, schema.contentDatabaseItems.documentId), ) .where(eq(schema.contentDatabaseItems.databaseId, args.database.id)); + let resolvedDatabaseSpaceId = + args.database.spaceId ?? databaseDocument.spaceId; + if ( + args.database.spaceId && + databaseDocument.spaceId && + args.database.spaceId !== databaseDocument.spaceId + ) { + throw new Error( + "Database page and database belong to different Content spaces.", + ); + } + if (!resolvedDatabaseSpaceId) { + resolvedDatabaseSpaceId = ( + await provisionContentSpaces(db, args.database.ownerEmail) + ).personalSpaceId; + } + const databaseSpaceId = resolvedDatabaseSpaceId; + if ( + currentItems.some( + (row) => row.document.spaceId && row.document.spaceId !== databaseSpaceId, + ) + ) { + throw new Error("Database contains a row from a different Content space."); + } + const legacyDocumentIds = [ + databaseDocument.id, + ...currentItems + .filter((row) => !row.document.spaceId) + .map((row) => row.document.id), + ]; + if ( + args.database.spaceId !== databaseSpaceId || + databaseDocument.spaceId !== databaseSpaceId || + legacyDocumentIds.length > 1 + ) { + await db.transaction(async (tx) => { + await tx + .update(schema.contentDatabases) + .set({ spaceId: databaseSpaceId, updatedAt: args.now }) + .where(eq(schema.contentDatabases.id, args.database.id)); + await tx + .update(schema.documents) + .set({ spaceId: databaseSpaceId, updatedAt: args.now }) + .where(inArray(schema.documents.id, legacyDocumentIds)); + await ensureDocumentsFilesMembership( + tx, + legacyDocumentIds, + args.now, + args.database.ownerEmail, + ); + }); + } const representedDocumentIds = new Set( (args.existingSourceRows ?? []) .map((row) => row.documentId) @@ -5196,6 +5264,7 @@ export async function importBuilderCmsEntriesAsDatabaseItems(args: { ); documentRows.push({ id: documentId, + spaceId: databaseSpaceId, ownerEmail: args.database.ownerEmail, orgId: args.database.orgId, parentId: args.database.documentId, @@ -5226,7 +5295,7 @@ export async function importBuilderCmsEntriesAsDatabaseItems(args: { await db.transaction(async (tx) => { for (const chunk of chunks( documentRows, - bulkChunkSizeForColumnCount(13), + bulkChunkSizeForColumnCount(14), )) { await tx .insert(schema.documents) @@ -5242,6 +5311,12 @@ export async function importBuilderCmsEntriesAsDatabaseItems(args: { .values(chunk) .onConflictDoNothing(); } + await ensureDocumentsFilesMembership( + tx, + documentRows.map((row) => row.id), + args.now, + args.database.ownerEmail, + ); }); return { imported: itemRows.length, importedEntriesByDocumentId }; diff --git a/templates/content/actions/_database-utils.ts b/templates/content/actions/_database-utils.ts index e55aba9a7e..d16af8dc82 100644 --- a/templates/content/actions/_database-utils.ts +++ b/templates/content/actions/_database-utils.ts @@ -43,6 +43,7 @@ type DocumentListRow = Omit; // string below; opened documents use their dedicated document read path instead. export const contentDatabaseListDocumentSelection = { id: schema.documents.id, + spaceId: schema.documents.spaceId, parentId: schema.documents.parentId, title: schema.documents.title, description: schema.documents.description, @@ -462,7 +463,12 @@ export async function getDatabaseItemByDocumentId( schema.contentDatabaseItems.id, ), ) - .where(and(...clauses)); + .where(and(...clauses)) + .orderBy( + sql`CASE WHEN ${schema.contentDatabaseSourceRows.sourceId} IS NOT NULL THEN 0 ELSE 1 END`, + sql`CASE WHEN ${schema.contentDatabases.systemRole} IS NULL THEN 0 ELSE 1 END`, + asc(schema.contentDatabases.id), + ); return row ?? null; } diff --git a/templates/content/actions/_local-file-documents.test.ts b/templates/content/actions/_local-file-documents.test.ts index a3dd3d250e..a092a2dd5f 100644 --- a/templates/content/actions/_local-file-documents.test.ts +++ b/templates/content/actions/_local-file-documents.test.ts @@ -6,14 +6,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createLocalFileDocument, + listLocalFileDocuments, localFileDocumentId, moveLocalFileDocument, removeContentLocalFileRoots, updateLocalFileDocument, } from "./_local-file-documents"; -import editDocument from "./edit-document"; -import pullDocument from "./pull-document"; -import searchDocuments from "./search-documents"; vi.mock("@agent-native/core/application-state", () => ({ writeAppState: vi.fn(), @@ -24,7 +22,6 @@ vi.mock("@agent-native/core/application-state", () => ({ const tmpRoots: string[] = []; const OLD_ENV = { - AGENT_NATIVE_MODE: process.env.AGENT_NATIVE_MODE, AGENT_NATIVE_DATA_MODE: process.env.AGENT_NATIVE_DATA_MODE, AGENT_NATIVE_MANIFEST: process.env.AGENT_NATIVE_MANIFEST, AGENT_NATIVE_MANIFEST_PATH: process.env.AGENT_NATIVE_MANIFEST_PATH, @@ -67,7 +64,6 @@ function setupLocalContentRepo(options: { profile?: string } = {}) { }, }); process.env.AGENT_NATIVE_MANIFEST_PATH = manifestPath; - process.env.AGENT_NATIVE_MODE = "local-files"; return root; } @@ -115,47 +111,7 @@ describe("content local file documents", () => { expect(readFile(root, "docs/raw.mdx")).toBe(rawMdx); }); - it("lets agent edit/search/pull actions operate on local files", async () => { - const root = setupLocalContentRepo(); - writeFile( - root, - "docs/guide.mdx", - '---\ntitle: "Guide"\n---\n\nAlpha beta needle.', - ); - writeFile( - root, - "blog/hidden.mdx", - '---\ntitle: "Hidden"\nhideFromSearch: true\n---\n\nneedle secret', - ); - const id = localFileDocumentId("docs/guide.mdx"); - - await expect( - editDocument.run({ id, find: "beta", replace: "gamma" }), - ).resolves.toMatchObject({ applied: 1, total: 1 }); - expect(readFile(root, "docs/guide.mdx")).toContain("Alpha gamma needle."); - - await expect( - searchDocuments.run({ query: "needle", limit: 10 }), - ).resolves.toMatchObject({ - documents: [ - expect.objectContaining({ - id, - title: "Guide", - snippet: expect.stringContaining("needle"), - }), - ], - }); - - await expect( - pullDocument.run({ id, format: "markdown" }), - ).resolves.toMatchObject({ - id, - title: "Guide", - content: expect.stringContaining("Alpha gamma needle."), - }); - }); - - it("reuses parsed local documents across repeated searches", async () => { + it("reuses parsed local documents across explicit compatibility reads", async () => { const root = setupLocalContentRepo(); writeFile(root, "docs/guide.mdx", "# Guide\n\nAlpha needle."); writeFile(root, "docs/other.mdx", "# Other\n\nBeta needle."); @@ -167,28 +123,29 @@ describe("content local file documents", () => { typeof filePath === "string" && filePath.endsWith(".mdx"), ).length; - await searchDocuments.run({ query: "needle", limit: 10 }); - const opensAfterFirstSearch = localContentOpens(); - expect(opensAfterFirstSearch).toBeGreaterThan(0); + const first = await listLocalFileDocuments(); + expect( + first.filter((document) => document.source?.kind === "file"), + ).toHaveLength(2); + const opensAfterFirstRead = localContentOpens(); + expect(opensAfterFirstRead).toBeGreaterThan(0); - await searchDocuments.run({ query: "alpha", limit: 10 }); - expect(localContentOpens()).toBe(opensAfterFirstSearch); + await listLocalFileDocuments(); + expect(localContentOpens()).toBe(opensAfterFirstRead); await updateLocalFileDocument(localFileDocumentId("docs/guide.mdx"), { content: "# Guide\n\nFresh needle.", }); openSpy.mockClear(); - await expect( - searchDocuments.run({ query: "fresh", limit: 10 }), - ).resolves.toMatchObject({ - documents: [ + await expect(listLocalFileDocuments()).resolves.toEqual( + expect.arrayContaining([ expect.objectContaining({ id: localFileDocumentId("docs/guide.mdx"), - snippet: expect.stringContaining("Fresh needle"), + content: expect.stringContaining("Fresh needle"), }), - ], - }); + ]), + ); expect(localContentOpens()).toBeGreaterThan(0); }); diff --git a/templates/content/actions/_local-file-documents.ts b/templates/content/actions/_local-file-documents.ts index a9b856789c..b76457f86b 100644 --- a/templates/content/actions/_local-file-documents.ts +++ b/templates/content/actions/_local-file-documents.ts @@ -4,7 +4,6 @@ import { deleteLocalArtifactFile, ensureLocalArtifactRoot, getLocalArtifactApp, - isAgentNativeLocalFileMode, loadAgentNativeManifest, listLocalArtifactFiles, readLocalArtifactFile, @@ -57,13 +56,6 @@ function localOptions(): LocalArtifactOptions { }; } -export async function isContentLocalFileMode() { - return isAgentNativeLocalFileMode({ - appId: CONTENT_APP_ID, - defaults: CONTENT_LOCAL_DEFAULTS, - }); -} - function encodeIdPath(filePath: string): string { return Buffer.from(filePath, "utf8").toString("base64url"); } @@ -242,7 +234,6 @@ function localFileDocumentsCacheScope() { return JSON.stringify({ manifest: process.env.AGENT_NATIVE_MANIFEST, manifestPath: process.env.AGENT_NATIVE_MANIFEST_PATH, - mode: process.env.AGENT_NATIVE_MODE, dataMode: process.env.AGENT_NATIVE_DATA_MODE, }); } diff --git a/templates/content/actions/_local-folder-source.test.ts b/templates/content/actions/_local-folder-source.test.ts new file mode 100644 index 0000000000..38c0d4c8a4 --- /dev/null +++ b/templates/content/actions/_local-folder-source.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; + +import { + localFolderSourceCapabilities, + localFolderSourceId, + localFolderSourceMetadata, +} from "./_local-folder-source"; + +describe("local folder source metadata", () => { + it("uses stable opaque identity without storing a filesystem path", () => { + const first = localFolderSourceId("files-db", "desktop-folder-7"); + const second = localFolderSourceId("files-db", "desktop-folder-7"); + expect(first).toBe(second); + expect(first).not.toContain("desktop-folder-7"); + + const metadata = localFolderSourceMetadata({ + connectionId: "desktop-folder-7", + label: "Product docs", + truthPolicy: "source_primary", + }); + expect(metadata).toMatchObject({ + connectionId: "desktop-folder-7", + connectionLabel: "Product docs", + truthPolicy: "source_primary", + readMode: "trusted-local-bridge", + }); + expect(JSON.stringify(metadata)).not.toContain("/Users/"); + }); + + it("advertises local editing without pretending a folder publishes", () => { + expect(localFolderSourceCapabilities()).toMatchObject({ + canRefresh: true, + canWriteBody: true, + canPublish: false, + canRename: true, + canReveal: true, + canUseLocalComponents: true, + }); + }); +}); diff --git a/templates/content/actions/_local-folder-source.ts b/templates/content/actions/_local-folder-source.ts new file mode 100644 index 0000000000..ca1c2ab9b9 --- /dev/null +++ b/templates/content/actions/_local-folder-source.ts @@ -0,0 +1,53 @@ +import { createHash } from "node:crypto"; + +import type { + ContentDatabaseSourceCapabilities, + ContentDatabaseSourceTruthPolicy, +} from "../shared/api.js"; + +export const LOCAL_FOLDER_SOURCE_TYPE = "local-folder" as const; + +export function localFolderSourceId(databaseId: string, connectionId: string) { + return `content_database_source_local_folder_${createHash("sha256") + .update(`${databaseId}:${connectionId}`) + .digest("hex") + .slice(0, 32)}`; +} + +export function localFolderSourceCapabilities(): ContentDatabaseSourceCapabilities { + return { + canRefresh: true, + canCreateChangeSets: true, + canWriteFields: true, + canWriteBody: true, + canPush: true, + canPull: true, + canPublish: false, + canDelete: true, + canStageLocalRevision: true, + liveWritesEnabled: false, + readOnlyRefresh: false, + canRename: true, + canReveal: true, + canUseLocalComponents: true, + }; +} + +export function localFolderSourceMetadata(input: { + connectionId: string; + label: string; + truthPolicy: ContentDatabaseSourceTruthPolicy; +}) { + return { + primaryKey: "relative_path", + naturalKeyField: "relative_path", + titleField: "title", + readMode: "trusted-local-bridge", + connectionId: input.connectionId, + connectionLabel: input.label, + truthPolicy: input.truthPolicy, + writeMode: "stage_only" as const, + notes: + "The trusted local bridge owns the folder handle or path; SQL stores only this opaque connection identity.", + }; +} diff --git a/templates/content/actions/_property-utils.ts b/templates/content/actions/_property-utils.ts index 3b8bebedf4..42a073940a 100644 --- a/templates/content/actions/_property-utils.ts +++ b/templates/content/actions/_property-utils.ts @@ -184,8 +184,10 @@ export function serializeDatabaseViewConfig( return JSON.stringify(normalizeDatabaseViewConfig(value)); } -function defaultDatabaseViewConfig(): ContentDatabaseViewConfig { - const view = defaultDatabaseView(); +export function defaultDatabaseViewConfig( + type: ContentDatabaseView["type"] = "table", +): ContentDatabaseViewConfig { + const view = defaultDatabaseView({}, type); return { activeViewId: view.id, views: [view], @@ -258,7 +260,9 @@ function defaultDatabaseView( ? "Timeline" : type === "form" ? "Form" - : "Table", + : type === "sidebar" + ? "Sidebar" + : "Table", type, sorts: values.sorts ?? [], filters: values.filters ?? [], @@ -289,7 +293,8 @@ function normalizeDatabaseView(value: unknown): ContentDatabaseView | null { view.type === "gallery" || view.type === "calendar" || view.type === "timeline" || - view.type === "form" + view.type === "form" || + view.type === "sidebar" ? view.type : "table"; return { diff --git a/templates/content/actions/add-database-item.ts b/templates/content/actions/add-database-item.ts index 752c775ffa..2da133cb92 100644 --- a/templates/content/actions/add-database-item.ts +++ b/templates/content/actions/add-database-item.ts @@ -10,6 +10,7 @@ import { isComputedPropertyType, type DocumentPropertyType, } from "../shared/properties.js"; +import { ensureDocumentFilesMembership } from "./_content-files.js"; import { getContentDatabaseResponse } from "./_database-utils.js"; import { databaseItemsPositionScope, @@ -47,60 +48,39 @@ export default defineAction({ "editor", ); const databaseDocument = access.resource; + if ( + database.spaceId && + databaseDocument.spaceId && + databaseDocument.spaceId !== database.spaceId + ) { + throw new Error( + `Database "${databaseId}" has inconsistent Content space`, + ); + } const now = new Date().toISOString(); + const databaseSpaceId = + database.spaceId ?? (databaseDocument.spaceId as string | null); + if (databaseSpaceId && (!database.spaceId || !databaseDocument.spaceId)) { + await db.transaction(async (tx) => { + if (!database.spaceId) { + await tx + .update(schema.contentDatabases) + .set({ spaceId: databaseSpaceId, updatedAt: now }) + .where(eq(schema.contentDatabases.id, databaseId)); + } + if (!databaseDocument.spaceId) { + await tx + .update(schema.documents) + .set({ spaceId: databaseSpaceId, updatedAt: now }) + .where(eq(schema.documents.id, database.documentId)); + } + await ensureDocumentFilesMembership(tx, database.documentId, now); + }); + } const documentId = nanoid(); const itemId = nanoid(); - await withPositionLock( - documentsPositionScope(database.ownerEmail, database.documentId), - async () => { - const [maxDocPos] = await db - .select({ max: sql`COALESCE(MAX(position), -1)` }) - .from(schema.documents) - .where( - and( - eq(schema.documents.ownerEmail, database.ownerEmail), - eq(schema.documents.parentId, database.documentId), - ), - ); - - await db.insert(schema.documents).values({ - id: documentId, - ownerEmail: database.ownerEmail, - orgId: database.orgId, - parentId: database.documentId, - title: title?.trim() ?? "", - content: "", - icon: null, - position: (maxDocPos?.max ?? -1) + 1, - isFavorite: 0, - hideFromSearch: databaseDocument.hideFromSearch ?? 0, - visibility: databaseDocument.visibility ?? "private", - createdAt: now, - updatedAt: now, - }); - }, - ); - - await withPositionLock(databaseItemsPositionScope(databaseId), async () => { - const [maxItemPos] = await db - .select({ max: sql`COALESCE(MAX(position), -1)` }) - .from(schema.contentDatabaseItems) - .where(eq(schema.contentDatabaseItems.databaseId, databaseId)); - - await db.insert(schema.contentDatabaseItems).values({ - id: itemId, - ownerEmail: database.ownerEmail, - orgId: database.orgId, - databaseId, - documentId, - position: (maxItemPos?.max ?? -1) + 1, - createdAt: now, - updatedAt: now, - }); - }); - const inheritedShares = await db .select({ principalType: schema.documentShares.principalType, @@ -110,21 +90,10 @@ export default defineAction({ .from(schema.documentShares) .where(eq(schema.documentShares.resourceId, database.documentId)); - if (inheritedShares.length > 0) { - await db.insert(schema.documentShares).values( - inheritedShares.map((share) => ({ - id: nanoid(), - resourceId: documentId, - principalType: share.principalType, - principalId: share.principalId, - role: share.role, - createdBy: getRequestUserEmail() ?? database.ownerEmail, - createdAt: now, - })), - ); - } - const initialValues = Object.entries(propertyValues ?? {}); + const propertyValueRows: Array< + typeof schema.documentPropertyValues.$inferInsert + > = []; if (initialValues.length > 0) { const requestedPropertyIds = initialValues.map( ([propertyId]) => propertyId, @@ -153,7 +122,7 @@ export default defineAction({ const definition = definitionById.get(propertyId); const type = definition?.type as DocumentPropertyType | undefined; if (!definition || !type || isComputedPropertyType(type)) continue; - await db.insert(schema.documentPropertyValues).values({ + propertyValueRows.push({ id: nanoid(), ownerEmail: database.ownerEmail, documentId, @@ -165,7 +134,80 @@ export default defineAction({ } } - await writeAppState("refresh-signal", { ts: Date.now() }); + await withPositionLock( + documentsPositionScope(database.ownerEmail, database.documentId), + () => + withPositionLock(databaseItemsPositionScope(databaseId), async () => { + await db.transaction(async (tx) => { + const [maxDocPos] = await tx + .select({ max: sql`COALESCE(MAX(position), -1)` }) + .from(schema.documents) + .where( + and( + eq(schema.documents.ownerEmail, database.ownerEmail), + eq(schema.documents.parentId, database.documentId), + ), + ); + const [maxItemPos] = await tx + .select({ max: sql`COALESCE(MAX(position), -1)` }) + .from(schema.contentDatabaseItems) + .where(eq(schema.contentDatabaseItems.databaseId, databaseId)); + + await tx.insert(schema.documents).values({ + id: documentId, + spaceId: databaseSpaceId, + ownerEmail: database.ownerEmail, + orgId: database.orgId, + parentId: database.documentId, + title: title?.trim() ?? "", + content: "", + icon: null, + position: (maxDocPos?.max ?? -1) + 1, + isFavorite: 0, + hideFromSearch: databaseDocument.hideFromSearch ?? 0, + visibility: databaseDocument.visibility ?? "private", + createdAt: now, + updatedAt: now, + }); + await tx.insert(schema.contentDatabaseItems).values({ + id: itemId, + ownerEmail: database.ownerEmail, + orgId: database.orgId, + databaseId, + documentId, + position: (maxItemPos?.max ?? -1) + 1, + createdAt: now, + updatedAt: now, + }); + if (inheritedShares.length > 0) { + await tx.insert(schema.documentShares).values( + inheritedShares.map((share) => ({ + id: nanoid(), + resourceId: documentId, + principalType: share.principalType, + principalId: share.principalId, + role: share.role, + createdBy: getRequestUserEmail() ?? database.ownerEmail, + createdAt: now, + })), + ); + } + if (propertyValueRows.length > 0) { + await tx + .insert(schema.documentPropertyValues) + .values(propertyValueRows); + } + if (databaseSpaceId) { + await ensureDocumentFilesMembership(tx, documentId, now); + } + }); + }), + ); + + await writeAppState("refresh-signal", { ts: Date.now() }).catch(() => { + // The row is already committed; polling will reconcile if a concurrent + // SQLite writer briefly blocks this best-effort refresh hint. + }); return { ...(await getContentDatabaseResponse(databaseId)), diff --git a/templates/content/actions/backfill-content-files.ts b/templates/content/actions/backfill-content-files.ts new file mode 100644 index 0000000000..368c178f44 --- /dev/null +++ b/templates/content/actions/backfill-content-files.ts @@ -0,0 +1,22 @@ +import { defineAction } from "@agent-native/core"; +import { writeAppState } from "@agent-native/core/application-state"; +import { getRequestUserEmail } from "@agent-native/core/server/request-context"; +import { z } from "zod"; + +import { getDb } from "../server/db/index.js"; +import { reconcileContentFilesMemberships } from "./_content-files.js"; +import { provisionContentSpaces } from "./_content-spaces.js"; + +export default defineAction({ + description: + "Assign legacy pages to Content spaces and reconcile their canonical Files database memberships.", + schema: z.object({}), + run: async () => { + const userEmail = getRequestUserEmail(); + if (!userEmail) throw new Error("no authenticated user"); + await provisionContentSpaces(getDb(), userEmail); + const result = await reconcileContentFilesMemberships(getDb(), userEmail); + await writeAppState("refresh-signal", { ts: Date.now() }); + return result; + }, +}); diff --git a/templates/content/actions/connect-local-folder-source.ts b/templates/content/actions/connect-local-folder-source.ts new file mode 100644 index 0000000000..ce5b262adb --- /dev/null +++ b/templates/content/actions/connect-local-folder-source.ts @@ -0,0 +1,221 @@ +import { defineAction } from "@agent-native/core"; +import { writeAppState } from "@agent-native/core/application-state"; +import { getRequestUserEmail } from "@agent-native/core/server/request-context"; +import { and, eq } from "drizzle-orm"; +import { z } from "zod"; + +import { getDb, schema } from "../server/db/index.js"; +import type { ContentDatabaseSourceTruthPolicy } from "../shared/api.js"; +import { + normalizeContentSpaceEmail, + resolveContentSpaceAccess, +} from "./_content-space-access.js"; +import { + personalContentSpaceId, + provisionContentSpaces, + provisionSourceBackedContentSpace, +} from "./_content-spaces.js"; +import { + LOCAL_FOLDER_SOURCE_TYPE, + localFolderSourceCapabilities, + localFolderSourceId, + localFolderSourceMetadata, +} from "./_local-folder-source.js"; + +const truthPolicySchema = z + .enum(["database_primary", "source_primary", "reviewed_bidirectional"]) + .default("database_primary"); + +export default defineAction({ + description: + "Connect an opaque trusted local-folder handle to a canonical Files database. The browser or Desktop bridge keeps the real handle/path; Content stores only safe connection metadata.", + schema: z.object({ + connectionId: z + .string() + .min(1) + .max(300) + .describe("Stable opaque ID from the trusted browser/Desktop registry"), + label: z.string().min(1).max(200).describe("User-visible folder label"), + spaceId: z + .string() + .optional() + .describe( + "Existing Content space whose Files database receives the source", + ), + databaseId: z + .string() + .optional() + .describe("Existing canonical Files database receiving the source"), + createSourceBackedSpace: z + .boolean() + .optional() + .default(false) + .describe("Create a separate private Content space for this folder"), + truthPolicy: truthPolicySchema.describe( + "Whether Content, the folder, or reviewed conflict resolution is authoritative", + ), + dryRun: z.boolean().optional().default(false), + }), + run: async (args) => { + const userEmail = getRequestUserEmail(); + if (!userEmail) throw new Error("no authenticated user"); + if (args.spaceId && args.databaseId) { + throw new Error("Choose either spaceId or databaseId, not both"); + } + if (args.createSourceBackedSpace && (args.spaceId || args.databaseId)) { + throw new Error( + "A new source-backed space cannot also target an existing space or database", + ); + } + + const db = getDb(); + const connectionId = args.connectionId.trim(); + const label = args.label.trim(); + const truthPolicy = args.truthPolicy as ContentDatabaseSourceTruthPolicy; + if (args.dryRun) { + const [existing] = await db + .select({ + source: schema.contentDatabaseSources, + database: schema.contentDatabases, + }) + .from(schema.contentDatabaseSources) + .innerJoin( + schema.contentDatabases, + eq( + schema.contentDatabases.id, + schema.contentDatabaseSources.databaseId, + ), + ) + .where( + and( + eq( + schema.contentDatabaseSources.sourceType, + LOCAL_FOLDER_SOURCE_TYPE, + ), + eq(schema.contentDatabaseSources.sourceTable, connectionId), + eq( + schema.contentDatabaseSources.ownerEmail, + normalizeContentSpaceEmail(userEmail), + ), + ), + ); + if (!existing?.database.spaceId) { + return { + connected: false as const, + sourceId: null, + connectionId, + label, + truthPolicy, + spaceId: null, + filesDatabaseId: null, + }; + } + await resolveContentSpaceAccess(existing.database.spaceId, "editor"); + return { + connected: true as const, + sourceId: existing.source.id, + sourceType: LOCAL_FOLDER_SOURCE_TYPE, + connectionId, + label, + truthPolicy, + spaceId: existing.database.spaceId, + filesDatabaseId: existing.database.id, + }; + } + await provisionContentSpaces(db, userEmail); + + let targetSpaceId = args.spaceId; + let targetDatabaseId = args.databaseId; + if (args.createSourceBackedSpace) { + const provisioned = await provisionSourceBackedContentSpace( + db, + userEmail, + { connectionId, name: label }, + ); + targetSpaceId = provisioned.spaceId; + targetDatabaseId = provisioned.filesDatabaseId; + } else if (!targetSpaceId && !targetDatabaseId) { + targetSpaceId = personalContentSpaceId(userEmail); + } + + let database: typeof schema.contentDatabases.$inferSelect | undefined; + if (targetDatabaseId) { + [database] = await db + .select() + .from(schema.contentDatabases) + .where(eq(schema.contentDatabases.id, targetDatabaseId)); + if (!database || database.deletedAt || database.systemRole !== "files") { + throw new Error( + `Database "${targetDatabaseId}" is not a canonical Files database`, + ); + } + if (!database.spaceId) { + throw new Error(`Database "${targetDatabaseId}" has no Content space`); + } + targetSpaceId = database.spaceId; + } + if (!targetSpaceId) throw new Error("Content space is required"); + + await resolveContentSpaceAccess(targetSpaceId, "editor"); + if (!database) { + [database] = await db + .select() + .from(schema.contentDatabases) + .where( + and( + eq(schema.contentDatabases.spaceId, targetSpaceId), + eq(schema.contentDatabases.systemRole, "files"), + ), + ); + } + if (!database || database.deletedAt) { + throw new Error(`Content space "${targetSpaceId}" has no Files database`); + } + + const id = localFolderSourceId(database.id, connectionId); + const now = new Date().toISOString(); + const values = { + id, + ownerEmail: database.ownerEmail, + orgId: database.orgId, + databaseId: database.id, + sourceType: LOCAL_FOLDER_SOURCE_TYPE, + sourceName: label, + sourceTable: connectionId, + syncState: "linked", + freshness: "unknown", + capabilitiesJson: JSON.stringify(localFolderSourceCapabilities()), + metadataJson: JSON.stringify( + localFolderSourceMetadata({ connectionId, label, truthPolicy }), + ), + lastError: null, + createdAt: now, + updatedAt: now, + }; + await db + .insert(schema.contentDatabaseSources) + .values(values) + .onConflictDoNothing(); + await db + .update(schema.contentDatabaseSources) + .set({ + sourceName: label, + capabilitiesJson: values.capabilitiesJson, + metadataJson: values.metadataJson, + updatedAt: now, + }) + .where(eq(schema.contentDatabaseSources.id, id)); + + await writeAppState("refresh-signal", { ts: Date.now() }); + return { + connected: true as const, + sourceId: id, + sourceType: LOCAL_FOLDER_SOURCE_TYPE, + connectionId, + label, + truthPolicy, + spaceId: targetSpaceId, + filesDatabaseId: database.id, + }; + }, +}); diff --git a/templates/content/actions/content-files.db.test.ts b/templates/content/actions/content-files.db.test.ts new file mode 100644 index 0000000000..de4cd0840a --- /dev/null +++ b/templates/content/actions/content-files.db.test.ts @@ -0,0 +1,272 @@ +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 { and, eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +const TEST_DB_PATH = join( + tmpdir(), + `content-files-${process.pid}-${Date.now()}.sqlite`, +); +const OWNER = "files-owner@example.com"; +const ORG_ID = "files-org"; + +type Schema = typeof import("../server/db/schema.js"); +let getDb: () => any; +let schema: Schema; +let provisionContentSpaces: typeof import("./_content-spaces.js").provisionContentSpaces; +let personalContentSpaceId: typeof import("./_content-spaces.js").personalContentSpaceId; +let organizationContentSpaceId: typeof import("./_content-spaces.js").organizationContentSpaceId; +let reconcileContentFilesMemberships: typeof import("./_content-files.js").reconcileContentFilesMemberships; + +beforeAll(async () => { + process.env.DATABASE_URL = `file:${TEST_DB_PATH}`; + const dbModule = await import("../server/db/index.js"); + getDb = dbModule.getDb; + schema = dbModule.schema; + ({ + provisionContentSpaces, + personalContentSpaceId, + organizationContentSpaceId, + } = await import("./_content-spaces.js")); + ({ reconcileContentFilesMemberships } = await import("./_content-files.js")); + 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 INTEGER NOT NULL + )`); + 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 INTEGER NOT NULL + )`); + await getDbExec().execute({ + sql: "INSERT INTO organizations (id, name, created_by, created_at) VALUES (?, ?, ?, ?)", + args: [ORG_ID, "Files Org", OWNER, Date.now()], + }); + await getDbExec().execute({ + sql: "INSERT INTO org_members (id, org_id, email, role, joined_at) VALUES (?, ?, ?, ?, ?)", + args: ["files-owner-membership", ORG_ID, OWNER, "owner", Date.now()], + }); + await runWithRequestContext({ userEmail: OWNER }, () => + provisionContentSpaces(getDb(), OWNER), + ); +}, 60000); + +afterAll(() => { + for (const suffix of ["", "-shm", "-wal"]) + rmSync(`${TEST_DB_PATH}${suffix}`, { force: true }); +}); + +async function createLegacyDocument(args: { + id: string; + orgId: string | null; + title: string; +}) { + const now = new Date().toISOString(); + await getDb() + .insert(schema.documents) + .values({ + id: args.id, + ownerEmail: OWNER, + orgId: args.orgId, + spaceId: null, + title: args.title, + content: "", + description: "", + position: 0, + isFavorite: 0, + hideFromSearch: 0, + visibility: args.orgId ? "org" : "private", + createdAt: now, + updatedAt: now, + }); +} + +async function getFilesDatabase(spaceId: string) { + const [database] = await getDb() + .select() + .from(schema.contentDatabases) + .where( + and( + eq(schema.contentDatabases.spaceId, spaceId), + eq(schema.contentDatabases.systemRole, "files"), + ), + ); + if (!database) throw new Error(`Missing Files database for ${spaceId}`); + return database; +} + +describe("Content Files membership reconciliation", () => { + it("assigns personal and organization legacy pages to their canonical Files databases", async () => { + await createLegacyDocument({ + id: "legacy-personal", + orgId: null, + title: "Personal", + }); + await createLegacyDocument({ + id: "legacy-org", + orgId: ORG_ID, + title: "Organization", + }); + const personalSpaceId = personalContentSpaceId(OWNER); + const orgSpaceId = organizationContentSpaceId(ORG_ID); + const personalFiles = await getFilesDatabase(personalSpaceId); + const orgFiles = await getFilesDatabase(orgSpaceId); + const now = new Date().toISOString(); + await getDb().insert(schema.contentDatabaseItems).values({ + id: "wrong-files-membership", + ownerEmail: OWNER, + orgId: null, + databaseId: orgFiles.id, + documentId: "legacy-personal", + position: 99, + createdAt: now, + updatedAt: now, + }); + + const result = await runWithRequestContext({ userEmail: OWNER }, () => + reconcileContentFilesMemberships(getDb(), OWNER), + ); + expect(result.assignedSpaces).toBe(2); + const documents = await getDb().select().from(schema.documents); + expect( + documents.find((document: any) => document.id === "legacy-personal") + ?.spaceId, + ).toBe(personalSpaceId); + expect( + documents.find((document: any) => document.id === "legacy-org")?.spaceId, + ).toBe(orgSpaceId); + const personalItems = await getDb() + .select() + .from(schema.contentDatabaseItems) + .where(eq(schema.contentDatabaseItems.documentId, "legacy-personal")); + const orgItems = await getDb() + .select() + .from(schema.contentDatabaseItems) + .where(eq(schema.contentDatabaseItems.documentId, "legacy-org")); + expect( + personalItems.filter((item: any) => item.databaseId === personalFiles.id), + ).toHaveLength(1); + expect( + personalItems.filter((item: any) => item.databaseId === orgFiles.id), + ).toHaveLength(0); + expect( + orgItems.filter((item: any) => item.databaseId === orgFiles.id), + ).toHaveLength(1); + }); + + it("is idempotent and never adds a Files database backing document to a Files database", async () => { + const personalFiles = await getFilesDatabase(personalContentSpaceId(OWNER)); + const second = await runWithRequestContext({ userEmail: OWNER }, () => + reconcileContentFilesMemberships(getDb(), OWNER), + ); + expect(second).toMatchObject({ + assignedSpaces: 0, + insertedMemberships: 0, + removedMemberships: 0, + }); + const selfItems = await getDb() + .select() + .from(schema.contentDatabaseItems) + .where( + and( + eq(schema.contentDatabaseItems.databaseId, personalFiles.id), + eq(schema.contentDatabaseItems.documentId, personalFiles.documentId), + ), + ); + expect(selfItems).toHaveLength(0); + }); + + it("repairs duplicate canonical memberships before uniqueness is enforced", async () => { + const personalFiles = await getFilesDatabase(personalContentSpaceId(OWNER)); + const [canonicalMembership] = await getDb() + .select() + .from(schema.contentDatabaseItems) + .where( + and( + eq(schema.contentDatabaseItems.databaseId, personalFiles.id), + eq(schema.contentDatabaseItems.documentId, "legacy-personal"), + ), + ); + if (!canonicalMembership) throw new Error("Missing canonical membership"); + await getDbExec().execute( + "DROP INDEX content_database_items_database_document_unique", + ); + try { + const now = new Date().toISOString(); + await getDb().insert(schema.contentDatabaseItems).values({ + id: "duplicate-files-membership", + ownerEmail: OWNER, + orgId: null, + databaseId: personalFiles.id, + documentId: "legacy-personal", + position: 100, + createdAt: now, + updatedAt: now, + }); + await getDb().insert(schema.contentDatabaseSourceRows).values({ + id: "duplicate-membership-source-row", + ownerEmail: OWNER, + sourceId: "duplicate-membership-source", + databaseItemId: "duplicate-files-membership", + documentId: "legacy-personal", + sourceRowId: "source-row", + sourceQualifiedId: "source:row", + sourceDisplayKey: "row", + sourceValuesJson: "{}", + createdAt: now, + updatedAt: now, + }); + await getDb().insert(schema.contentSpaceCatalogItems).values({ + id: "duplicate-membership-catalog-reference", + ownerEmail: OWNER, + catalogDatabaseId: "test-catalog", + databaseItemId: "duplicate-files-membership", + documentId: "legacy-personal", + spaceId: "test-space", + createdAt: now, + updatedAt: now, + }); + const result = await runWithRequestContext({ userEmail: OWNER }, () => + reconcileContentFilesMemberships(getDb(), OWNER), + ); + expect(result.removedMemberships).toBe(1); + const memberships = await getDb() + .select() + .from(schema.contentDatabaseItems) + .where( + and( + eq(schema.contentDatabaseItems.databaseId, personalFiles.id), + eq(schema.contentDatabaseItems.documentId, "legacy-personal"), + ), + ); + expect(memberships).toHaveLength(1); + const [sourceRow] = await getDb() + .select() + .from(schema.contentDatabaseSourceRows) + .where( + eq( + schema.contentDatabaseSourceRows.id, + "duplicate-membership-source-row", + ), + ); + const [catalogReference] = await getDb() + .select() + .from(schema.contentSpaceCatalogItems) + .where( + eq( + schema.contentSpaceCatalogItems.id, + "duplicate-membership-catalog-reference", + ), + ); + expect(sourceRow?.databaseItemId).toBe(canonicalMembership.id); + expect(catalogReference?.databaseItemId).toBe(canonicalMembership.id); + } finally { + await getDbExec().execute( + "CREATE UNIQUE INDEX IF NOT EXISTS content_database_items_database_document_unique ON content_database_items (database_id, document_id)", + ); + } + }); +}); diff --git a/templates/content/actions/content-spaces.db.test.ts b/templates/content/actions/content-spaces.db.test.ts new file mode 100644 index 0000000000..03bd26ae94 --- /dev/null +++ b/templates/content/actions/content-spaces.db.test.ts @@ -0,0 +1,256 @@ +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 { and, eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +const TEST_DB_PATH = join( + tmpdir(), + `content-spaces-${process.pid}-${Date.now()}.sqlite`, +); + +type Schema = typeof import("../server/db/schema.js"); +let getDb: () => any; +let schema: Schema; +let provisionContentSpaces: typeof import("./_content-spaces.js").provisionContentSpaces; +let personalContentSpaceId: typeof import("./_content-spaces.js").personalContentSpaceId; +let organizationContentSpaceId: typeof import("./_content-spaces.js").organizationContentSpaceId; +let resolveContentSpaceAccess: typeof import("./_content-space-access.js").resolveContentSpaceAccess; +let listContentSpacesAction: typeof import("./list-content-spaces.js").default; +let ensureContentSpacesAction: typeof import("./ensure-content-spaces.js").default; +let deleteContentDatabaseAction: typeof import("./delete-content-database.js").default; +let deleteDocumentAction: typeof import("./delete-document.js").default; + +const OWNER = "owner@example.com"; +const MEMBER = "member@example.com"; +const OUTSIDER = "outsider@example.com"; + +beforeAll(async () => { + process.env.DATABASE_URL = `file:${TEST_DB_PATH}`; + const dbModule = await import("../server/db/index.js"); + getDb = dbModule.getDb; + schema = dbModule.schema; + ({ + provisionContentSpaces, + personalContentSpaceId, + organizationContentSpaceId, + } = await import("./_content-spaces.js")); + ({ resolveContentSpaceAccess } = await import("./_content-space-access.js")); + listContentSpacesAction = (await import("./list-content-spaces.js")).default; + ensureContentSpacesAction = (await import("./ensure-content-spaces.js")) + .default; + deleteContentDatabaseAction = (await import("./delete-content-database.js")) + .default; + deleteDocumentAction = (await import("./delete-document.js")).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 INTEGER NOT NULL + )`); + 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 INTEGER NOT NULL + )`); +}, 60000); + +afterAll(() => { + for (const suffix of ["", "-shm", "-wal"]) + rmSync(`${TEST_DB_PATH}${suffix}`, { force: true }); +}); + +async function addOrganization(id: string, name: string, owner = OWNER) { + await getDbExec().execute({ + sql: "INSERT INTO organizations (id, name, created_by, created_at) VALUES (?, ?, ?, ?)", + args: [id, name, owner, Date.now()], + }); +} + +async function addMember( + id: string, + orgId: string, + email: string, + role = "member", +) { + await getDbExec().execute({ + sql: "INSERT INTO org_members (id, org_id, email, role, joined_at) VALUES (?, ?, ?, ?, ?)", + args: [id, orgId, email, role, Date.now()], + }); +} + +describe("Content space provisioning", () => { + it("is idempotent, opaque, and creates exactly one Files and Workspaces database", async () => { + const first = await runWithRequestContext({ userEmail: OWNER }, () => + provisionContentSpaces(getDb(), OWNER), + ); + const second = await runWithRequestContext({ userEmail: OWNER }, () => + provisionContentSpaces(getDb(), OWNER), + ); + expect(first.personalSpaceId).toBe(second.personalSpaceId); + expect(first.personalSpaceId).not.toContain(OWNER); + expect(first.personalSpaceId).toMatch( + /^content_space_personal_[a-f0-9]{32}$/, + ); + expect(second.created).toEqual({ + spaces: 0, + databases: 0, + documents: 0, + catalogItems: 0, + }); + const databases = await getDb() + .select() + .from(schema.contentDatabases) + .where(eq(schema.contentDatabases.spaceId, first.personalSpaceId)); + expect( + databases.filter((database: any) => database.systemRole === "files"), + ).toHaveLength(1); + expect( + databases.filter((database: any) => database.systemRole === "workspaces"), + ).toHaveLength(1); + const files = databases.find( + (database: any) => database.systemRole === "files", + ); + expect(JSON.parse(files.viewConfigJson)).toMatchObject({ + activeViewId: "default", + views: [{ id: "default", type: "sidebar", name: "Sidebar" }], + }); + const filesSelfItems = await getDb() + .select() + .from(schema.contentDatabaseItems) + .where( + and( + eq(schema.contentDatabaseItems.databaseId, files.id), + eq(schema.contentDatabaseItems.documentId, files.documentId), + ), + ); + expect(filesSelfItems).toHaveLength(0); + await runWithRequestContext({ userEmail: OWNER }, async () => { + await expect( + deleteContentDatabaseAction.run({ databaseId: files.id }), + ).rejects.toThrow("System Content databases cannot be deleted"); + await expect( + deleteDocumentAction.run({ id: files.documentId }), + ).rejects.toThrow("System Content database documents cannot be deleted"); + }); + }); + + it("automatically reconciles legacy top-level documents into Files", async () => { + const now = new Date().toISOString(); + await getDb().insert(schema.documents).values({ + id: "legacy-top-level-page", + ownerEmail: OWNER, + orgId: null, + spaceId: null, + parentId: null, + title: "Legacy page", + content: "Still here", + position: 40, + visibility: "private", + createdAt: now, + updatedAt: now, + }); + const result = await runWithRequestContext({ userEmail: OWNER }, () => + ensureContentSpacesAction.run({}), + ); + const [document] = await getDb() + .select() + .from(schema.documents) + .where(eq(schema.documents.id, "legacy-top-level-page")); + expect(document.spaceId).toBe(result.personalSpaceId); + await expect( + getDb() + .select() + .from(schema.contentDatabaseItems) + .where( + and( + eq( + schema.contentDatabaseItems.databaseId, + result.personalFilesDatabaseId, + ), + eq(schema.contentDatabaseItems.documentId, "legacy-top-level-page"), + ), + ), + ).resolves.toHaveLength(1); + }); + + it("provisions every current organization membership and keeps organization Files documents org-visible", async () => { + await addOrganization("org-alpha", "Alpha"); + await addOrganization("org-beta", "Beta"); + await addMember("owner-alpha", "org-alpha", OWNER, "owner"); + await addMember("owner-beta", "org-beta", OWNER, "admin"); + const result = await runWithRequestContext({ userEmail: OWNER }, () => + provisionContentSpaces(getDb(), OWNER), + ); + expect(result.spaceIds).toEqual( + expect.arrayContaining([ + organizationContentSpaceId("org-alpha"), + organizationContentSpaceId("org-beta"), + ]), + ); + const orgSpaces = await getDb() + .select() + .from(schema.contentSpaces) + .where(eq(schema.contentSpaces.orgId, "org-alpha")); + expect(orgSpaces).toHaveLength(1); + const [files] = await getDb() + .select() + .from(schema.contentDatabases) + .where( + and( + eq(schema.contentDatabases.spaceId, orgSpaces[0]!.id), + eq(schema.contentDatabases.systemRole, "files"), + ), + ); + const [document] = await getDb() + .select() + .from(schema.documents) + .where(eq(schema.documents.id, files!.documentId)); + expect(document).toMatchObject({ + ownerEmail: OWNER, + orgId: "org-alpha", + visibility: "org", + }); + const catalogItems = await getDb() + .select() + .from(schema.contentDatabaseItems) + .where( + eq(schema.contentDatabaseItems.databaseId, result.catalogDatabaseId), + ); + expect(catalogItems.map((item: any) => item.position).sort()).toEqual([ + 0, 1, 2, + ]); + }); + + it("does not let a stale catalog reference grant a non-member visibility", async () => { + await addOrganization("org-shared", "Shared"); + await addMember("member-shared", "org-shared", MEMBER); + await runWithRequestContext({ userEmail: MEMBER }, () => + provisionContentSpaces(getDb(), MEMBER), + ); + const spaceId = organizationContentSpaceId("org-shared"); + await getDbExec().execute({ + sql: "DELETE FROM org_members WHERE id = ?", + args: ["member-shared"], + }); + await runWithRequestContext({ userEmail: MEMBER }, async () => { + await expect(resolveContentSpaceAccess(spaceId)).rejects.toThrow( + "Not authorized", + ); + await expect(listContentSpacesAction.run({})).resolves.toMatchObject({ + spaces: expect.not.arrayContaining([ + expect.objectContaining({ id: spaceId }), + ]), + }); + }); + }); + + it("denies an unrelated authenticated user from a personal space", async () => { + const spaceId = personalContentSpaceId(OWNER); + await runWithRequestContext({ userEmail: OUTSIDER }, () => + expect(resolveContentSpaceAccess(spaceId)).rejects.toThrow( + "Not authorized", + ), + ); + }); +}); diff --git a/templates/content/actions/create-content-database.ts b/templates/content/actions/create-content-database.ts index 88f8ee6224..50761ebd6f 100644 --- a/templates/content/actions/create-content-database.ts +++ b/templates/content/actions/create-content-database.ts @@ -14,6 +14,12 @@ import type { ContentDatabaseResponse, CreateDatabaseRequest, } from "../shared/api.js"; +import { ensureDocumentFilesMembership } from "./_content-files.js"; +import { resolveContentSpaceAccess } from "./_content-space-access.js"; +import { + organizationContentSpaceId, + provisionContentSpaces, +} from "./_content-spaces.js"; import { getContentDatabaseResponse } from "./_database-utils.js"; import { documentsPositionScope, withPositionLock } from "./_position-utils.js"; import { nanoid, seedDefaultBlocksField } from "./_property-utils.js"; @@ -23,6 +29,10 @@ const createContentDatabaseSchema = z.object({ .string() .optional() .describe("Existing document to convert into a database page"), + spaceId: z + .string() + .optional() + .describe("Content space for a new top-level database"), parentId: z .string() .nullish() @@ -73,13 +83,84 @@ export async function createContentDatabaseCore( args: CreateDatabaseRequest, options: { db?: any } = {}, ): Promise { - const databaseId = await createContentDatabaseRecord(args, options); + const db = options.db ?? getDb(); + const resolvedSpaceId = await resolveContentDatabaseSpace(args, db); + let databaseId: string | null = null; + if (options.db) { + databaseId = await createContentDatabaseRecord(args, { + db, + spaceId: resolvedSpaceId, + }); + } else { + await db.transaction(async (tx: any) => { + databaseId = await createContentDatabaseRecord(args, { + db: tx, + spaceId: resolvedSpaceId, + }); + }); + } + if (!databaseId) throw new Error("Content database was not created"); return getContentDatabaseResponse(databaseId); } +async function healLegacyDocumentSpace(db: any, resource: any) { + const provisioned = await provisionContentSpaces(db, resource.ownerEmail); + const spaceId = resource.orgId + ? organizationContentSpaceId(resource.orgId) + : provisioned.personalSpaceId; + const [space] = await db + .select({ id: schema.contentSpaces.id }) + .from(schema.contentSpaces) + .where(eq(schema.contentSpaces.id, spaceId)); + if (!space) { + throw new Error(`Unable to resolve a Content space for "${resource.id}"`); + } + const now = new Date().toISOString(); + await db + .update(schema.documents) + .set({ spaceId, updatedAt: now }) + .where(eq(schema.documents.id, resource.id)); + await ensureDocumentFilesMembership(db, resource.id, now); + return spaceId; +} + +export async function resolveContentDatabaseSpace( + args: CreateDatabaseRequest, + db: any, +): Promise { + if (args.documentId) { + const access = await assertAccess("document", args.documentId, "editor"); + const spaceId = + (access.resource.spaceId as string | null) ?? + (await healLegacyDocumentSpace(db, access.resource)); + if (args.spaceId && args.spaceId !== spaceId) { + throw new Error( + "A converted database must keep its document Content space", + ); + } + return spaceId; + } + if (args.parentId) { + const access = await assertAccess("document", args.parentId, "editor"); + const spaceId = + (access.resource.spaceId as string | null) ?? + (await healLegacyDocumentSpace(db, access.resource)); + if (args.spaceId && args.spaceId !== spaceId) { + throw new Error("Nested databases must use their parent Content space"); + } + return spaceId; + } + const userEmail = getRequestUserEmail(); + if (!userEmail) throw new Error("no authenticated user"); + const provisioned = await provisionContentSpaces(db, userEmail); + const spaceId = args.spaceId ?? provisioned.personalSpaceId; + await resolveContentSpaceAccess(spaceId); + return spaceId; +} + export async function createContentDatabaseRecord( args: CreateDatabaseRequest, - options: { db?: any } = {}, + options: { db?: any; spaceId?: string } = {}, ): Promise { const db = options.db ?? getDb(); const now = new Date().toISOString(); @@ -89,6 +170,7 @@ export async function createContentDatabaseRecord( let ownerEmail = getRequestUserEmail(); if (!ownerEmail) throw new Error("no authenticated user"); let orgId = getRequestOrgId() ?? null; + let spaceId = options.spaceId ?? null; let inheritedShares: Array<{ principalType: "user" | "org"; principalId: string; @@ -100,13 +182,31 @@ export async function createContentDatabaseRecord( const document = access.resource; ownerEmail = document.ownerEmail as string; orgId = (document.orgId as string | null) ?? null; + spaceId = (document.spaceId as string | null) ?? spaceId; + if (!spaceId) { + throw new Error(`Document "${documentId}" has no Content space`); + } + if (args.spaceId && args.spaceId !== spaceId) { + throw new Error( + "A converted database must keep its document Content space", + ); + } title = databaseTitleForPage(title, document.title); const [existing] = await db .select() .from(schema.contentDatabases) .where(eq(schema.contentDatabases.documentId, documentId)); - if (existing) return existing.id; + if (existing) { + if (existing.spaceId !== spaceId) { + await db + .update(schema.contentDatabases) + .set({ spaceId, updatedAt: now }) + .where(eq(schema.contentDatabases.id, existing.id)); + } + await ensureDocumentFilesMembership(db, documentId, now); + return existing.id; + } if (title && title !== document.title && !document.title.trim()) { await db @@ -131,6 +231,13 @@ export async function createContentDatabaseRecord( const parent = parentAccess.resource; ownerEmail = parent.ownerEmail as string; orgId = (parent.orgId as string | null) ?? null; + spaceId = (parent.spaceId as string | null) ?? spaceId; + if (!spaceId) { + throw new Error(`Parent document "${parentId}" has no Content space`); + } + if (args.spaceId && args.spaceId !== spaceId) { + throw new Error("Nested databases must use their parent Content space"); + } visibility = parent.visibility ?? "private"; hideFromSearch = parent.hideFromSearch ?? 0; inheritedShares = await db @@ -141,6 +248,16 @@ export async function createContentDatabaseRecord( }) .from(schema.documentShares) .where(eq(schema.documentShares.resourceId, parentId)); + } else { + if (!spaceId) { + throw new Error( + "A top-level database requires a resolved Content space", + ); + } + const spaceAccess = await resolveContentSpaceAccess(spaceId); + ownerEmail = getRequestUserEmail() ?? ownerEmail; + orgId = spaceAccess.space.orgId; + visibility = orgId ? "org" : "private"; } documentId = nanoid(); @@ -168,6 +285,7 @@ export async function createContentDatabaseRecord( await db.insert(schema.documents).values({ id: documentId!, + spaceId, ownerEmail: resolvedOwnerEmail, orgId, parentId, @@ -203,6 +321,7 @@ export async function createContentDatabaseRecord( const databaseId = nanoid(); await db.insert(schema.contentDatabases).values({ id: databaseId, + spaceId, ownerEmail, orgId, documentId, @@ -214,6 +333,7 @@ export async function createContentDatabaseRecord( // Every database is seeded with one primary "Content" Blocks field, backed // by `documents.content`, so each row's body is a first-class property. await seedDefaultBlocksField({ databaseId, ownerEmail, orgId, now, db }); + await ensureDocumentFilesMembership(db, documentId, now); return databaseId; } diff --git a/templates/content/actions/create-document.test.ts b/templates/content/actions/create-document.test.ts deleted file mode 100644 index ccb63c756b..0000000000 --- a/templates/content/actions/create-document.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const mocks = vi.hoisted(() => ({ - createLocalFileDocument: vi.fn(), - getRequestUserEmail: vi.fn(), - isContentLocalFileMode: vi.fn(), - writeAppState: vi.fn(), -})); - -vi.mock("@agent-native/core/application-state", () => ({ - writeAppState: mocks.writeAppState, -})); - -vi.mock("@agent-native/core/server/request-context", () => ({ - getRequestOrgId: vi.fn(() => null), - getRequestUserEmail: mocks.getRequestUserEmail, -})); - -vi.mock("./_local-file-documents.js", () => ({ - createLocalFileDocument: mocks.createLocalFileDocument, - isContentLocalFileMode: mocks.isContentLocalFileMode, -})); - -import createDocument from "./create-document"; - -const OLD_AGENT_USER_EMAIL = process.env.AGENT_USER_EMAIL; - -beforeEach(() => { - if (OLD_AGENT_USER_EMAIL === undefined) { - delete process.env.AGENT_USER_EMAIL; - } else { - process.env.AGENT_USER_EMAIL = OLD_AGENT_USER_EMAIL; - } - mocks.createLocalFileDocument.mockReset(); - mocks.getRequestUserEmail.mockReset(); - mocks.isContentLocalFileMode.mockReset(); - mocks.writeAppState.mockReset(); -}); - -describe("create-document", () => { - it("preflights app-state identity before writing local files", async () => { - delete process.env.AGENT_USER_EMAIL; - mocks.getRequestUserEmail.mockReturnValue(null); - mocks.isContentLocalFileMode.mockResolvedValue(true); - - await expect(createDocument.run({ title: "Ghost" })).rejects.toThrow( - "Application state access requires an authenticated request context or AGENT_USER_EMAIL env var", - ); - - expect(mocks.createLocalFileDocument).not.toHaveBeenCalled(); - expect(mocks.writeAppState).not.toHaveBeenCalled(); - }); -}); diff --git a/templates/content/actions/create-document.ts b/templates/content/actions/create-document.ts index fa557bd432..4747376629 100644 --- a/templates/content/actions/create-document.ts +++ b/templates/content/actions/create-document.ts @@ -14,10 +14,9 @@ import { parseDocumentFavorite, parseDocumentHideFromSearch, } from "../server/lib/documents.js"; -import { - createLocalFileDocument, - isContentLocalFileMode, -} from "./_local-file-documents.js"; +import { ensureDocumentFilesMembership } from "./_content-files.js"; +import { resolveContentSpaceAccess } from "./_content-space-access.js"; +import { provisionContentSpaces } from "./_content-spaces.js"; import { documentsPositionScope, withPositionLock } from "./_position-utils.js"; function nanoid(size = 12): string { @@ -29,13 +28,6 @@ function nanoid(size = 12): string { return id; } -function assertCanWriteAppState() { - if (getRequestUserEmail() || process.env.AGENT_USER_EMAIL) return; - throw new Error( - "Application state access requires an authenticated request context or AGENT_USER_EMAIL env var", - ); -} - export default defineAction({ description: "Create a new document.", schema: z.object({ @@ -43,6 +35,10 @@ export default defineAction({ .string() .optional() .describe("Pre-generated document ID (for optimistic UI)"), + spaceId: z + .string() + .optional() + .describe("Content space for a new top-level document"), title: z.string().describe("Document title"), content: z.string().optional().describe("Markdown content"), description: z @@ -66,21 +62,6 @@ export default defineAction({ }), }, run: async (args) => { - if (await isContentLocalFileMode()) { - assertCanWriteAppState(); - const doc = await createLocalFileDocument(args); - await writeAppState("refresh-signal", { ts: Date.now() }); - return { - ...doc, - urlPath: `/page/${doc.id}`, - deepLink: buildDeepLink({ - app: "content", - view: "editor", - params: { documentId: doc.id }, - }), - }; - } - const title = args.title; let content = args.content || ""; @@ -130,6 +111,28 @@ export default defineAction({ .where(eq(schema.documentShares.resourceId, parentId)); } + let spaceId: string; + if (parentId) { + const [parent] = await db + .select({ spaceId: schema.documents.spaceId }) + .from(schema.documents) + .where(eq(schema.documents.id, parentId)); + if (!parent?.spaceId) { + throw new Error(`Parent document "${parentId}" has no Content space`); + } + if (args.spaceId && args.spaceId !== parent.spaceId) { + throw new Error("Nested documents must use their parent Content space"); + } + spaceId = parent.spaceId; + } else { + const provisioned = await provisionContentSpaces(db, currentUserEmail); + spaceId = args.spaceId ?? provisioned.personalSpaceId; + const spaceAccess = await resolveContentSpaceAccess(spaceId); + ownerEmail = currentUserEmail; + orgId = spaceAccess.space.orgId; + visibility = orgId ? "org" : "private"; + } + const now = new Date().toISOString(); const id = args.id || nanoid(); @@ -154,39 +157,43 @@ export default defineAction({ const position = (maxPos[0]?.max ?? -1) + 1; - await db.insert(schema.documents).values({ - id, - ownerEmail, - orgId, - parentId, - title, - content, - description, - icon, - position, - isFavorite: 0, - hideFromSearch, - visibility, - createdAt: now, - updatedAt: now, + await db.transaction(async (tx) => { + await tx.insert(schema.documents).values({ + id, + spaceId, + ownerEmail, + orgId, + parentId, + title, + content, + description, + icon, + position, + isFavorite: 0, + hideFromSearch, + visibility, + createdAt: now, + updatedAt: now, + }); + + if (inheritedShares.length > 0) { + await tx.insert(schema.documentShares).values( + inheritedShares.map((share) => ({ + id: nanoid(), + resourceId: id, + principalType: share.principalType, + principalId: share.principalId, + role: share.role, + createdBy: currentUserEmail, + createdAt: now, + })), + ); + } + await ensureDocumentFilesMembership(tx, id, now); }); }, ); - if (inheritedShares.length > 0) { - await db.insert(schema.documentShares).values( - inheritedShares.map((share) => ({ - id: nanoid(), - resourceId: id, - principalType: share.principalType, - principalId: share.principalId, - role: share.role, - createdBy: currentUserEmail, - createdAt: now, - })), - ); - } - const [doc] = await db .select() .from(schema.documents) diff --git a/templates/content/actions/create-inline-content-database.ts b/templates/content/actions/create-inline-content-database.ts index c02a7b98fc..eeb6498f7e 100644 --- a/templates/content/actions/create-inline-content-database.ts +++ b/templates/content/actions/create-inline-content-database.ts @@ -10,6 +10,7 @@ import { nanoid } from "./_property-utils.js"; import { createContentDatabaseRecord, databaseTitleForPage, + resolveContentDatabaseSpace, } from "./create-content-database.js"; function createInlineDatabaseBlockId(): string { @@ -33,6 +34,10 @@ export default defineAction({ const ownerBlockId = createInlineDatabaseBlockId(); let databaseId: string | null = null; let databaseDocumentId: string | null = null; + const spaceId = await resolveContentDatabaseSpace( + { parentId: hostDocumentId }, + db, + ); await db.transaction(async (tx) => { databaseId = await createContentDatabaseRecord( @@ -41,7 +46,7 @@ export default defineAction({ title: databaseTitleForPage(title), description, }, - { db: tx }, + { db: tx, spaceId }, ); const [updated] = await tx diff --git a/templates/content/actions/database-row-batch-actions.db.test.ts b/templates/content/actions/database-row-batch-actions.db.test.ts index 967b997487..eb3af80003 100644 --- a/templates/content/actions/database-row-batch-actions.db.test.ts +++ b/templates/content/actions/database-row-batch-actions.db.test.ts @@ -17,6 +17,7 @@ let schema: Schema; let duplicateDatabaseItemsAction: typeof import("./duplicate-database-items.js").default; let deleteDatabaseItemsAction: typeof import("./delete-database-items.js").default; let addDatabaseItemAction: typeof import("./add-database-item.js").default; +let spaceId: string; const OWNER = "owner@example.com"; const COLLABORATOR = "collaborator@example.com"; @@ -33,6 +34,30 @@ beforeAll(async () => { addDatabaseItemAction = (await import("./add-database-item.js")).default; const plugin = (await import("../server/plugins/db.js")).default; await plugin(undefined as any); + const { systemIdsForContentSpace } = await import("./_content-spaces.js"); + spaceId = `batch_space_${Date.now()}`; + const filesIds = systemIdsForContentSpace(spaceId, "files"); + const now = new Date().toISOString(); + await getDb().insert(schema.documents).values({ + id: filesIds.documentId, + spaceId, + ownerEmail: OWNER, + title: "Files", + content: "", + visibility: "private", + createdAt: now, + updatedAt: now, + }); + await getDb().insert(schema.contentDatabases).values({ + id: filesIds.databaseId, + spaceId, + systemRole: "files", + ownerEmail: OWNER, + documentId: filesIds.documentId, + title: "Files", + createdAt: now, + updatedAt: now, + }); }, 60000); afterAll(() => { @@ -61,6 +86,7 @@ async function createDocument(args: { const id = args.id ?? nextId("doc"); await db.insert(schema.documents).values({ id, + spaceId, ownerEmail: args.ownerEmail ?? OWNER, parentId: args.parentId ?? null, title: args.title ?? "Untitled", @@ -82,6 +108,7 @@ async function createDatabaseWithRows(rowCount: number) { }); await db.insert(schema.contentDatabases).values({ id: databaseId, + spaceId, ownerEmail: OWNER, documentId: databaseDocumentId, title: "Database", @@ -192,6 +219,14 @@ describe("database row batch actions", () => { expect(result.duplicatedItemIds).toHaveLength(2); expect(result.duplicatedDocumentIds).toHaveLength(2); + await expect( + db + .select({ spaceId: schema.documents.spaceId }) + .from(schema.documents) + .where( + inArray(schema.documents.id, result.duplicatedDocumentIds ?? []), + ), + ).resolves.toEqual([{ spaceId }, { spaceId }]); expect(result.duplicatedItemId).toBe(result.duplicatedItemIds?.[0]); expect(result.duplicatedDocumentId).toBe(result.duplicatedDocumentIds?.[0]); expect(result.sourceItemIds).toEqual([rows[1].itemId, rows[2].itemId]); diff --git a/templates/content/actions/delete-content-database.ts b/templates/content/actions/delete-content-database.ts index dd344f2d33..08fe83c5e9 100644 --- a/templates/content/actions/delete-content-database.ts +++ b/templates/content/actions/delete-content-database.ts @@ -14,6 +14,9 @@ export default defineAction({ }), run: async ({ databaseId }) => { const { database } = await assertContentDatabaseLifecycleAccess(databaseId); + if (database.systemRole) { + throw new Error("System Content databases cannot be deleted"); + } const db = getDb(); const deletedAt = database.deletedAt ?? new Date().toISOString(); diff --git a/templates/content/actions/delete-document.ts b/templates/content/actions/delete-document.ts index 6a3ad84ad9..ea42fc1fcb 100644 --- a/templates/content/actions/delete-document.ts +++ b/templates/content/actions/delete-document.ts @@ -6,11 +6,6 @@ import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; import { chunks } from "./_batch-utils.js"; -import { - deleteLocalFileDocument, - isLocalDocumentId, - isContentLocalFileMode, -} from "./_local-file-documents.js"; const DELETE_BATCH_SIZE = 90; @@ -365,16 +360,17 @@ export default defineAction({ const id = args.id; if (!id) throw new Error("--id is required"); - if ((await isContentLocalFileMode()) && isLocalDocumentId(id)) { - const result = await deleteLocalFileDocument(id); - await writeAppState("refresh-signal", { ts: Date.now() }); - return result; - } - const access = await assertAccess("document", id, "admin"); const existing = access.resource; const db = getDb(); + const [systemDatabase] = await db + .select({ systemRole: schema.contentDatabases.systemRole }) + .from(schema.contentDatabases) + .where(eq(schema.contentDatabases.documentId, id)); + if (systemDatabase?.systemRole) { + throw new Error("System Content database documents cannot be deleted"); + } const deleted = await deleteDocumentRecursive( db, id, diff --git a/templates/content/actions/disconnect-local-folder-source.ts b/templates/content/actions/disconnect-local-folder-source.ts new file mode 100644 index 0000000000..830a5320b5 --- /dev/null +++ b/templates/content/actions/disconnect-local-folder-source.ts @@ -0,0 +1,103 @@ +import { defineAction } from "@agent-native/core"; +import { writeAppState } from "@agent-native/core/application-state"; +import { and, eq, inArray } from "drizzle-orm"; +import { z } from "zod"; + +import { getDb, schema } from "../server/db/index.js"; +import { resolveContentSpaceAccess } from "./_content-space-access.js"; +import { LOCAL_FOLDER_SOURCE_TYPE } from "./_local-folder-source.js"; + +export default defineAction({ + description: + "Disconnect a local-folder source without deleting its local files or the SQL-backed Content pages it materialized.", + schema: z.object({ sourceId: z.string().min(1) }), + run: async ({ sourceId }) => { + const db = getDb(); + const [target] = await db + .select({ + source: schema.contentDatabaseSources, + database: schema.contentDatabases, + }) + .from(schema.contentDatabaseSources) + .innerJoin( + schema.contentDatabases, + eq( + schema.contentDatabases.id, + schema.contentDatabaseSources.databaseId, + ), + ) + .where(eq(schema.contentDatabaseSources.id, sourceId)); + if ( + !target || + target.source.sourceType !== LOCAL_FOLDER_SOURCE_TYPE || + !target.database.spaceId + ) { + throw new Error(`Local folder source "${sourceId}" not found`); + } + await resolveContentSpaceAccess(target.database.spaceId, "editor"); + const spaceId = target.database.spaceId; + const rows = await db + .select({ documentId: schema.contentDatabaseSourceRows.documentId }) + .from(schema.contentDatabaseSourceRows) + .where(eq(schema.contentDatabaseSourceRows.sourceId, sourceId)); + const documentIds = [...new Set(rows.map((row) => row.documentId))]; + const now = new Date().toISOString(); + + await db.transaction(async (tx: any) => { + await tx + .delete(schema.contentDatabaseSourceExecutions) + .where(eq(schema.contentDatabaseSourceExecutions.sourceId, sourceId)); + await tx + .delete(schema.contentDatabaseSourceExecutionClaims) + .where( + eq(schema.contentDatabaseSourceExecutionClaims.sourceId, sourceId), + ); + await tx + .delete(schema.contentDatabaseSourceChangeReviews) + .where( + eq(schema.contentDatabaseSourceChangeReviews.sourceId, sourceId), + ); + await tx + .delete(schema.contentDatabaseSourceChangeSets) + .where(eq(schema.contentDatabaseSourceChangeSets.sourceId, sourceId)); + await tx + .delete(schema.contentDatabaseBodyHydrationQueue) + .where(eq(schema.contentDatabaseBodyHydrationQueue.sourceId, sourceId)); + await tx + .delete(schema.contentDatabaseSourceFields) + .where(eq(schema.contentDatabaseSourceFields.sourceId, sourceId)); + await tx + .delete(schema.contentDatabaseSourceRows) + .where(eq(schema.contentDatabaseSourceRows.sourceId, sourceId)); + await tx + .delete(schema.contentDatabaseSources) + .where(eq(schema.contentDatabaseSources.id, sourceId)); + if (documentIds.length) { + await tx + .update(schema.documents) + .set({ + sourceMode: "database", + sourceKind: null, + sourcePath: null, + sourceAbsolutePath: null, + sourceRootPath: null, + sourceUpdatedAt: now, + updatedAt: now, + }) + .where( + and( + inArray(schema.documents.id, documentIds), + eq(schema.documents.spaceId, spaceId), + ), + ); + } + }); + await writeAppState("refresh-signal", { ts: Date.now() }); + return { + success: true, + sourceId, + disconnectedDocuments: documentIds.length, + localFilesDeleted: 0, + }; + }, +}); diff --git a/templates/content/actions/duplicate-database-item.ts b/templates/content/actions/duplicate-database-item.ts index af655088f5..eb467a4141 100644 --- a/templates/content/actions/duplicate-database-item.ts +++ b/templates/content/actions/duplicate-database-item.ts @@ -6,6 +6,7 @@ import { and, eq, gte, isNull, sql } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; +import { ensureDocumentFilesMembership } from "./_content-files.js"; import { getContentDatabaseResponse } from "./_database-utils.js"; import { nanoid } from "./_property-utils.js"; @@ -48,6 +49,12 @@ export default defineAction({ ); if (!row) throw new Error("Database row not found."); + if (!row.database.spaceId) { + throw new Error("Database does not belong to a Content space."); + } + if (row.document.spaceId !== row.database.spaceId) { + throw new Error("Cannot duplicate a database row across Content spaces."); + } await assertAccess("document", row.database.documentId, "editor"); await assertAccess("document", row.document.id, "viewer"); @@ -103,6 +110,7 @@ export default defineAction({ await tx.insert(schema.documents).values({ id: nextDocumentId, + spaceId: row.database.spaceId, ownerEmail: row.document.ownerEmail, orgId: row.document.orgId, parentId: row.database.documentId, @@ -155,6 +163,8 @@ export default defineAction({ })), ); } + + await ensureDocumentFilesMembership(tx, nextDocumentId, now); }); await writeAppState("refresh-signal", { ts: Date.now() }); diff --git a/templates/content/actions/duplicate-database-items.ts b/templates/content/actions/duplicate-database-items.ts index e87d33278f..80cbaac502 100644 --- a/templates/content/actions/duplicate-database-items.ts +++ b/templates/content/actions/duplicate-database-items.ts @@ -5,6 +5,7 @@ import { assertAccess } from "@agent-native/core/sharing"; import { and, eq, gte, inArray, sql } from "drizzle-orm"; import { getDb, schema } from "../server/db/index.js"; +import { ensureDocumentsFilesMembership } from "./_content-files.js"; import { databaseRowBatchSchema, resolveDatabaseRowsForBatch, @@ -19,6 +20,12 @@ export default defineAction({ run: async (args) => { const db = getDb(); const { database, rows } = await resolveDatabaseRowsForBatch(args); + if (!database.spaceId) { + throw new Error("Database does not belong to a Content space."); + } + if (rows.some((row) => row.document.spaceId !== database.spaceId)) { + throw new Error("Cannot duplicate database rows across Content spaces."); + } await assertAccess("document", database.documentId, "editor"); for (const row of rows) { @@ -103,6 +110,7 @@ export default defineAction({ await tx.insert(schema.documents).values( duplicates.map((duplicate) => ({ id: duplicate.duplicatedDocumentId, + spaceId: database.spaceId, ownerEmail: duplicate.row.document.ownerEmail, orgId: duplicate.row.document.orgId, parentId: database.documentId, @@ -163,6 +171,11 @@ export default defineAction({ ), ); } + await ensureDocumentsFilesMembership( + tx, + duplicates.map((duplicate) => duplicate.duplicatedDocumentId), + now, + ); }); await writeAppState("refresh-signal", { ts: Date.now() }); diff --git a/templates/content/actions/edit-document.ts b/templates/content/actions/edit-document.ts index 92761b69df..a3e325f532 100644 --- a/templates/content/actions/edit-document.ts +++ b/templates/content/actions/edit-document.ts @@ -10,11 +10,6 @@ import { eq } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; -import { - getLocalFileDocument, - isContentLocalFileMode, - updateLocalFileDocument, -} from "./_local-file-documents.js"; interface TextEdit { find: string; @@ -72,19 +67,8 @@ export default defineAction({ if (edit.replace === undefined) edit.replace = ""; } - const localFileMode = await isContentLocalFileMode(); - const existing = await (async () => { - if (localFileMode) { - const doc = await getLocalFileDocument(id); - if (doc.source?.kind === "folder") { - throw new Error("Folders cannot be edited directly"); - } - return doc; - } - - const access = await assertAccess("document", id, "editor"); - return access.resource; - })(); + const access = await assertAccess("document", id, "editor"); + const existing = access.resource; // ─── Apply edits to the document markdown ─────────────────────────────── // @@ -128,16 +112,6 @@ export default defineAction({ return { applied: 0, total: edits.length, results }; } - if (localFileMode) { - await updateLocalFileDocument(id, { content }); - await writeAppState("refresh-signal", { ts: Date.now() }); - return { - applied: changeCount, - total: edits.length, - results, - }; - } - // Persist. The fresh updatedAt is the signal the open editor uses to tell an // intentional external edit apart from a stale autosave echo. const db = getDb(); diff --git a/templates/content/actions/ensure-content-spaces.ts b/templates/content/actions/ensure-content-spaces.ts new file mode 100644 index 0000000000..977a096b04 --- /dev/null +++ b/templates/content/actions/ensure-content-spaces.ts @@ -0,0 +1,26 @@ +import { defineAction } from "@agent-native/core"; +import { writeAppState } from "@agent-native/core/application-state"; +import { getRequestUserEmail } from "@agent-native/core/server/request-context"; +import { z } from "zod"; + +import { getDb } from "../server/db/index.js"; +import { reconcileContentFilesMemberships } from "./_content-files.js"; +import { provisionContentSpaces } from "./_content-spaces.js"; + +export default defineAction({ + description: + "Provision and reconcile the signed-in user's personal and organization Content spaces.", + schema: z.object({}), + run: async () => { + const userEmail = getRequestUserEmail(); + if (!userEmail) throw new Error("no authenticated user"); + const db = getDb(); + const result = await provisionContentSpaces(db, userEmail); + const reconciliation = await reconcileContentFilesMemberships( + db, + userEmail, + ); + await writeAppState("refresh-signal", { ts: Date.now() }); + return { ...result, reconciliation }; + }, +}); diff --git a/templates/content/actions/export-content-source.ts b/templates/content/actions/export-content-source.ts index c2b9d58504..e82f6d8142 100644 --- a/templates/content/actions/export-content-source.ts +++ b/templates/content/actions/export-content-source.ts @@ -1,6 +1,6 @@ import { defineAction } from "@agent-native/core"; import { accessFilter } from "@agent-native/core/sharing"; -import { asc } from "drizzle-orm"; +import { and, asc, eq, inArray } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; @@ -10,13 +10,21 @@ import { } from "../server/lib/documents.js"; import { buildContentSourceBundle, + serializeContentSourceDocument, type ContentSourceDocument, } from "../shared/content-source.js"; export default defineAction({ description: "Export editable Content documents as source-control friendly Markdown/MDX files with frontmatter.", - schema: z.object({}), + schema: z.object({ + sourceId: z + .string() + .optional() + .describe( + "Connected local-folder source to export using its relative paths", + ), + }), http: { method: "GET" }, readOnly: true, publicAgent: { @@ -27,11 +35,28 @@ export default defineAction({ description: "Export Content documents as a local-file source bundle for MDX workflows.", }, - run: async () => { - const rows = await getDb() + run: async ({ sourceId }) => { + const db = getDb(); + const sourceRows = sourceId + ? await db + .select() + .from(schema.contentDatabaseSourceRows) + .where(eq(schema.contentDatabaseSourceRows.sourceId, sourceId)) + : []; + const sourceDocumentIds = sourceRows.map((row) => row.documentId); + const rows = await db .select() .from(schema.documents) - .where(accessFilter(schema.documents, schema.documentShares)) + .where( + sourceId + ? sourceDocumentIds.length + ? and( + accessFilter(schema.documents, schema.documentShares), + inArray(schema.documents.id, sourceDocumentIds), + ) + : eq(schema.documents.id, "__no_local_folder_documents__") + : accessFilter(schema.documents, schema.documentShares), + ) .orderBy(asc(schema.documents.position), asc(schema.documents.title)); const documents: ContentSourceDocument[] = rows.map((doc) => ({ @@ -48,9 +73,22 @@ export default defineAction({ updatedAt: doc.updatedAt, })); - return { - ...buildContentSourceBundle(documents), - count: documents.length, - }; + if (sourceId) { + const pathByDocumentId = new Map( + sourceRows.map((row) => [row.documentId, row.sourceDisplayKey]), + ); + return { + root: "", + exportedAt: new Date().toISOString(), + files: Object.fromEntries( + documents.map((document) => [ + pathByDocumentId.get(document.id) ?? `content/${document.id}.mdx`, + serializeContentSourceDocument(document), + ]), + ), + count: documents.length, + }; + } + return { ...buildContentSourceBundle(documents), count: documents.length }; }, }); diff --git a/templates/content/actions/get-document.ts b/templates/content/actions/get-document.ts index 498875d7d5..5b6980d07b 100644 --- a/templates/content/actions/get-document.ts +++ b/templates/content/actions/get-document.ts @@ -15,12 +15,6 @@ import { serializeDatabaseMembership, } from "./_database-utils.js"; import { serializeDocumentSource } from "./_document-source.js"; -import { - getLocalFileDocument, - getLocalDocumentContextPath, - isLocalDocumentId, - isContentLocalFileMode, -} from "./_local-file-documents.js"; import "../server/db/index.js"; import { listPropertiesForDocument, @@ -46,14 +40,6 @@ export default defineAction({ run: async (args) => { if (!args.id) throw new Error("--id is required"); - if ((await isContentLocalFileMode()) && isLocalDocumentId(args.id)) { - const document = await getLocalFileDocument(args.id); - return { - ...document, - contextPath: await getLocalDocumentContextPath(args.id), - }; - } - const access = await resolveAccess("document", args.id); // Not-found is a deterministic client-state condition (deleted or // inaccessible document still referenced by an open tab) — 404, not a diff --git a/templates/content/actions/import-content-source.db.test.ts b/templates/content/actions/import-content-source.db.test.ts index c500beed00..080313f157 100644 --- a/templates/content/actions/import-content-source.db.test.ts +++ b/templates/content/actions/import-content-source.db.test.ts @@ -2,6 +2,7 @@ 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 { eq } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; @@ -29,6 +30,12 @@ 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 INTEGER NOT NULL + )`); + 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 INTEGER NOT NULL + )`); }, 60000); afterAll(() => { @@ -69,10 +76,18 @@ describe("import-content-source descriptions", () => { ]); await expect( getDb() - .select({ description: schema.documents.description }) + .select({ + description: schema.documents.description, + spaceId: schema.documents.spaceId, + }) .from(schema.documents) .where(eq(schema.documents.id, "doc_description_roundtrip")), - ).resolves.toEqual([{ description: "Initial stable guidance" }]); + ).resolves.toEqual([ + { + description: "Initial stable guidance", + spaceId: expect.stringMatching(/^content_space_personal_/), + }, + ]); const updated = await runWithRequestContext({ userEmail: OWNER }, () => importContentSourceAction.run({ diff --git a/templates/content/actions/import-content-source.ts b/templates/content/actions/import-content-source.ts index f62c0c6cbc..6c00807ff5 100644 --- a/templates/content/actions/import-content-source.ts +++ b/templates/content/actions/import-content-source.ts @@ -15,6 +15,12 @@ import { parseContentSourceFile, type ParsedContentSourceFile, } from "../shared/content-source.js"; +import { ensureDocumentsFilesMembership } from "./_content-files.js"; +import { + organizationContentSpaceId, + personalContentSpaceId, + provisionContentSpaces, +} from "./_content-spaces.js"; const MAX_SOURCE_FILES = 500; const MAX_SOURCE_FILE_BYTES = 2 * 1024 * 1024; @@ -187,6 +193,17 @@ export default defineAction({ const currentOrgId = getRequestOrgId() ?? null; const db = getDb(); const now = new Date().toISOString(); + const defaultSpaceId = currentOrgId + ? organizationContentSpaceId(currentOrgId) + : personalContentSpaceId(currentUserEmail); + if (!dryRun) { + const provisioned = await provisionContentSpaces(db, currentUserEmail); + if (!provisioned.spaceIds.includes(defaultSpaceId)) { + throw new Error( + "The active organization does not have a writable Content space.", + ); + } + } const parsed: ParsedContentSourceFile[] = entries.map( ([filePath, source]) => parseContentSourceFile(filePath, source), ); @@ -229,6 +246,7 @@ export default defineAction({ const idByPath = new Map(); const pathById = new Map(); const ownerById = new Map(); + const spaceById = new Map(); const desiredParentById = new Map(); const desiredPositionById = new Map(); const currentParentById = new Map(); @@ -271,7 +289,9 @@ export default defineAction({ } if (existing) { + const existingSpaceId = existing.spaceId ?? defaultSpaceId; ownerById.set(id, existing.ownerEmail as string); + spaceById.set(id, existingSpaceId); currentParentById.set(id, existing.parentId ?? null); currentPositionById.set(id, existing.position ?? 0); if (file.parentId !== undefined) { @@ -299,6 +319,7 @@ export default defineAction({ existing.sourceKind !== sourceUpdates.sourceKind || existing.sourcePath !== sourceUpdates.sourcePath || existing.sourceRootPath !== sourceUpdates.sourceRootPath; + const spaceChanged = !existing.spaceId; const anyChange = titleChanged || contentChanged || @@ -306,7 +327,8 @@ export default defineAction({ iconChanged || favoriteChanged || discoverabilityChanged || - sourceChanged; + sourceChanged || + spaceChanged; if (!anyChange) { unchanged.push({ id, path: file.path, title: existing.title }); @@ -324,6 +346,7 @@ export default defineAction({ } const updates: Record = { updatedAt: now }; + if (!existing.spaceId) updates.spaceId = existingSpaceId; if (titleChanged) updates.title = file.title; if (contentChanged) updates.content = file.content; if (descriptionChanged) updates.description = file.description; @@ -348,6 +371,7 @@ export default defineAction({ try { await db.insert(schema.documents).values({ id, + spaceId: defaultSpaceId, ownerEmail: currentUserEmail, orgId: currentOrgId, parentId: null, @@ -382,6 +406,7 @@ export default defineAction({ desiredParentById.set(id, file.parentId ?? null); desiredPositionById.set(id, file.position ?? index); ownerById.set(id, currentUserEmail); + spaceById.set(id, defaultSpaceId); created.push({ id, path: file.path, title: file.title }); } @@ -410,7 +435,8 @@ export default defineAction({ const parentAccess = await resolveAccess("document", parentId); if ( parentAccess && - (parentAccess.resource.ownerEmail as string) === ownerEmail + (parentAccess.resource.ownerEmail as string) === ownerEmail && + parentAccess.resource.spaceId === spaceById.get(id) ) { try { await assertParentIsNotDescendant({ ownerEmail, id, parentId }); @@ -428,7 +454,7 @@ export default defineAction({ } else { skipped.push({ path: pathById.get(id) ?? id, - reason: `Skipped parent "${parentId}" because it is not editable in the same owner scope.`, + reason: `Skipped parent "${parentId}" because it is not editable in the same Content space.`, }); } } @@ -452,6 +478,18 @@ export default defineAction({ .where(eq(schema.documents.id, id)); } + await ensureDocumentsFilesMembership( + db, + [ + ...new Set([ + ...created.map((row) => row.id), + ...updated.map((row) => row.id), + ...unchanged.map((row) => row.id), + ]), + ], + now, + ); + await writeAppState("refresh-signal", { ts: Date.now() }); } diff --git a/templates/content/actions/list-content-spaces.ts b/templates/content/actions/list-content-spaces.ts new file mode 100644 index 0000000000..ea439cbb83 --- /dev/null +++ b/templates/content/actions/list-content-spaces.ts @@ -0,0 +1,103 @@ +import { defineAction } from "@agent-native/core"; +import { getRequestUserEmail } from "@agent-native/core/server/request-context"; +import { and, eq, isNull } from "drizzle-orm"; +import { z } from "zod"; + +import { getDb, schema } from "../server/db/index.js"; +import { + listContentOrganizationMemberships, + normalizeContentSpaceEmail, +} from "./_content-space-access.js"; +import { + personalContentSpaceId, + systemIdsForContentSpace, +} from "./_content-spaces.js"; + +export default defineAction({ + description: + "List Content spaces that are already provisioned and currently authorized for the signed-in user.", + schema: z.object({}), + http: { method: "GET" }, + readOnly: true, + run: async () => { + const userEmail = getRequestUserEmail(); + if (!userEmail) throw new Error("no authenticated user"); + const email = normalizeContentSpaceEmail(userEmail); + const db = getDb(); + const personalSpaceId = personalContentSpaceId(email); + const catalogIds = systemIdsForContentSpace(personalSpaceId, "workspaces"); + const memberships = await listContentOrganizationMemberships(email); + const roleByOrgId = new Map( + memberships.map((membership) => [ + membership.orgId, + membership.role === "owner" + ? "owner" + : membership.role === "admin" + ? "editor" + : "viewer", + ]), + ); + const rows = await db + .select({ + mapping: schema.contentSpaceCatalogItems, + space: schema.contentSpaces, + item: schema.contentDatabaseItems, + }) + .from(schema.contentSpaceCatalogItems) + .innerJoin( + schema.contentSpaces, + eq(schema.contentSpaces.id, schema.contentSpaceCatalogItems.spaceId), + ) + .innerJoin( + schema.contentDatabaseItems, + eq( + schema.contentDatabaseItems.id, + schema.contentSpaceCatalogItems.databaseItemId, + ), + ) + .where( + and( + eq(schema.contentSpaceCatalogItems.ownerEmail, email), + eq( + schema.contentSpaceCatalogItems.catalogDatabaseId, + catalogIds.databaseId, + ), + isNull(schema.contentSpaces.archivedAt), + ), + ); + const spaces = [] as Array<{ + id: string; + name: string; + kind: string; + filesDatabaseId: string; + orgId: string | null; + role: string; + catalogItemId: string; + catalogDocumentId: string; + }>; + for (const row of rows) { + if ( + row.item.databaseId !== catalogIds.databaseId || + row.item.documentId !== row.mapping.documentId + ) + continue; + const role = row.space.orgId + ? roleByOrgId.get(row.space.orgId) + : row.space.ownerEmail.toLowerCase() === email + ? "owner" + : undefined; + if (!role) continue; + spaces.push({ + id: row.space.id, + name: row.space.name, + kind: row.space.kind, + filesDatabaseId: row.space.filesDatabaseId, + orgId: row.space.orgId, + role, + catalogItemId: row.mapping.databaseItemId, + catalogDocumentId: row.mapping.documentId, + }); + } + return { catalogDatabaseId: catalogIds.databaseId, spaces }; + }, +}); diff --git a/templates/content/actions/list-documents.ts b/templates/content/actions/list-documents.ts index 9b42c6ec71..922ac0f3cf 100644 --- a/templates/content/actions/list-documents.ts +++ b/templates/content/actions/list-documents.ts @@ -19,10 +19,6 @@ import { } from "../server/lib/documents.js"; import { serializeDatabaseMembership } from "./_database-utils.js"; import { serializeDocumentSource } from "./_document-source.js"; -import { - isContentLocalFileMode, - listLocalFileDocuments, -} from "./_local-file-documents.js"; import { parseDatabaseViewConfig } from "./_property-utils.js"; function contentPreview(content: string, maxLength = 180) { @@ -52,9 +48,6 @@ export default defineAction({ schema: z.object({}), http: { method: "GET" }, run: async () => { - const localFileMode = await isContentLocalFileMode(); - const localDocuments = localFileMode ? await listLocalFileDocuments() : []; - const db = getDb(); const userEmail = getRequestUserEmail(); const orgId = getRequestOrgId(); @@ -174,6 +167,10 @@ export default defineAction({ inArray(schema.contentDatabases.documentId, visibleDocumentIds), isNull(schema.contentDatabases.deletedAt), ), + ) + .orderBy( + sql`CASE WHEN ${schema.contentDatabases.systemRole} IS NULL THEN 0 ELSE 1 END`, + asc(schema.contentDatabases.id), ), db .select({ @@ -196,6 +193,10 @@ export default defineAction({ ), isNull(schema.contentDatabases.deletedAt), ), + ) + .orderBy( + sql`CASE WHEN ${schema.contentDatabases.systemRole} IS NULL THEN 0 ELSE 1 END`, + asc(schema.contentDatabases.id), ), db .select({ @@ -230,7 +231,9 @@ export default defineAction({ } for (const row of databaseMemberships) { - databaseMembershipByDocumentId.set(row.item.documentId, row); + if (!databaseMembershipByDocumentId.has(row.item.documentId)) { + databaseMembershipByDocumentId.set(row.item.documentId, row); + } } for (const database of softDeletedDatabases) { @@ -318,6 +321,6 @@ export default defineAction({ }; }); - return { documents: [...localDocuments, ...mapped] }; + return { documents: mapped }; }, }); diff --git a/templates/content/actions/local-folder-source.db.test.ts b/templates/content/actions/local-folder-source.db.test.ts new file mode 100644 index 0000000000..d44c4cb2e7 --- /dev/null +++ b/templates/content/actions/local-folder-source.db.test.ts @@ -0,0 +1,467 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } 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 { and, eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +const TEST_DB_PATH = join( + tmpdir(), + `local-folder-source-${process.pid}-${Date.now()}.sqlite`, +); +const OWNER = "folder-owner@example.com"; + +type Schema = typeof import("../server/db/schema.js"); +let getDb: () => any; +let schema: Schema; +let connectLocalFolder: typeof import("./connect-local-folder-source.js").default; +let syncLocalFolder: typeof import("./sync-local-folder-source.js").default; +let disconnectLocalFolder: typeof import("./disconnect-local-folder-source.js").default; +let resolveLocalFolderConflict: typeof import("./resolve-local-folder-conflict.js").default; +let syncManifestLocalFolder: typeof import("./sync-manifest-local-folder-source.js").default; + +beforeAll(async () => { + process.env.DATABASE_URL = `file:${TEST_DB_PATH}`; + const dbModule = await import("../server/db/index.js"); + getDb = dbModule.getDb; + schema = dbModule.schema; + 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 INTEGER NOT NULL + )`); + 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 INTEGER NOT NULL + )`); + connectLocalFolder = (await import("./connect-local-folder-source.js")) + .default; + syncLocalFolder = (await import("./sync-local-folder-source.js")).default; + disconnectLocalFolder = (await import("./disconnect-local-folder-source.js")) + .default; + resolveLocalFolderConflict = ( + await import("./resolve-local-folder-conflict.js") + ).default; + syncManifestLocalFolder = ( + await import("./sync-manifest-local-folder-source.js") + ).default; +}, 60000); + +afterAll(() => { + for (const suffix of ["", "-shm", "-wal"]) + rmSync(`${TEST_DB_PATH}${suffix}`, { force: true }); +}); + +describe("local-folder Content source", () => { + it("previews a new connection without creating durable rows", async () => { + const beforeSpaces = await getDb().select().from(schema.contentSpaces); + const beforeSources = await getDb() + .select() + .from(schema.contentDatabaseSources); + const preview = await runWithRequestContext({ userEmail: OWNER }, () => + connectLocalFolder.run({ + connectionId: "desktop-folder-preview-only", + label: "Preview only", + createSourceBackedSpace: true, + truthPolicy: "database_primary", + dryRun: true, + }), + ); + expect(preview).toMatchObject({ connected: false, sourceId: null }); + await expect( + getDb().select().from(schema.contentSpaces), + ).resolves.toHaveLength(beforeSpaces.length); + await expect( + getDb().select().from(schema.contentDatabaseSources), + ).resolves.toHaveLength(beforeSources.length); + }); + + it("creates an opaque source-backed space and materializes files in canonical Files", async () => { + const connection = await runWithRequestContext({ userEmail: OWNER }, () => + connectLocalFolder.run({ + connectionId: "desktop-folder-1", + label: "Product docs", + createSourceBackedSpace: true, + truthPolicy: "source_primary", + }), + ); + expect(connection).toMatchObject({ + sourceType: "local-folder", + label: "Product docs", + truthPolicy: "source_primary", + }); + const [storedSource] = await getDb() + .select() + .from(schema.contentDatabaseSources) + .where(eq(schema.contentDatabaseSources.id, connection.sourceId)); + expect(storedSource.sourceTable).toBe("desktop-folder-1"); + expect(storedSource.metadataJson).not.toContain("/Users/"); + + const first = await runWithRequestContext({ userEmail: OWNER }, () => + syncLocalFolder.run({ + sourceId: connection.sourceId, + files: { "guide.md": "# Guide\n\nFirst body." }, + }), + ); + expect(first.created).toHaveLength(1); + expect(first.conflicts).toHaveLength(0); + const documentId = first.created[0]!.id; + const [document] = await getDb() + .select() + .from(schema.documents) + .where(eq(schema.documents.id, documentId)); + expect(document).toMatchObject({ + spaceId: connection.spaceId, + sourceMode: "local-files", + sourcePath: "guide.md", + }); + const memberships = await getDb() + .select() + .from(schema.contentDatabaseItems) + .where( + and( + eq( + schema.contentDatabaseItems.databaseId, + connection.filesDatabaseId, + ), + eq(schema.contentDatabaseItems.documentId, documentId), + ), + ); + expect(memberships).toHaveLength(1); + const sourceRows = await getDb() + .select() + .from(schema.contentDatabaseSourceRows) + .where( + eq(schema.contentDatabaseSourceRows.sourceId, connection.sourceId), + ); + expect(sourceRows).toHaveLength(1); + expect(sourceRows[0]!.sourceValuesJson).not.toContain("First body"); + + const second = await runWithRequestContext({ userEmail: OWNER }, () => + syncLocalFolder.run({ + sourceId: connection.sourceId, + files: { "guide.md": "# Guide\n\nFirst body." }, + }), + ); + expect(second.unchanged).toHaveLength(1); + await expect( + getDb() + .select() + .from(schema.contentDatabaseItems) + .where( + and( + eq( + schema.contentDatabaseItems.databaseId, + connection.filesDatabaseId, + ), + eq(schema.contentDatabaseItems.documentId, documentId), + ), + ), + ).resolves.toHaveLength(1); + }); + + it("records concurrent source-primary changes for review without overwriting Content", async () => { + const [source] = await getDb() + .select() + .from(schema.contentDatabaseSources) + .where(eq(schema.contentDatabaseSources.sourceTable, "desktop-folder-1")); + const [row] = await getDb() + .select() + .from(schema.contentDatabaseSourceRows) + .where(eq(schema.contentDatabaseSourceRows.sourceId, source.id)); + await getDb() + .update(schema.documents) + .set({ + content: "Content-side edit", + updatedAt: new Date().toISOString(), + }) + .where(eq(schema.documents.id, row.documentId)); + + const result = await runWithRequestContext({ userEmail: OWNER }, () => + syncLocalFolder.run({ + sourceId: source.id, + files: { "guide.md": "# Guide\n\nFolder-side edit." }, + }), + ); + expect(result.conflicts).toHaveLength(1); + const [document] = await getDb() + .select() + .from(schema.documents) + .where(eq(schema.documents.id, row.documentId)); + expect(document.content).toBe("Content-side edit"); + const [changeSet] = await getDb() + .select() + .from(schema.contentDatabaseSourceChangeSets) + .where(eq(schema.contentDatabaseSourceChangeSets.sourceId, source.id)); + expect(changeSet).toMatchObject({ + documentId: row.documentId, + direction: "incoming", + state: "proposed", + }); + expect(changeSet.bodyChangeJson).not.toContain("Folder-side edit."); + await runWithRequestContext({ userEmail: OWNER }, () => + resolveLocalFolderConflict.run({ + changeSetId: changeSet.id, + decision: "accept_source", + sourceContent: "# Guide\n\nFolder-side edit.", + }), + ); + const [resolvedDocument] = await getDb() + .select() + .from(schema.documents) + .where(eq(schema.documents.id, row.documentId)); + expect(resolvedDocument.content).toBe("# Guide\n\nFolder-side edit."); + const [resolvedChangeSet] = await getDb() + .select() + .from(schema.contentDatabaseSourceChangeSets) + .where(eq(schema.contentDatabaseSourceChangeSets.id, changeSet.id)); + expect(resolvedChangeSet.state).toBe("applied"); + }); + + it("tracks stable-id renames and reviews source deletions without deleting the global page", async () => { + const connection = await runWithRequestContext({ userEmail: OWNER }, () => + connectLocalFolder.run({ + connectionId: "desktop-folder-rename", + label: "Renamed docs", + createSourceBackedSpace: true, + truthPolicy: "source_primary", + }), + ); + const source = `---\nid: stable-local-page\n---\n# Stable page\n\nBody.`; + await runWithRequestContext({ userEmail: OWNER }, () => + syncLocalFolder.run({ + sourceId: connection.sourceId, + files: { "old-name.md": source }, + }), + ); + const renamed = await runWithRequestContext({ userEmail: OWNER }, () => + syncLocalFolder.run({ + sourceId: connection.sourceId, + files: { "new-name.md": source }, + }), + ); + expect(renamed.created).toHaveLength(0); + expect(renamed.updated).toEqual([ + expect.objectContaining({ id: "stable-local-page", path: "new-name.md" }), + ]); + await expect( + getDb() + .select() + .from(schema.documents) + .where(eq(schema.documents.id, "stable-local-page")), + ).resolves.toEqual([ + expect.objectContaining({ sourcePath: "new-name.md" }), + ]); + + const deletion = await runWithRequestContext({ userEmail: OWNER }, () => + syncLocalFolder.run({ sourceId: connection.sourceId, files: {} }), + ); + expect(deletion.conflicts).toEqual([ + expect.objectContaining({ id: "stable-local-page", path: "new-name.md" }), + ]); + const [changeSet] = await getDb() + .select() + .from(schema.contentDatabaseSourceChangeSets) + .where( + and( + eq( + schema.contentDatabaseSourceChangeSets.sourceId, + connection.sourceId, + ), + eq( + schema.contentDatabaseSourceChangeSets.documentId, + "stable-local-page", + ), + ), + ); + expect(changeSet).toMatchObject({ + kind: "metadata_update", + direction: "incoming", + state: "proposed", + }); + await runWithRequestContext({ userEmail: OWNER }, () => + resolveLocalFolderConflict.run({ + changeSetId: changeSet.id, + decision: "accept_source", + }), + ); + await expect( + getDb() + .select() + .from(schema.documents) + .where(eq(schema.documents.id, "stable-local-page")), + ).resolves.toEqual([ + expect.objectContaining({ sourceMode: null, sourcePath: null }), + ]); + await expect( + getDb() + .select() + .from(schema.contentDatabaseSourceRows) + .where( + and( + eq(schema.contentDatabaseSourceRows.sourceId, connection.sourceId), + eq( + schema.contentDatabaseSourceRows.documentId, + "stable-local-page", + ), + ), + ), + ).resolves.toHaveLength(0); + }); + + it("keeps database-primary edits and stages them for export", async () => { + const connection = await runWithRequestContext({ userEmail: OWNER }, () => + connectLocalFolder.run({ + connectionId: "desktop-folder-database-primary", + label: "Database-owned docs", + createSourceBackedSpace: true, + truthPolicy: "database_primary", + }), + ); + const first = await runWithRequestContext({ userEmail: OWNER }, () => + syncLocalFolder.run({ + sourceId: connection.sourceId, + files: { "owned.md": "# Owned\n\nFolder revision." }, + }), + ); + const documentId = first.created[0]!.id; + await getDb() + .update(schema.documents) + .set({ + title: "Content-owned title", + content: "# Owned\n\nContent revision.", + }) + .where(eq(schema.documents.id, documentId)); + const second = await runWithRequestContext({ userEmail: OWNER }, () => + syncLocalFolder.run({ + sourceId: connection.sourceId, + files: { "owned.md": "# Owned\n\nFolder revision." }, + }), + ); + expect(second.conflicts).toHaveLength(0); + expect(second.outbound).toEqual([ + expect.objectContaining({ id: documentId, path: "owned.md" }), + ]); + await expect( + getDb() + .select() + .from(schema.documents) + .where(eq(schema.documents.id, documentId)), + ).resolves.toEqual([ + expect.objectContaining({ + title: "Content-owned title", + content: "# Owned\n\nContent revision.", + }), + ]); + await expect( + getDb() + .select() + .from(schema.contentDatabaseSourceChangeSets) + .where( + and( + eq( + schema.contentDatabaseSourceChangeSets.sourceId, + connection.sourceId, + ), + eq(schema.contentDatabaseSourceChangeSets.documentId, documentId), + ), + ), + ).resolves.toEqual([ + expect.objectContaining({ direction: "outbound", state: "proposed" }), + ]); + }); + + it("bootstraps a manifest-declared folder without enabling local-file mode", async () => { + const workspace = mkdtempSync(join(tmpdir(), "content-manifest-folder-")); + const manifestPath = join(workspace, "agent-native.json"); + mkdirSync(join(workspace, "docs")); + writeFileSync(join(workspace, "docs", "hello.md"), "# Hello\n\nFrom disk."); + writeFileSync( + manifestPath, + JSON.stringify({ + version: 1, + apps: { + content: { + roots: [ + { + name: "CLI docs", + path: "docs", + extensions: [".md", ".mdx"], + source: { + type: "local-folder", + connectionId: "local-folder:cli-test", + truthPolicy: "source_primary", + }, + }, + ], + }, + }, + }), + ); + const previousManifest = process.env.AGENT_NATIVE_MANIFEST_PATH; + process.env.AGENT_NATIVE_MANIFEST_PATH = manifestPath; + try { + const result = await runWithRequestContext({ userEmail: OWNER }, () => + syncManifestLocalFolder.run({ + connectionId: "local-folder:cli-test", + file: "docs/hello.md", + dryRun: false, + }), + ); + expect(result.requestedDocumentId).toBe(result.created[0]!.id); + await expect( + getDb() + .select() + .from(schema.documents) + .where(eq(schema.documents.id, result.requestedDocumentId!)), + ).resolves.toEqual([ + expect.objectContaining({ + spaceId: result.spaceId, + content: "# Hello\n\nFrom disk.", + }), + ]); + } finally { + if (previousManifest === undefined) { + delete process.env.AGENT_NATIVE_MANIFEST_PATH; + } else { + process.env.AGENT_NATIVE_MANIFEST_PATH = previousManifest; + } + rmSync(workspace, { recursive: true, force: true }); + } + }); + + it("disconnects the adapter without deleting local files or Content pages", async () => { + const [source] = await getDb() + .select() + .from(schema.contentDatabaseSources) + .where(eq(schema.contentDatabaseSources.sourceTable, "desktop-folder-1")); + const [row] = await getDb() + .select() + .from(schema.contentDatabaseSourceRows) + .where(eq(schema.contentDatabaseSourceRows.sourceId, source.id)); + const result = await runWithRequestContext({ userEmail: OWNER }, () => + disconnectLocalFolder.run({ sourceId: source.id }), + ); + expect(result).toMatchObject({ + success: true, + disconnectedDocuments: 1, + localFilesDeleted: 0, + }); + await expect( + getDb() + .select() + .from(schema.contentDatabaseSources) + .where(eq(schema.contentDatabaseSources.id, source.id)), + ).resolves.toHaveLength(0); + const [document] = await getDb() + .select() + .from(schema.documents) + .where(eq(schema.documents.id, row.documentId)); + expect(document).toMatchObject({ + id: row.documentId, + sourceMode: "database", + sourcePath: null, + }); + }); +}); diff --git a/templates/content/actions/move-document.db.test.ts b/templates/content/actions/move-document.db.test.ts index 436dd5d712..9655599fa6 100644 --- a/templates/content/actions/move-document.db.test.ts +++ b/templates/content/actions/move-document.db.test.ts @@ -60,6 +60,7 @@ async function createDocument(args: { title?: string; position?: number; ownerEmail?: string; + spaceId?: string | null; }) { const db = getDb(); const now = new Date().toISOString(); @@ -67,6 +68,7 @@ async function createDocument(args: { await db.insert(schema.documents).values({ id, ownerEmail: args.ownerEmail ?? OWNER, + spaceId: args.spaceId ?? null, parentId: args.parentId ?? null, title: args.title ?? "Untitled", content: "", @@ -92,6 +94,16 @@ async function childPositions(parentId: string) { } describe("move-document position race", () => { + it("rejects a parent in another Content space", async () => { + const id = await createDocument({ spaceId: "space-one" }); + const parentId = await createDocument({ spaceId: "space-two" }); + await expect( + runWithRequestContext({ userEmail: OWNER }, () => + moveDocumentAction.run({ id, parentId } as any), + ), + ).rejects.toThrow("same Content space"); + }); + it("assigns distinct, gapless positions when several documents are reparented into the same parent at an explicit position concurrently", async () => { const parentId = await createDocument({ title: "Parent" }); // Two pre-existing children the resequence branch must also account for. diff --git a/templates/content/actions/move-document.ts b/templates/content/actions/move-document.ts index c1552f63da..cfb63a4b80 100644 --- a/templates/content/actions/move-document.ts +++ b/templates/content/actions/move-document.ts @@ -9,11 +9,6 @@ import { parseDocumentFavorite, parseDocumentHideFromSearch, } from "../server/lib/documents.js"; -import { - isLocalDocumentId, - isContentLocalFileMode, - moveLocalFileDocument, -} from "./_local-file-documents.js"; import { documentsPositionScope, withPositionLock } from "./_position-utils.js"; async function assertParentIsNotDescendant({ @@ -214,15 +209,6 @@ export default defineAction({ throw new Error("A document cannot be moved under itself"); } - if ((await isContentLocalFileMode()) && isLocalDocumentId(id)) { - const doc = await moveLocalFileDocument(id, args); - await writeAppState("refresh-signal", { ts: Date.now() }); - return { - ...doc, - urlPath: `/page/${doc.id}`, - }; - } - const access = await assertAccess("document", id, "editor"); const existing = access.resource; const ownerEmail = existing.ownerEmail as string; @@ -243,6 +229,9 @@ export default defineAction({ if (parentAccess.resource.ownerEmail !== ownerEmail) { throw new Error("Parent document must belong to the same owner"); } + if (parentAccess.resource.spaceId !== existing.spaceId) { + throw new Error("Parent document must be in the same Content space"); + } if (!sameRootSection(parentAccess.resource, existing)) { throw new Error("Parent document must be in the same section"); } diff --git a/templates/content/actions/pull-document.ts b/templates/content/actions/pull-document.ts index 77a69a7c14..e0a48bd2bb 100644 --- a/templates/content/actions/pull-document.ts +++ b/templates/content/actions/pull-document.ts @@ -5,10 +5,6 @@ import { z } from "zod"; import "../server/db/index.js"; import { flushOpenDocumentEditorToSql } from "./_document-flush.js"; -import { - getLocalFileDocument, - isContentLocalFileMode, -} from "./_local-file-documents.js"; /** * Collab-aware "ingest the final" read for external agents. @@ -65,25 +61,6 @@ export default defineAction({ readOnly: true, publicAgent: { expose: true, readOnly: true, requiresAuth: true }, run: async ({ id, format }) => { - if (await isContentLocalFileMode()) { - const doc = await getLocalFileDocument(id); - if (doc.source?.kind === "folder") { - throw new Error("Folders cannot be pulled as markdown documents"); - } - return { - id: doc.id, - title: doc.title, - description: doc.description ?? "", - content: formatDocumentContent(doc.content ?? "", format), - format, - deepLink: buildDeepLink({ - app: "content", - view: "editor", - params: { documentId: doc.id }, - }), - }; - } - const access = await resolveAccess("document", id); if (!access) throw new Error(`Document "${id}" not found`); diff --git a/templates/content/actions/remove-local-file-source.ts b/templates/content/actions/remove-local-file-source.ts index de2a29deae..a79e6b0444 100644 --- a/templates/content/actions/remove-local-file-source.ts +++ b/templates/content/actions/remove-local-file-source.ts @@ -1,18 +1,11 @@ import { defineAction } from "@agent-native/core"; import { writeAppState } from "@agent-native/core/application-state"; import { accessFilter, assertAccess } from "@agent-native/core/sharing"; -import { and, eq } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; -import { - invalidateLocalFileDocumentsCache, - isContentLocalFileMode, - removeContentLocalFileRoots, -} from "./_local-file-documents.js"; -import { deleteDocumentRecursive } from "./delete-document.js"; - -async function removeImportedLocalSourceDocuments( +async function unlinkImportedLocalSourceDocuments( sourceRootPath?: string | null, ) { const db = getDb(); @@ -33,29 +26,60 @@ async function removeImportedLocalSourceDocuments( .where( and(...clauses, accessFilter(schema.documents, schema.documentShares)), ); - const candidateIds = new Set(candidates.map((document) => document.id)); - const roots = candidates.filter( - (document) => !document.parentId || !candidateIds.has(document.parentId), - ); - let deleted = 0; + const trackedRows = candidates.length + ? await db + .select({ documentId: schema.contentDatabaseSourceRows.documentId }) + .from(schema.contentDatabaseSourceRows) + .innerJoin( + schema.contentDatabaseSources, + eq( + schema.contentDatabaseSources.id, + schema.contentDatabaseSourceRows.sourceId, + ), + ) + .where( + and( + inArray( + schema.contentDatabaseSourceRows.documentId, + candidates.map((document) => document.id), + ), + eq(schema.contentDatabaseSources.sourceType, "local-folder"), + ), + ) + : []; + const trackedDocumentIds = new Set(trackedRows.map((row) => row.documentId)); + const documentIds: string[] = []; + for (const document of candidates) { + if (trackedDocumentIds.has(document.id)) continue; + await assertAccess("document", document.id, "admin"); + documentIds.push(document.id); + } - for (const document of roots) { - const access = await assertAccess("document", document.id, "admin"); - deleted += ( - await deleteDocumentRecursive( - db, - document.id, - access.resource.ownerEmail as string, - ) - ).length; + if (documentIds.length > 0) { + const now = new Date().toISOString(); + await db + .update(schema.documents) + .set({ + sourceMode: "database", + sourceKind: null, + sourcePath: null, + sourceRootPath: null, + sourceUpdatedAt: now, + updatedAt: now, + }) + .where(inArray(schema.documents.id, documentIds)); } - return { removed: deleted, roots: [] as string[], manifestPath: null }; + return { + removed: documentIds.length, + roots: [] as string[], + manifestPath: null, + }; } export default defineAction({ description: - "Remove local-file sources from Content without deleting the files on disk. In local-file mode this unlinks configured Content roots from agent-native.json; in database mode this removes imported local-file document entries.", + "Unlink legacy local-file source metadata from SQL-backed Content pages without deleting either the pages or files on disk. Use disconnect-local-folder-source for connected folder adapters.", schema: z.object({ sourceRootPath: z .string() @@ -75,9 +99,7 @@ export default defineAction({ "Unlink local-file entries from Content without deleting local Markdown or MDX files.", }, run: async ({ sourceRootPath }) => { - const result = (await isContentLocalFileMode()) - ? await removeContentLocalFileRoots(sourceRootPath) - : await removeImportedLocalSourceDocuments(sourceRootPath); + const result = await unlinkImportedLocalSourceDocuments(sourceRootPath); if (result.removed === 0 && result.roots.length === 0) { throw new Error( @@ -87,7 +109,6 @@ export default defineAction({ ); } - invalidateLocalFileDocumentsCache(); await writeAppState("refresh-signal", { ts: Date.now() }); return { diff --git a/templates/content/actions/resolve-local-folder-conflict.ts b/templates/content/actions/resolve-local-folder-conflict.ts new file mode 100644 index 0000000000..3f3aff1f6c --- /dev/null +++ b/templates/content/actions/resolve-local-folder-conflict.ts @@ -0,0 +1,295 @@ +import { createHash } from "node:crypto"; + +import { defineAction } from "@agent-native/core"; +import { writeAppState } from "@agent-native/core/application-state"; +import { and, eq } from "drizzle-orm"; +import { z } from "zod"; + +import { getDb, schema } from "../server/db/index.js"; +import { resolveContentSpaceAccess } from "./_content-space-access.js"; +import { LOCAL_FOLDER_SOURCE_TYPE } from "./_local-folder-source.js"; + +const MAX_SOURCE_FILE_BYTES = 2 * 1024 * 1024; + +function hash(content: string) { + return createHash("sha256").update(content).digest("hex"); +} + +function parseObject(value: string | null | undefined) { + if (!value) return {} as Record; + try { + const parsed = JSON.parse(value) as unknown; + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + } catch { + return {}; + } +} + +function proposedMetadata(value: string | null | undefined) { + if (!value) return {} as Record; + try { + const parsed = JSON.parse(value) as unknown; + if (!Array.isArray(parsed)) return {}; + return Object.fromEntries( + parsed.flatMap((change) => { + if ( + !change || + typeof change !== "object" || + !("field" in change) || + !("proposedValue" in change) || + !["title", "description", "icon"].includes(String(change.field)) + ) { + return []; + } + const proposed = change.proposedValue; + return typeof proposed === "string" || proposed === null + ? [[String(change.field), proposed] as const] + : []; + }), + ); + } catch { + return {}; + } +} + +export default defineAction({ + description: + "Resolve an incoming local-folder conflict by keeping Content or accepting the exact reviewed folder revision supplied by the trusted bridge.", + schema: z.object({ + changeSetId: z.string().min(1), + decision: z.enum(["keep_content", "accept_source"]), + sourceContent: z.string().max(MAX_SOURCE_FILE_BYTES).optional(), + }), + run: async ({ changeSetId, decision, sourceContent }) => { + const db = getDb(); + const [target] = await db + .select({ + changeSet: schema.contentDatabaseSourceChangeSets, + source: schema.contentDatabaseSources, + database: schema.contentDatabases, + document: schema.documents, + }) + .from(schema.contentDatabaseSourceChangeSets) + .innerJoin( + schema.contentDatabaseSources, + eq( + schema.contentDatabaseSources.id, + schema.contentDatabaseSourceChangeSets.sourceId, + ), + ) + .innerJoin( + schema.contentDatabases, + eq( + schema.contentDatabases.id, + schema.contentDatabaseSources.databaseId, + ), + ) + .innerJoin( + schema.documents, + eq( + schema.documents.id, + schema.contentDatabaseSourceChangeSets.documentId, + ), + ) + .where(eq(schema.contentDatabaseSourceChangeSets.id, changeSetId)); + if ( + !target || + target.source.sourceType !== LOCAL_FOLDER_SOURCE_TYPE || + target.changeSet.direction !== "incoming" || + target.changeSet.state !== "proposed" || + !target.database.spaceId + ) { + throw new Error(`Open local-folder conflict "${changeSetId}" not found`); + } + await resolveContentSpaceAccess(target.database.spaceId, "editor"); + const bodyChange = parseObject(target.changeSet.bodyChangeJson); + const sourceDeletion = bodyChange.operation === "source_delete"; + const proposedHash = + typeof bodyChange.proposedHash === "string" + ? bodyChange.proposedHash + : null; + if (!sourceDeletion && !proposedHash) + throw new Error("Conflict is missing its source hash"); + if ( + decision === "accept_source" && + !sourceDeletion && + sourceContent === undefined + ) { + throw new Error( + "sourceContent is required when accepting the folder revision", + ); + } + if ( + decision === "accept_source" && + !sourceDeletion && + hash(sourceContent!) !== proposedHash + ) { + throw new Error( + "The supplied folder revision changed after review; refresh before resolving", + ); + } + + const now = new Date().toISOString(); + await db.transaction(async (tx: any) => { + if (decision === "accept_source" && sourceDeletion) { + await tx + .delete(schema.contentDatabaseSourceRows) + .where( + and( + eq(schema.contentDatabaseSourceRows.sourceId, target.source.id), + eq( + schema.contentDatabaseSourceRows.documentId, + target.document.id, + ), + ), + ); + await tx + .update(schema.documents) + .set({ + sourceMode: null, + sourceKind: null, + sourcePath: null, + sourceRootPath: null, + sourceUpdatedAt: null, + updatedAt: now, + }) + .where(eq(schema.documents.id, target.document.id)); + } else if (decision === "accept_source") { + const metadata = proposedMetadata(target.changeSet.fieldChangesJson); + const resolvedTitle = Object.prototype.hasOwnProperty.call( + metadata, + "title", + ) + ? (metadata.title ?? "") + : target.document.title; + const resolvedDescription = Object.prototype.hasOwnProperty.call( + metadata, + "description", + ) + ? (metadata.description ?? "") + : (target.document.description ?? ""); + const resolvedIcon = Object.prototype.hasOwnProperty.call( + metadata, + "icon", + ) + ? (metadata.icon ?? null) + : (target.document.icon ?? null); + await tx + .insert(schema.documentVersions) + .values({ + id: `content_document_version_${createHash("sha256") + .update( + `${target.document.id}:${target.document.updatedAt}:${proposedHash}`, + ) + .digest("hex") + .slice(0, 32)}`, + ownerEmail: target.document.ownerEmail, + documentId: target.document.id, + title: target.document.title, + content: target.document.content, + createdAt: now, + }) + .onConflictDoNothing(); + await tx + .update(schema.documents) + .set({ + content: sourceContent!, + ...(Object.prototype.hasOwnProperty.call(metadata, "title") + ? { title: metadata.title ?? "" } + : {}), + ...(Object.prototype.hasOwnProperty.call(metadata, "description") + ? { description: metadata.description ?? "" } + : {}), + ...(Object.prototype.hasOwnProperty.call(metadata, "icon") + ? { icon: metadata.icon } + : {}), + sourceUpdatedAt: now, + updatedAt: now, + }) + .where(eq(schema.documents.id, target.document.id)); + const [sourceRow] = await tx + .select() + .from(schema.contentDatabaseSourceRows) + .where( + and( + eq(schema.contentDatabaseSourceRows.sourceId, target.source.id), + eq( + schema.contentDatabaseSourceRows.documentId, + target.document.id, + ), + ), + ); + if (sourceRow) { + const values = parseObject(sourceRow.sourceValuesJson); + await tx + .update(schema.contentDatabaseSourceRows) + .set({ + sourceValuesJson: JSON.stringify({ + ...values, + contentHash: proposedHash, + metadataHash: hash( + JSON.stringify({ + title: resolvedTitle, + description: resolvedDescription, + icon: resolvedIcon, + }), + ), + }), + syncState: "linked", + freshness: "fresh", + lastSyncedAt: now, + lastSourceUpdatedAt: now, + updatedAt: now, + }) + .where(eq(schema.contentDatabaseSourceRows.id, sourceRow.id)); + } + } + await tx + .update(schema.contentDatabaseSourceChangeSets) + .set({ + state: decision === "accept_source" ? "applied" : "rejected", + updatedAt: now, + }) + .where(eq(schema.contentDatabaseSourceChangeSets.id, changeSetId)); + const remaining = await tx + .select({ id: schema.contentDatabaseSourceChangeSets.id }) + .from(schema.contentDatabaseSourceChangeSets) + .where( + and( + eq( + schema.contentDatabaseSourceChangeSets.sourceId, + target.source.id, + ), + eq(schema.contentDatabaseSourceChangeSets.direction, "incoming"), + eq(schema.contentDatabaseSourceChangeSets.state, "proposed"), + ), + ); + await tx + .update(schema.contentDatabaseSources) + .set({ + syncState: remaining.length ? "error" : "linked", + freshness: + remaining.length || decision === "keep_content" ? "stale" : "fresh", + lastError: remaining.length + ? `${remaining.length} file conflict${remaining.length === 1 ? "" : "s"} require review.` + : decision === "accept_source" + ? null + : "Content was kept; push the retained revision to the folder when ready.", + updatedAt: now, + }) + .where(eq(schema.contentDatabaseSources.id, target.source.id)); + }); + await writeAppState("refresh-signal", { ts: Date.now() }); + return { + success: true, + changeSetId, + decision, + documentId: target.document.id, + sourceId: target.source.id, + sourceDeleted: sourceDeletion, + state: decision === "accept_source" ? "applied" : "rejected", + }; + }, +}); diff --git a/templates/content/actions/resync-content-database-source.db.test.ts b/templates/content/actions/resync-content-database-source.db.test.ts index 6f2fb28430..a8d6141fcd 100644 --- a/templates/content/actions/resync-content-database-source.db.test.ts +++ b/templates/content/actions/resync-content-database-source.db.test.ts @@ -7,7 +7,9 @@ import { rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +// guard:allow-unscoped — isolated SQLite fixtures intentionally inspect rows directly. +import { getDbExec } from "@agent-native/core/db"; import { and, eq, ne } from "drizzle-orm"; import { afterAll, @@ -554,6 +556,7 @@ let seedCollabFromText: typeof import("@agent-native/core/collab").seedFromText; let getCollabText: typeof import("@agent-native/core/collab").getText; const OWNER = "owner@example.com"; +const IMPORT_SPACE_ID = "builder_import_test_space"; beforeAll(async () => { process.env.DATABASE_URL = `file:${TEST_DB_PATH}`; @@ -562,6 +565,35 @@ beforeAll(async () => { schema = dbModule.schema; const plugin = (await import("../server/plugins/db.js")).default; await plugin(undefined as any); + const { systemIdsForContentSpace } = await import("./_content-spaces.js"); + const filesIds = systemIdsForContentSpace(IMPORT_SPACE_ID, "files"); + const now = new Date().toISOString(); + await getDb().insert(schema.documents).values({ + id: filesIds.documentId, + spaceId: IMPORT_SPACE_ID, + ownerEmail: OWNER, + title: "Files", + content: "", + visibility: "private", + createdAt: now, + updatedAt: now, + }); + await getDb().insert(schema.contentDatabases).values({ + id: filesIds.databaseId, + spaceId: IMPORT_SPACE_ID, + systemRole: "files", + ownerEmail: OWNER, + documentId: filesIds.documentId, + title: "Files", + createdAt: now, + updatedAt: now, + }); + await getDbExec().execute(`CREATE TABLE IF NOT EXISTS organizations ( + id TEXT PRIMARY KEY, name TEXT NOT NULL, created_by TEXT NOT NULL, created_at INTEGER NOT NULL + )`); + 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 INTEGER NOT NULL + )`); resync = (await import("./_database-source-utils.js")) .resyncBuilderCmsSourceSnapshot; importBuilderEntries = (await import("./_database-source-utils.js")) @@ -1255,6 +1287,7 @@ it("resync re-links only the source's own rows, never another collection's (self const databaseDocId = "doc_db_resync"; await db.insert(schema.documents).values({ id: databaseDocId, + spaceId: IMPORT_SPACE_ID, ownerEmail: OWNER, title: "DB", createdAt: now, @@ -1262,6 +1295,7 @@ it("resync re-links only the source's own rows, never another collection's (self }); await db.insert(schema.contentDatabases).values({ id: databaseId, + spaceId: IMPORT_SPACE_ID, ownerEmail: OWNER, documentId: databaseDocId, title: "DB", @@ -1388,6 +1422,7 @@ it("records freshly imported Builder row identities even when title and URL keys const databaseDocId = "doc_db_resync_duplicate_keys"; await db.insert(schema.documents).values({ id: databaseDocId, + spaceId: IMPORT_SPACE_ID, ownerEmail: OWNER, title: "DB duplicate keys", createdAt: now, @@ -1395,6 +1430,7 @@ it("records freshly imported Builder row identities even when title and URL keys }); await db.insert(schema.contentDatabases).values({ id: databaseId, + spaceId: IMPORT_SPACE_ID, ownerEmail: OWNER, documentId: databaseDocId, title: "DB duplicate keys", diff --git a/templates/content/actions/reveal-local-source-file.ts b/templates/content/actions/reveal-local-source-file.ts index b6f7b14dab..86179b4636 100644 --- a/templates/content/actions/reveal-local-source-file.ts +++ b/templates/content/actions/reveal-local-source-file.ts @@ -9,10 +9,6 @@ import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; import { serializeDocumentSource } from "./_document-source.js"; -import { - getLocalFileDocument, - isContentLocalFileMode, -} from "./_local-file-documents.js"; async function revealPath(absolutePath: string) { const target = path.resolve(absolutePath); @@ -46,19 +42,12 @@ export default defineAction({ id: z.string().describe("Document ID"), }), run: async ({ id }) => { - let absolutePath: string | undefined; - - if (await isContentLocalFileMode()) { - const doc = await getLocalFileDocument(id); - absolutePath = doc.source?.absolutePath; - } else { - const access = await assertAccess("document", id, "viewer"); - const [doc] = await getDb() - .select() - .from(schema.documents) - .where(eq(schema.documents.id, access.resource.id)); - absolutePath = serializeDocumentSource(doc)?.absolutePath; - } + const access = await assertAccess("document", id, "viewer"); + const [doc] = await getDb() + .select() + .from(schema.documents) + .where(eq(schema.documents.id, access.resource.id)); + const absolutePath = serializeDocumentSource(doc)?.absolutePath; if (!absolutePath) { throw new Error( diff --git a/templates/content/actions/search-documents.ts b/templates/content/actions/search-documents.ts index 63903a1e64..ce67ed5630 100644 --- a/templates/content/actions/search-documents.ts +++ b/templates/content/actions/search-documents.ts @@ -8,10 +8,6 @@ import { documentDiscoveryFilter, parseDocumentHideFromSearch, } from "../server/lib/documents.js"; -import { - isContentLocalFileMode, - listLocalFileDocuments, -} from "./_local-file-documents.js"; function escapeLike(s: string): string { return s.replace(/([\\%_])/g, "\\$1"); @@ -50,36 +46,6 @@ export default defineAction({ run: async (args) => { const query = args.query; - if (await isContentLocalFileMode()) { - const normalizedQuery = query.toLowerCase(); - const docs = (await listLocalFileDocuments()) - .filter((doc) => doc.source?.kind !== "folder") - .filter((doc) => !doc.hideFromSearch) - .filter( - (doc) => - !normalizedQuery || - doc.title.toLowerCase().includes(normalizedQuery) || - (doc.description ?? "").toLowerCase().includes(normalizedQuery) || - doc.content.toLowerCase().includes(normalizedQuery), - ) - .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) - .slice(0, args.limit); - - return { - documents: docs.map((doc) => ({ - id: doc.id, - parentId: doc.parentId, - title: doc.title, - description: doc.description, - icon: doc.icon, - snippet: makeSnippet(doc.content, query), - contentLength: doc.content.length, - hideFromSearch: doc.hideFromSearch, - updatedAt: doc.updatedAt, - })), - }; - } - const db = getDb(); const pattern = `%${escapeLike(query)}%`; diff --git a/templates/content/actions/share-local-file-document.ts b/templates/content/actions/share-local-file-document.ts index a0519b47df..d86595b38e 100644 --- a/templates/content/actions/share-local-file-document.ts +++ b/templates/content/actions/share-local-file-document.ts @@ -5,7 +5,7 @@ import { getRequestOrgId, getRequestUserEmail, } from "@agent-native/core/server/request-context"; -import { and, eq, sql } from "drizzle-orm"; +import { and, eq, isNull, or, sql } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; @@ -13,6 +13,12 @@ import { parseDocumentFavorite, parseDocumentHideFromSearch, } from "../server/lib/documents.js"; +import { ensureDocumentFilesMembership } from "./_content-files.js"; +import { + organizationContentSpaceId, + personalContentSpaceId, + provisionContentSpaces, +} from "./_content-spaces.js"; import { serializeDocumentSource } from "./_document-source.js"; import { getLocalFileDocument, @@ -86,6 +92,15 @@ export default defineAction({ const now = new Date().toISOString(); const orgId = getRequestOrgId() ?? null; const db = getDb(); + const provisioned = await provisionContentSpaces(db, userEmail); + const targetSpaceId = orgId + ? organizationContentSpaceId(orgId) + : personalContentSpaceId(userEmail); + if (!provisioned.spaceIds.includes(targetSpaceId)) { + throw new Error( + "The active organization does not have a writable Content space.", + ); + } const [existing] = await db .select() @@ -96,6 +111,10 @@ export default defineAction({ eq(schema.documents.sourceMode, "database"), eq(schema.documents.sourceKind, "local-file-copy"), eq(schema.documents.sourcePath, sourcePath), + or( + eq(schema.documents.spaceId, targetSpaceId), + isNull(schema.documents.spaceId), + ), ), ) .limit(1); @@ -104,6 +123,7 @@ export default defineAction({ await db .update(schema.documents) .set({ + spaceId: existing.spaceId ?? targetSpaceId, title: localDocument.title, content: localDocument.content, icon: localDocument.icon, @@ -115,6 +135,8 @@ export default defineAction({ }) .where(eq(schema.documents.id, existing.id)); + await ensureDocumentFilesMembership(db, existing.id, now); + const [row] = await db .select() .from(schema.documents) @@ -140,6 +162,7 @@ export default defineAction({ await db.insert(schema.documents).values({ id: documentId, + spaceId: targetSpaceId, ownerEmail: userEmail, orgId, parentId: null, @@ -158,6 +181,7 @@ export default defineAction({ createdAt: now, updatedAt: now, }); + await ensureDocumentFilesMembership(db, documentId, now); }, ); diff --git a/templates/content/actions/space-aware-writers.db.test.ts b/templates/content/actions/space-aware-writers.db.test.ts new file mode 100644 index 0000000000..ff34a6aba5 --- /dev/null +++ b/templates/content/actions/space-aware-writers.db.test.ts @@ -0,0 +1,212 @@ +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 { and, eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +vi.mock("./_local-file-documents.js", async (importOriginal) => { + const original = + await importOriginal(); + return { ...original, isContentLocalFileMode: async () => false }; +}); + +const TEST_DB_PATH = join( + tmpdir(), + `space-aware-writers-${process.pid}-${Date.now()}.sqlite`, +); + +type Schema = typeof import("../server/db/schema.js"); +let getDb: () => any; +let schema: Schema; +let createDocument: typeof import("./create-document.js").default; +let createContentDatabase: typeof import("./create-content-database.js").default; +let addDatabaseItem: typeof import("./add-database-item.js").default; +let organizationContentSpaceId: typeof import("./_content-spaces.js").organizationContentSpaceId; + +const OWNER = "owner@example.com"; +const MEMBER = "member@example.com"; +const OUTSIDER = "outsider@example.com"; + +beforeAll(async () => { + process.env.DATABASE_URL = `file:${TEST_DB_PATH}`; + const dbModule = await import("../server/db/index.js"); + getDb = dbModule.getDb; + schema = dbModule.schema; + createDocument = (await import("./create-document.js")).default; + createContentDatabase = (await import("./create-content-database.js")) + .default; + addDatabaseItem = (await import("./add-database-item.js")).default; + ({ organizationContentSpaceId } = await import("./_content-spaces.js")); + 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 INTEGER NOT NULL + )`); + 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 INTEGER NOT NULL + )`); +}, 60000); + +afterAll(() => { + for (const suffix of ["", "-shm", "-wal"]) { + rmSync(`${TEST_DB_PATH}${suffix}`, { force: true }); + } +}); + +async function addOrganizationMember(args: { + orgId: string; + email: string; + role?: string; +}) { + await getDbExec().execute({ + sql: "INSERT OR IGNORE INTO organizations (id, name, created_by, created_at) VALUES (?, ?, ?, ?)", + args: [args.orgId, "Shared workspace", OWNER, Date.now()], + }); + await getDbExec().execute({ + sql: "INSERT INTO org_members (id, org_id, email, role, joined_at) VALUES (?, ?, ?, ?, ?)", + args: [ + `${args.orgId}:${args.email}`, + args.orgId, + args.email, + args.role ?? "member", + Date.now(), + ], + }); +} + +async function filesMemberships(documentId: string) { + return getDb() + .select({ + itemId: schema.contentDatabaseItems.id, + databaseId: schema.contentDatabaseItems.databaseId, + spaceId: schema.contentDatabases.spaceId, + }) + .from(schema.contentDatabaseItems) + .innerJoin( + schema.contentDatabases, + eq(schema.contentDatabases.id, schema.contentDatabaseItems.databaseId), + ) + .where( + and( + eq(schema.contentDatabaseItems.documentId, documentId), + eq(schema.contentDatabases.systemRole, "files"), + ), + ); +} + +describe("space-aware document writers", () => { + it("defaults root pages to personal Files and keeps nested pages in the parent space", async () => { + const parent = await runWithRequestContext({ userEmail: OWNER }, () => + createDocument.run({ title: "Parent" }), + ); + const child = await runWithRequestContext({ userEmail: OWNER }, () => + createDocument.run({ title: "Child", parentId: parent.id }), + ); + + const rows = await getDb() + .select({ id: schema.documents.id, spaceId: schema.documents.spaceId }) + .from(schema.documents) + .where(eq(schema.documents.id, child.id)); + expect(rows[0]?.spaceId).toBeTruthy(); + await expect(filesMemberships(parent.id)).resolves.toHaveLength(1); + await expect(filesMemberships(child.id)).resolves.toEqual([ + expect.objectContaining({ spaceId: rows[0]?.spaceId }), + ]); + + await expect( + runWithRequestContext({ userEmail: OWNER }, () => + createDocument.run({ + title: "Wrong space", + parentId: parent.id, + spaceId: "content_space_elsewhere", + }), + ), + ).rejects.toThrow("parent Content space"); + }); + + it("lets an ordinary org member create an owned org-visible root page but denies outsiders", async () => { + const orgId = "org-shared-writers"; + await addOrganizationMember({ orgId, email: MEMBER }); + const spaceId = organizationContentSpaceId(orgId); + const created = await runWithRequestContext( + { userEmail: MEMBER, orgId }, + () => createDocument.run({ title: "Member page", spaceId }), + ); + + const [document] = await getDb() + .select() + .from(schema.documents) + .where(eq(schema.documents.id, created.id)); + expect(document).toMatchObject({ + spaceId, + ownerEmail: MEMBER, + orgId, + visibility: "org", + }); + await expect(filesMemberships(created.id)).resolves.toHaveLength(1); + + const createdDatabase = await runWithRequestContext( + { userEmail: MEMBER, orgId }, + () => + createContentDatabase.run({ + title: "Member database", + spaceId, + }), + ); + const [database] = await getDb() + .select() + .from(schema.contentDatabases) + .where(eq(schema.contentDatabases.id, createdDatabase.database.id)); + expect(database).toMatchObject({ spaceId, ownerEmail: MEMBER, orgId }); + await expect( + filesMemberships(createdDatabase.database.documentId), + ).resolves.toHaveLength(1); + + await expect( + runWithRequestContext({ userEmail: OUTSIDER }, () => + createDocument.run({ title: "No entry", spaceId }), + ), + ).rejects.toThrow("Not authorized"); + }); + + it("keeps databases and their rows in one space and repairs converted page membership", async () => { + const page = await runWithRequestContext({ userEmail: OWNER }, () => + createDocument.run({ title: "Convert me" }), + ); + const [pageRow] = await getDb() + .select({ spaceId: schema.documents.spaceId }) + .from(schema.documents) + .where(eq(schema.documents.id, page.id)); + await getDb() + .delete(schema.contentDatabaseItems) + .where(eq(schema.contentDatabaseItems.documentId, page.id)); + + const converted = await runWithRequestContext({ userEmail: OWNER }, () => + createContentDatabase.run({ documentId: page.id }), + ); + const [databaseRow] = await getDb() + .select({ spaceId: schema.contentDatabases.spaceId }) + .from(schema.contentDatabases) + .where(eq(schema.contentDatabases.id, converted.database.id)); + expect(databaseRow?.spaceId).toBe(pageRow?.spaceId); + await expect(filesMemberships(page.id)).resolves.toHaveLength(1); + + const row = await runWithRequestContext({ userEmail: OWNER }, () => + addDatabaseItem.run({ + databaseId: converted.database.id, + title: "Database row", + }), + ); + const [rowDocument] = await getDb() + .select({ spaceId: schema.documents.spaceId }) + .from(schema.documents) + .where(eq(schema.documents.id, row.createdDocumentId!)); + expect(rowDocument?.spaceId).toBe(pageRow?.spaceId); + await expect( + filesMemberships(row.createdDocumentId!), + ).resolves.toHaveLength(1); + }); +}); diff --git a/templates/content/actions/split-mode-retirement.test.ts b/templates/content/actions/split-mode-retirement.test.ts new file mode 100644 index 0000000000..a7ac148800 --- /dev/null +++ b/templates/content/actions/split-mode-retirement.test.ts @@ -0,0 +1,38 @@ +import fs from "node:fs/promises"; + +import { describe, expect, it } from "vitest"; + +const canonicalDocumentActions = [ + "create-document.ts", + "delete-document.ts", + "move-document.ts", + "search-documents.ts", + "pull-document.ts", + "list-documents.ts", + "get-document.ts", + "edit-document.ts", + "update-document.ts", + "view-screen.ts", + "reveal-local-source-file.ts", + "remove-local-file-source.ts", +]; + +describe("Content split-mode retirement", () => { + it("keeps canonical document actions on the SQL path regardless of process mode", async () => { + for (const file of canonicalDocumentActions) { + const source = await fs.readFile(new URL(file, import.meta.url), "utf8"); + expect(source, file).not.toContain("AGENT_NATIVE_MODE"); + expect(source, file).not.toContain("isContentLocalFileMode"); + expect(source, file).not.toContain("LocalFileDocument"); + } + }); + + it("does not include the global mode selector in the explicit local-artifact compatibility cache", async () => { + const source = await fs.readFile( + new URL("_local-file-documents.ts", import.meta.url), + "utf8", + ); + expect(source).not.toContain("AGENT_NATIVE_MODE"); + expect(source).not.toContain("isAgentNativeLocalFileMode"); + }); +}); diff --git a/templates/content/actions/submit-content-database-form.db.test.ts b/templates/content/actions/submit-content-database-form.db.test.ts index fdc1f88a30..2850ddb744 100644 --- a/templates/content/actions/submit-content-database-form.db.test.ts +++ b/templates/content/actions/submit-content-database-form.db.test.ts @@ -19,6 +19,7 @@ type Schema = typeof import("../server/db/schema.js"); let getDb: () => any; let schema: Schema; let submitForm: typeof import("./submit-content-database-form.js").default; +let spaceId: string; beforeAll(async () => { process.env.DATABASE_URL = `file:${TEST_DB_PATH}`; @@ -28,6 +29,30 @@ beforeAll(async () => { submitForm = (await import("./submit-content-database-form.js")).default; const plugin = (await import("../server/plugins/db.js")).default; await plugin(undefined as any); + const { systemIdsForContentSpace } = await import("./_content-spaces.js"); + spaceId = `form_space_${Date.now()}`; + const filesIds = systemIdsForContentSpace(spaceId, "files"); + const now = new Date().toISOString(); + await getDb().insert(schema.documents).values({ + id: filesIds.documentId, + spaceId, + ownerEmail: OWNER, + title: "Files", + content: "", + visibility: "private", + createdAt: now, + updatedAt: now, + }); + await getDb().insert(schema.contentDatabases).values({ + id: filesIds.databaseId, + spaceId, + systemRole: "files", + ownerEmail: OWNER, + documentId: filesIds.documentId, + title: "Files", + createdAt: now, + updatedAt: now, + }); }, 60_000); afterAll(() => { @@ -49,6 +74,7 @@ async function seedFormDatabase() { const requesterId = `requester_${suffix}`; await db.insert(schema.documents).values({ id: databaseDocumentId, + spaceId, ownerEmail: OWNER, title: "Design asks", content: "", @@ -58,6 +84,7 @@ async function seedFormDatabase() { }); await db.insert(schema.contentDatabases).values({ id: databaseId, + spaceId, ownerEmail: OWNER, documentId: databaseDocumentId, title: "Design asks", @@ -179,6 +206,12 @@ describe("submit-content-database-form", () => { verified: true, urlPath: `/page/${result.createdDocumentId}`, }); + await expect( + getDb() + .select({ spaceId: schema.documents.spaceId }) + .from(schema.documents) + .where(eq(schema.documents.id, result.createdDocumentId)), + ).resolves.toEqual([{ spaceId }]); expect(result.deepLink).toContain(result.createdDocumentId); const db = getDb(); diff --git a/templates/content/actions/submit-content-database-form.ts b/templates/content/actions/submit-content-database-form.ts index cf240ddae1..0dd8f8772b 100644 --- a/templates/content/actions/submit-content-database-form.ts +++ b/templates/content/actions/submit-content-database-form.ts @@ -24,6 +24,7 @@ import { type DocumentPropertyType, type DocumentPropertyValue, } from "../shared/properties.js"; +import { ensureDocumentFilesMembership } from "./_content-files.js"; import { nanoid, parseDatabaseViewConfig } from "./_property-utils.js"; const submitContentDatabaseFormSchema = z.object({ @@ -197,6 +198,9 @@ export default defineAction({ ), ); if (!database) throw new Error(`Database "${databaseId}" not found.`); + if (!database.spaceId) { + throw new Error("Database does not belong to a Content space."); + } const access = await assertAccess( "document", @@ -204,6 +208,11 @@ export default defineAction({ "editor", ); const databaseDocument = access.resource; + if (databaseDocument.spaceId !== database.spaceId) { + throw new Error( + "Database page and database belong to different Content spaces.", + ); + } const definitions = await db .select() .from(schema.documentPropertyDefinitions) @@ -318,6 +327,7 @@ export default defineAction({ await tx.insert(schema.documents).values({ id: documentId, + spaceId: database.spaceId, ownerEmail: database.ownerEmail, orgId: database.orgId, parentId: database.documentId, @@ -381,6 +391,8 @@ export default defineAction({ ); } + await ensureDocumentFilesMembership(tx, documentId, now); + const [savedDocument] = await tx .select({ id: schema.documents.id, diff --git a/templates/content/actions/sync-local-folder-source.ts b/templates/content/actions/sync-local-folder-source.ts new file mode 100644 index 0000000000..277f1144d8 --- /dev/null +++ b/templates/content/actions/sync-local-folder-source.ts @@ -0,0 +1,638 @@ +import { createHash } from "node:crypto"; + +import { defineAction } from "@agent-native/core"; +import { writeAppState } from "@agent-native/core/application-state"; +import { getRequestUserEmail } from "@agent-native/core/server/request-context"; +import { assertAccess } from "@agent-native/core/sharing"; +import { and, eq, inArray } from "drizzle-orm"; +import { z } from "zod"; + +import { getDb, schema } from "../server/db/index.js"; +import type { ContentDatabaseSourceTruthPolicy } from "../shared/api.js"; +import { + isBuilderMdxSourcePath, + isContentSourcePath, + parseContentSourceFile, +} from "../shared/content-source.js"; +import { ensureDocumentsFilesMembership } from "./_content-files.js"; +import { resolveContentSpaceAccess } from "./_content-space-access.js"; +import { LOCAL_FOLDER_SOURCE_TYPE } from "./_local-folder-source.js"; + +const MAX_SOURCE_FILES = 500; +const MAX_SOURCE_FILE_BYTES = 2 * 1024 * 1024; + +function opaqueId(kind: string, value: string) { + return `${kind}_${createHash("sha256").update(value).digest("hex").slice(0, 32)}`; +} + +function contentHash(content: string) { + return createHash("sha256").update(content).digest("hex"); +} + +function metadataHash(value: { + title: string; + description?: string | null; + icon?: string | null; +}) { + return contentHash( + JSON.stringify({ + title: value.title, + description: value.description ?? "", + icon: value.icon ?? null, + }), + ); +} + +function parseJson(value: string | null | undefined) { + if (!value) return {} as Record; + try { + const parsed = JSON.parse(value) as unknown; + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + } catch { + return {}; + } +} + +function truthPolicy(value: unknown): ContentDatabaseSourceTruthPolicy { + return value === "source_primary" || value === "reviewed_bidirectional" + ? value + : "database_primary"; +} + +function normalizedEntries(files: Record) { + return Object.entries(files) + .filter(([path]) => isContentSourcePath(path)) + .sort(([left], [right]) => left.localeCompare(right)); +} + +function sourceValues(args: { + path: string; + title: string; + hash: string; + metadataHash: string; +}) { + return JSON.stringify({ + relativePath: args.path, + extension: args.path.toLowerCase().endsWith(".mdx") ? ".mdx" : ".md", + title: args.title, + contentHash: args.hash, + metadataHash: args.metadataHash, + }); +} + +function bodyChangeJson(args: { currentHash: string; incomingHash: string }) { + return JSON.stringify({ + summary: "Local folder and Content both changed since the last sync.", + currentExcerpt: null, + proposedExcerpt: null, + currentHash: args.currentHash, + proposedHash: args.incomingHash, + }); +} + +function deletionChangeJson(args: { + path: string; + previousHash: string | null; +}) { + return JSON.stringify({ + operation: "source_delete", + relativePath: args.path, + previousHash: args.previousHash, + }); +} + +function metadataChanges( + current: { title: string; description?: string | null; icon?: string | null }, + proposed: { + title: string; + description?: string | null; + icon?: string | null; + }, +) { + return (["title", "description", "icon"] as const).flatMap((field) => { + const currentValue = + current[field] ?? (field === "description" ? "" : null); + const proposedValue = + proposed[field] ?? (field === "description" ? "" : null); + return currentValue === proposedValue + ? [] + : [{ field, currentValue, proposedValue }]; + }); +} + +export default defineAction({ + description: + "Refresh a connected local-folder source through the trusted browser/Desktop bridge, materializing Markdown/MDX as normal SQL-backed Content pages and recording safe source identity and conflicts.", + schema: z.object({ + sourceId: z.string().min(1), + files: z + .record(z.string(), z.string().max(MAX_SOURCE_FILE_BYTES)) + .refine((value) => Object.keys(value).length <= MAX_SOURCE_FILES, { + message: `Sync is limited to ${MAX_SOURCE_FILES} files.`, + }), + dryRun: z.boolean().optional().default(false), + }), + run: async ({ sourceId, files, dryRun }) => { + const userEmail = getRequestUserEmail(); + if (!userEmail) throw new Error("no authenticated user"); + const builderPaths = Object.keys(files).filter(isBuilderMdxSourcePath); + if (builderPaths.length) { + throw new Error( + `Builder .builder.mdx files require the Builder actions: ${builderPaths.join(", ")}`, + ); + } + const entries = normalizedEntries(files); + const db = getDb(); + const [target] = await db + .select({ + source: schema.contentDatabaseSources, + database: schema.contentDatabases, + }) + .from(schema.contentDatabaseSources) + .innerJoin( + schema.contentDatabases, + eq( + schema.contentDatabases.id, + schema.contentDatabaseSources.databaseId, + ), + ) + .where(eq(schema.contentDatabaseSources.id, sourceId)); + if ( + !target || + target.source.sourceType !== LOCAL_FOLDER_SOURCE_TYPE || + target.database.systemRole !== "files" || + !target.database.spaceId + ) { + throw new Error(`Local folder source "${sourceId}" not found`); + } + await resolveContentSpaceAccess(target.database.spaceId, "editor"); + + const parsed = entries.map(([path, value]) => + parseContentSourceFile(path, value), + ); + const parseErrors = parsed.flatMap((file) => + file.errors?.length + ? [{ path: file.path, reason: file.errors.join(" ") }] + : [], + ); + const valid = parsed.filter((file) => !file.errors?.length); + const duplicateIds = new Set(); + const seenIds = new Set(); + for (const file of valid) { + if (!file.id) continue; + if (seenIds.has(file.id)) duplicateIds.add(file.id); + seenIds.add(file.id); + } + if (duplicateIds.size) { + throw new Error( + `Duplicate source ids: ${[...duplicateIds].sort().join(", ")}`, + ); + } + + const storedRows = await db + .select() + .from(schema.contentDatabaseSourceRows) + .where(eq(schema.contentDatabaseSourceRows.sourceId, sourceId)); + const rowByPath = new Map( + storedRows.map((row) => [ + String(parseJson(row.sourceValuesJson).relativePath ?? ""), + row, + ]), + ); + const rowByDocumentId = new Map( + storedRows.map((row) => [row.documentId, row]), + ); + const explicitIds = valid.flatMap((file) => (file.id ? [file.id] : [])); + const linkedIds = storedRows.map((row) => row.documentId); + const candidateIds = [...new Set([...explicitIds, ...linkedIds])]; + const existingDocuments = candidateIds.length + ? await db + .select() + .from(schema.documents) + .where(inArray(schema.documents.id, candidateIds)) + : []; + const documentById = new Map( + existingDocuments.map((document) => [document.id, document]), + ); + + for (const id of explicitIds) { + const existing = documentById.get(id); + if (!existing) continue; + if (existing.spaceId !== target.database.spaceId) { + throw new Error( + `Document "${id}" belongs to another Content space and cannot be imported here`, + ); + } + await assertAccess("document", id, "editor"); + } + + const metadata = parseJson(target.source.metadataJson); + const policy = truthPolicy(metadata.truthPolicy); + const now = new Date().toISOString(); + const created: Array<{ id: string; path: string; title: string }> = []; + const updated: Array<{ id: string; path: string; title: string }> = []; + const unchanged: Array<{ id: string; path: string; title: string }> = []; + const skipped = [...parseErrors]; + const conflicts: Array<{ id: string; path: string; title: string }> = []; + const outbound: Array<{ id: string; path: string; title: string }> = []; + + const plans = valid.map((file, index) => { + const pathRow = rowByPath.get(file.path); + const id = + file.id ?? + pathRow?.documentId ?? + opaqueId("content_local_file", `${sourceId}:${file.path}`); + const existing = documentById.get(id); + const previousRow = rowByDocumentId.get(id) ?? pathRow; + const previousValues = parseJson(previousRow?.sourceValuesJson); + const previousHash = + typeof previousValues.contentHash === "string" + ? previousValues.contentHash + : null; + const previousMetadataHash = + typeof previousValues.metadataHash === "string" + ? previousValues.metadataHash + : null; + const incomingHash = contentHash(file.content); + const incomingMetadataHash = metadataHash(file); + const localHash = existing ? contentHash(existing.content) : null; + const localMetadataHash = existing ? metadataHash(existing) : null; + const incomingChanged = + previousHash !== incomingHash || + previousMetadataHash !== incomingMetadataHash; + const localChanged = + !!existing && + ((!!previousHash && localHash !== previousHash) || + (!!previousMetadataHash && + localMetadataHash !== previousMetadataHash)); + const conflict = + !!existing && + !!previousHash && + incomingChanged && + (localChanged || policy === "database_primary") && + localHash !== incomingHash; + const keepContent = + !!existing && + !!previousHash && + localChanged && + !incomingChanged && + policy !== "source_primary"; + const applyIncoming = + incomingChanged || (localChanged && policy === "source_primary"); + return { + file, + index, + id, + existing, + previousRow, + previousHash, + incomingHash, + incomingMetadataHash, + localHash, + localMetadataHash, + incomingChanged, + localChanged, + conflict, + keepContent, + applyIncoming, + }; + }); + + const currentDocumentIds = new Set(plans.map((plan) => plan.id)); + const missingRows = storedRows.filter( + (row) => !currentDocumentIds.has(row.documentId), + ); + for (const row of missingRows) { + const values = parseJson(row.sourceValuesJson); + const path = String(values.relativePath ?? row.sourceDisplayKey ?? ""); + const document = documentById.get(row.documentId); + conflicts.push({ + id: row.documentId, + path, + title: document?.title ?? path, + }); + } + + for (const plan of plans) { + if (plan.conflict) { + conflicts.push({ + id: plan.id, + path: plan.file.path, + title: plan.file.title, + }); + } else if (!plan.existing) { + created.push({ + id: plan.id, + path: plan.file.path, + title: plan.file.title, + }); + } else if ( + plan.applyIncoming || + plan.existing.title !== plan.file.title || + plan.existing.sourcePath !== plan.file.path + ) { + updated.push({ + id: plan.id, + path: plan.file.path, + title: plan.file.title, + }); + } else { + unchanged.push({ + id: plan.id, + path: plan.file.path, + title: plan.file.title, + }); + } + if (plan.keepContent) { + outbound.push({ + id: plan.id, + path: plan.file.path, + title: plan.file.title, + }); + } + } + + if (!dryRun) { + await db.transaction(async (tx: any) => { + for (const plan of plans) { + if (plan.conflict) { + const changeSetId = opaqueId( + "content_source_change", + `${sourceId}:${plan.id}:${plan.incomingHash}`, + ); + await tx + .insert(schema.contentDatabaseSourceChangeSets) + .values({ + id: changeSetId, + ownerEmail: target.source.ownerEmail, + sourceId, + databaseItemId: plan.previousRow?.databaseItemId ?? null, + documentId: plan.id, + kind: "body_update", + direction: "incoming", + state: "proposed", + pushMode: "none", + localOnly: 0, + summary: `Review concurrent local-folder changes for "${plan.file.title}".`, + fieldChangesJson: JSON.stringify( + metadataChanges(plan.existing!, plan.file), + ), + bodyChangeJson: bodyChangeJson({ + currentHash: plan.localHash!, + incomingHash: plan.incomingHash, + }), + createdAt: now, + updatedAt: now, + }) + .onConflictDoNothing(); + continue; + } + + if (plan.keepContent) { + const changeSetId = opaqueId( + "content_source_change", + `${sourceId}:${plan.id}:outbound:${plan.localHash}`, + ); + await tx + .insert(schema.contentDatabaseSourceChangeSets) + .values({ + id: changeSetId, + ownerEmail: target.source.ownerEmail, + sourceId, + databaseItemId: plan.previousRow?.databaseItemId ?? null, + documentId: plan.id, + kind: "body_update", + direction: "outbound", + state: "proposed", + pushMode: "none", + localOnly: 1, + summary: `Content has a newer revision of "${plan.file.title}" ready to export.`, + fieldChangesJson: "[]", + bodyChangeJson: bodyChangeJson({ + currentHash: plan.incomingHash, + incomingHash: plan.localHash!, + }), + createdAt: now, + updatedAt: now, + }) + .onConflictDoNothing(); + continue; + } + + if (!plan.existing) { + await tx.insert(schema.documents).values({ + id: plan.id, + spaceId: target.database.spaceId, + ownerEmail: userEmail, + orgId: target.database.orgId, + parentId: null, + title: plan.file.title, + content: plan.file.content, + description: plan.file.description ?? "", + icon: plan.file.icon ?? null, + position: plan.file.position ?? plan.index, + isFavorite: plan.file.isFavorite ? 1 : 0, + hideFromSearch: plan.file.hideFromSearch ? 1 : 0, + sourceMode: "local-files", + sourceKind: "file", + sourcePath: plan.file.path, + sourceRootPath: target.source.sourceName, + sourceUpdatedAt: now, + visibility: target.database.orgId ? "org" : "private", + createdAt: now, + updatedAt: now, + }); + } else if ( + plan.applyIncoming || + plan.existing.sourcePath !== plan.file.path + ) { + await tx + .insert(schema.documentVersions) + .values({ + id: opaqueId( + "content_document_version", + `${plan.id}:${plan.existing.updatedAt}:${plan.incomingHash}`, + ), + ownerEmail: plan.existing.ownerEmail, + documentId: plan.id, + title: plan.existing.title, + content: plan.existing.content, + createdAt: now, + }) + .onConflictDoNothing(); + await tx + .update(schema.documents) + .set({ + ...(plan.applyIncoming + ? { + title: plan.file.title, + content: plan.file.content, + description: + plan.file.description ?? plan.existing.description, + icon: plan.file.icon ?? plan.existing.icon, + } + : {}), + sourceMode: "local-files", + sourceKind: "file", + sourcePath: plan.file.path, + sourceRootPath: target.source.sourceName, + sourceUpdatedAt: now, + updatedAt: now, + }) + .where(eq(schema.documents.id, plan.id)); + } + } + + const materializedIds = plans + .filter((plan) => !plan.conflict) + .map((plan) => plan.id); + await ensureDocumentsFilesMembership(tx, materializedIds, now); + const filesItems = materializedIds.length + ? await tx + .select() + .from(schema.contentDatabaseItems) + .where( + and( + eq( + schema.contentDatabaseItems.databaseId, + target.database.id, + ), + inArray( + schema.contentDatabaseItems.documentId, + materializedIds, + ), + ), + ) + : []; + const itemByDocumentId = new Map< + string, + typeof schema.contentDatabaseItems.$inferSelect + >( + filesItems.map( + (item: typeof schema.contentDatabaseItems.$inferSelect) => + [item.documentId, item] as const, + ), + ); + for (const plan of plans.filter((candidate) => !candidate.conflict)) { + if (plan.keepContent) continue; + const item = itemByDocumentId.get(plan.id); + if (!item) { + throw new Error( + `Files membership was not created for "${plan.id}"`, + ); + } + const rowId = opaqueId( + "content_source_row", + `${sourceId}:${plan.id}`, + ); + const rowValues = { + sourceId, + databaseItemId: item.id, + documentId: plan.id, + sourceRowId: plan.file.path, + sourceQualifiedId: `local-folder://${target.source.sourceTable}/${encodeURIComponent(plan.file.path)}`, + sourceDisplayKey: plan.file.path, + sourceValuesJson: sourceValues({ + path: plan.file.path, + title: plan.file.title, + hash: plan.incomingHash, + metadataHash: plan.incomingMetadataHash, + }), + provenance: "trusted local-folder bridge", + syncState: "linked", + freshness: "fresh", + lastSyncedAt: now, + lastSourceUpdatedAt: now, + updatedAt: now, + }; + await tx + .insert(schema.contentDatabaseSourceRows) + .values({ + id: rowId, + ownerEmail: target.source.ownerEmail, + createdAt: now, + ...rowValues, + }) + .onConflictDoNothing(); + await tx + .update(schema.contentDatabaseSourceRows) + .set(rowValues) + .where(eq(schema.contentDatabaseSourceRows.id, rowId)); + } + for (const row of missingRows) { + const values = parseJson(row.sourceValuesJson); + const path = String( + values.relativePath ?? row.sourceDisplayKey ?? row.sourceRowId, + ); + const changeSetId = opaqueId( + "content_source_change", + `${sourceId}:${row.documentId}:source-delete:${path}`, + ); + await tx + .insert(schema.contentDatabaseSourceChangeSets) + .values({ + id: changeSetId, + ownerEmail: target.source.ownerEmail, + sourceId, + databaseItemId: row.databaseItemId, + documentId: row.documentId, + kind: "metadata_update", + direction: "incoming", + state: "proposed", + pushMode: "none", + localOnly: 0, + summary: `The local source no longer contains "${path}". Review before unlinking it.`, + fieldChangesJson: "[]", + bodyChangeJson: deletionChangeJson({ + path, + previousHash: + typeof values.contentHash === "string" + ? values.contentHash + : null, + }), + createdAt: now, + updatedAt: now, + }) + .onConflictDoNothing(); + } + await tx + .update(schema.contentDatabaseSources) + .set({ + syncState: conflicts.length ? "error" : "linked", + freshness: conflicts.length || outbound.length ? "stale" : "fresh", + lastRefreshedAt: now, + lastSourceUpdatedAt: now, + lastError: conflicts.length + ? `${conflicts.length} file conflict${conflicts.length === 1 ? "" : "s"} require review.` + : outbound.length + ? `${outbound.length} Content change${outbound.length === 1 ? " is" : "s are"} ready to export.` + : null, + updatedAt: now, + }) + .where(eq(schema.contentDatabaseSources.id, sourceId)); + }); + await writeAppState("refresh-signal", { ts: Date.now() }); + } + + return { + sourceId, + spaceId: target.database.spaceId, + filesDatabaseId: target.database.id, + truthPolicy: policy, + dryRun, + filesSeen: entries.length, + created, + updated, + unchanged, + conflicts, + outbound, + skipped, + errors: parseErrors, + idByPath: Object.fromEntries( + plans.map((plan) => [plan.file.path, plan.id]), + ), + }; + }, +}); diff --git a/templates/content/actions/sync-manifest-local-folder-source.ts b/templates/content/actions/sync-manifest-local-folder-source.ts new file mode 100644 index 0000000000..e82902ea5c --- /dev/null +++ b/templates/content/actions/sync-manifest-local-folder-source.ts @@ -0,0 +1,123 @@ +import { defineAction } from "@agent-native/core"; +import { + getLocalArtifactApp, + listConfiguredLocalArtifactFiles, + readConfiguredLocalArtifactFile, + type LocalArtifactOptions, +} from "@agent-native/core/local-artifacts"; +import { z } from "zod"; + +import { parseContentSourceFile } from "../shared/content-source.js"; +import connectLocalFolderSource from "./connect-local-folder-source.js"; +import syncLocalFolderSource from "./sync-local-folder-source.js"; + +const CONTENT_LOCAL_DEFAULTS: LocalArtifactOptions["defaults"] = { + roots: [], + components: "components", + hide: ["**/_*.md", "**/_*.mdx"], +}; + +function localOptions(): LocalArtifactOptions { + return { appId: "content", defaults: CONTENT_LOCAL_DEFAULTS }; +} + +export default defineAction({ + description: + "Bootstrap a manifest-declared local-folder source through the trusted local server bridge into normal SQL-backed Content.", + schema: z.object({ + connectionId: z.string().min(1).max(300), + file: z.string().optional(), + spaceId: z.string().optional(), + dryRun: z.boolean().optional().default(false), + }), + run: async ({ connectionId, file, spaceId, dryRun }) => { + const options = localOptions(); + const app = await getLocalArtifactApp(options); + const root = app.roots.find( + (candidate) => + candidate.source?.type === "local-folder" && + candidate.source.connectionId === connectionId, + ); + if (!root) { + throw new Error( + `Local folder connection "${connectionId}" is not declared in agent-native.json`, + ); + } + const source = root.source; + if (!source || source.type !== "local-folder") { + throw new Error(`Local folder connection "${connectionId}" is invalid`); + } + const metadata = (await listConfiguredLocalArtifactFiles(options)).filter( + (candidate) => candidate.rootPath === root.path, + ); + const files: Record = {}; + for (const candidate of metadata) { + const loaded = await readConfiguredLocalArtifactFile({ + ...options, + path: candidate.path, + }); + if (loaded) files[candidate.path] = loaded.content; + } + if (file && !Object.prototype.hasOwnProperty.call(files, file)) { + throw new Error(`Local file "${file}" is not in the configured folder`); + } + const connection = await connectLocalFolderSource.run({ + connectionId, + label: root.name, + spaceId, + createSourceBackedSpace: !spaceId, + truthPolicy: source.truthPolicy ?? "source_primary", + dryRun, + }); + if (!connection.connected || !connection.sourceId) { + const parsed = Object.entries(files).map(([path, content]) => + parseContentSourceFile(path, content), + ); + const valid = parsed.filter((candidate) => !candidate.errors?.length); + return { + sourceId: null, + spaceId: null, + filesDatabaseId: null, + truthPolicy: source.truthPolicy ?? "source_primary", + dryRun: true, + filesSeen: parsed.length, + created: valid.map((candidate) => ({ + id: candidate.id ?? "", + path: candidate.path, + title: candidate.title, + })), + updated: [], + unchanged: [], + conflicts: [], + outbound: [], + skipped: parsed.flatMap((candidate) => + candidate.errors?.length + ? [{ path: candidate.path, reason: candidate.errors.join(" ") }] + : [], + ), + errors: parsed.flatMap((candidate) => + candidate.errors?.length + ? [{ path: candidate.path, reason: candidate.errors.join(" ") }] + : [], + ), + idByPath: Object.fromEntries( + valid.map((candidate) => [candidate.path, candidate.id ?? ""]), + ), + connectionId, + requestedFile: file ?? null, + requestedDocumentId: null, + }; + } + const result = await syncLocalFolderSource.run({ + sourceId: connection.sourceId, + files, + dryRun, + }); + return { + ...result, + connectionId, + requestedFile: file ?? null, + requestedDocumentId: file ? (result.idByPath[file] ?? null) : null, + }; + }, +}); diff --git a/templates/content/actions/update-content-database-view.test.ts b/templates/content/actions/update-content-database-view.test.ts index bd7ff4d4af..c8a5f4ce59 100644 --- a/templates/content/actions/update-content-database-view.test.ts +++ b/templates/content/actions/update-content-database-view.test.ts @@ -68,6 +68,28 @@ describe("update content database view", () => { }); }); + it("accepts sidebar as a saved database view type", () => { + const parsed = action.schema.parse({ + databaseId: "database", + viewConfig: { + activeViewId: "sidebar", + views: [ + { + id: "sidebar", + name: "Navigation", + type: "sidebar", + sorts: [], + filters: [], + columnWidths: {}, + groupByPropertyId: "status", + }, + ], + }, + }); + + expect(parsed.viewConfig.views[0]?.type).toBe("sidebar"); + }); + it("keeps legacy JSON compatible and normalizes form questions on startup reads", () => { const legacy = parseDatabaseViewConfig( JSON.stringify({ diff --git a/templates/content/actions/update-content-database-view.ts b/templates/content/actions/update-content-database-view.ts index 55d2691007..de44b8eb5b 100644 --- a/templates/content/actions/update-content-database-view.ts +++ b/templates/content/actions/update-content-database-view.ts @@ -66,7 +66,16 @@ const viewSchema = z.object({ id: z.string(), name: z.string(), type: z - .enum(["table", "board", "list", "gallery", "calendar", "timeline", "form"]) + .enum([ + "table", + "board", + "list", + "gallery", + "calendar", + "timeline", + "form", + "sidebar", + ]) .default("table"), sorts: z.array(sortSchema).default([]), filters: z.array(filterSchema).default([]), diff --git a/templates/content/actions/update-document.ts b/templates/content/actions/update-document.ts index 336510a054..1da7956e4c 100644 --- a/templates/content/actions/update-document.ts +++ b/templates/content/actions/update-document.ts @@ -14,11 +14,6 @@ import type { DocumentUpdateResponse } from "../shared/api.js"; import { BUILDER_CMS_BODY_CONTENT_KEY } from "./_builder-cms-source-adapter.js"; import { reconcileInlineDatabasesForDocument } from "./_content-database-lifecycle.js"; import { serializeDocumentSource } from "./_document-source.js"; -import { - isLocalDocumentId, - isContentLocalFileMode, - updateLocalFileDocument, -} from "./_local-file-documents.js"; // Not (yet) part of the shared API surface — kept local to avoid touching // shared/api.ts, which another workstream owns concurrently. Structural @@ -198,16 +193,6 @@ export default defineAction({ const isAgentCaller = ctx?.caller === "tool" || ctx?.caller === "mcp" || ctx?.caller === "a2a"; - if ((await isContentLocalFileMode()) && isLocalDocumentId(id)) { - const doc = await updateLocalFileDocument(id, args); - await writeAppState("refresh-signal", { ts: Date.now() }); - return { - ...doc, - urlPath: `/page/${doc.id}`, - softDeletedDatabaseIds: [], - }; - } - const access = await assertAccess("document", id, "editor"); const existing = access.resource; const ownerEmail = existing.ownerEmail as string; diff --git a/templates/content/actions/view-screen.ts b/templates/content/actions/view-screen.ts index 611c7c9a37..8ad90e0a87 100644 --- a/templates/content/actions/view-screen.ts +++ b/templates/content/actions/view-screen.ts @@ -37,14 +37,6 @@ import { getDocumentContextPath, serializeDatabaseMembership, } from "./_database-utils.js"; -import { serializeDocumentSource } from "./_document-source.js"; -import { - getLocalFileDocument, - getLocalDocumentContextPath, - isContentLocalFileMode, - isLocalDocumentId, - localContentViewScreenSummary, -} from "./_local-file-documents.js"; import { listPropertiesForDocument, serializeDatabase, @@ -659,53 +651,13 @@ export default defineAction({ run: async () => { const navigation = await readAppStateForCurrentTab("navigation"); const localFilesState = await readAppState("local-files"); + const contentSpaceState = await readAppState("content-space"); const screen: Record = {}; if (navigation) screen.navigation = navigation; + if (contentSpaceState) screen.contentSpace = contentSpaceState; const nav = navigation as NavigationState | null; - if (await isContentLocalFileMode()) { - screen.localFiles = { - ...(await localContentViewScreenSummary()), - actions: [ - "list-documents", - "get-document", - "create-document", - "update-document", - "delete-document", - "share-local-file-document", - ], - }; - if (nav?.documentId && isLocalDocumentId(nav.documentId)) { - screen.document = { - ...(await getLocalFileDocument(nav.documentId)), - contextPath: await getLocalDocumentContextPath(nav.documentId), - }; - } else if (nav?.documentId) { - const access = await resolveAccess("document", nav.documentId); - if (access) { - const doc = access.resource; - screen.document = { - id: doc.id, - parentId: doc.parentId, - title: doc.title, - content: doc.content, - description: doc.description, - icon: doc.icon, - position: doc.position, - isFavorite: parseDocumentFavorite(doc.isFavorite), - hideFromSearch: parseDocumentHideFromSearch(doc.hideFromSearch), - visibility: doc.visibility, - source: serializeDocumentSource(doc), - createdAt: doc.createdAt, - updatedAt: doc.updatedAt, - contextPath: await getDocumentContextPath(doc), - }; - } - } - return screen; - } - const db = getDb(); if (nav?.view === "local-files") { diff --git a/templates/content/app/components/editor/database/DatabaseView.tsx b/templates/content/app/components/editor/database/DatabaseView.tsx index 60c5992fe3..36f68b6ae3 100644 --- a/templates/content/app/components/editor/database/DatabaseView.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.tsx @@ -116,6 +116,7 @@ import { IconGripVertical, IconLayoutKanban, IconLayoutGrid, + IconLayoutSidebar, IconList, IconLock, IconMinus, @@ -248,6 +249,7 @@ import { VisualEditor } from "../VisualEditor"; import { DatabaseFormView } from "./FormView"; import { DatabaseGalleryView } from "./GalleryView"; import { DatabaseListView } from "./ListView"; +import { DatabaseSidebarView } from "./sidebar"; import { DatabaseTimelineView } from "./TimelineView"; export interface DatabaseViewProps { @@ -307,6 +309,7 @@ const DATABASE_VIEW_TYPES: ContentDatabaseViewType[] = [ "timeline", "calendar", "form", + "sidebar", ]; const DATABASE_OPEN_PAGES_IN: ContentDatabaseOpenPagesIn[] = [ "preview", @@ -2010,6 +2013,27 @@ function DatabaseTable({ orderedProperties, visibleItems, ]); + const sidebarGroups = useMemo( + () => + databaseVisibleGroups( + databaseViewItemGroups( + visibleItems, + orderedProperties, + activeView.groupByPropertyId, + ), + activeView.hideEmptyGroups === true, + ), + [ + activeView.groupByPropertyId, + activeView.hideEmptyGroups, + orderedProperties, + visibleItems, + ], + ); + const sidebarIsGrouped = useMemo( + () => !!databaseViewGroupingProperty(activeView, orderedProperties), + [activeView, orderedProperties], + ); useEffect(() => { if (!data?.database.id) return; @@ -2433,6 +2457,22 @@ function DatabaseTable({ onDeletedPreviewItem={handleDeletedPreviewItem} onOpenPage={openItemPage} /> + ) : activeView.type === "sidebar" ? ( + ) : activeView.type === "list" ? ( - secondary - ? onNavPush({ - kind: "keyConfirm", - candidate: { - sourceType: secondary.sourceType, - sourceName: secondary.sourceName, - sourceTable: secondary.sourceTable, - displayName: secondary.sourceName, - existingSourceId: secondary.id, - }, - }) - : undefined - } + onAddDetails={() => { + if (!secondary || secondary.sourceType === "local-folder") return; + onNavPush({ + kind: "keyConfirm", + candidate: { + sourceType: secondary.sourceType, + sourceName: secondary.sourceName, + sourceTable: secondary.sourceTable, + displayName: secondary.sourceName, + existingSourceId: secondary.id, + }, + }); + }} onAddItems={async () => { if (!secondary) return; await onChangeSourceRole(secondary.id, "items"); @@ -8151,12 +8190,17 @@ function DatabaseSettingsSourcePanel({ item.id !== selectedSource.id && !sourceAddsDetails(item), - )} + canAddDetails={ + selectedSource.sourceType !== "local-folder" && + sources.some( + (item) => + item.id !== selectedSource.id && !sourceAddsDetails(item), + ) + } canEdit={canEdit} pending={selectedSourceRoleControlPending} - onAddDetails={() => + onAddDetails={() => { + if (selectedSource.sourceType === "local-folder") return; onNavPush({ kind: "keyConfirm", candidate: { @@ -8166,8 +8210,8 @@ function DatabaseSettingsSourcePanel({ displayName: selectedSource.sourceName, existingSourceId: selectedSource.id, }, - }) - } + }); + }} onAddItems={async () => { await onChangeSourceRole(selectedSource.id, "items"); onNavReplace([]); @@ -12988,7 +13032,8 @@ function normalizeClientDatabaseView( value.type === "gallery" || value.type === "calendar" || value.type === "timeline" || - value.type === "form" + value.type === "form" || + value.type === "sidebar" ? value.type : "table"; return createDatabaseView( @@ -13524,6 +13569,7 @@ function databaseViewIcon(type: ContentDatabaseViewType) { if (type === "calendar") return IconCalendar; if (type === "timeline") return IconTimeline; if (type === "form") return IconForms; + if (type === "sidebar") return IconLayoutSidebar; return IconTable; } @@ -13534,6 +13580,7 @@ function databaseViewDefaultName(type: ContentDatabaseViewType) { if (type === "calendar") return "Calendar"; if (type === "timeline") return "Timeline"; if (type === "form") return "Form"; + if (type === "sidebar") return "Sidebar"; return "Table"; } @@ -13584,7 +13631,8 @@ export function databaseViewGroupingProperty( if ( view.type !== "table" && view.type !== "list" && - view.type !== "gallery" + view.type !== "gallery" && + view.type !== "sidebar" ) { return null; } diff --git a/templates/content/app/components/editor/database/grouping.ts b/templates/content/app/components/editor/database/grouping.ts index c8cdd43e00..5a72329b02 100644 --- a/templates/content/app/components/editor/database/grouping.ts +++ b/templates/content/app/components/editor/database/grouping.ts @@ -73,7 +73,8 @@ export function databaseViewGroupingProperty( if ( view.type !== "table" && view.type !== "list" && - view.type !== "gallery" + view.type !== "gallery" && + view.type !== "sidebar" ) { return null; } diff --git a/templates/content/app/components/editor/database/settings.tsx b/templates/content/app/components/editor/database/settings.tsx index 3fd696b9a7..a38671b570 100644 --- a/templates/content/app/components/editor/database/settings.tsx +++ b/templates/content/app/components/editor/database/settings.tsx @@ -768,20 +768,19 @@ function DatabaseSettingsSourcePanel({ source={secondary} canEdit={canEdit} pending={sourceActionPending} - onAddDetails={() => - secondary - ? onNavPush({ - kind: "keyConfirm", - candidate: { - sourceType: secondary.sourceType, - sourceName: secondary.sourceName, - sourceTable: secondary.sourceTable, - displayName: secondary.sourceName, - existingSourceId: secondary.id, - }, - }) - : undefined - } + onAddDetails={() => { + if (!secondary || secondary.sourceType === "local-folder") return; + onNavPush({ + kind: "keyConfirm", + candidate: { + sourceType: secondary.sourceType, + sourceName: secondary.sourceName, + sourceTable: secondary.sourceTable, + displayName: secondary.sourceName, + existingSourceId: secondary.id, + }, + }); + }} onAddItems={async () => { if (!secondary) return; await onChangeSourceRole(secondary.id, "items"); @@ -1142,12 +1141,16 @@ function DatabaseSettingsSourcePanel({ item.id !== source.id && !sourceAddsDetails(item), - )} + canAddDetails={ + source.sourceType !== "local-folder" && + sources.some( + (item) => item.id !== source.id && !sourceAddsDetails(item), + ) + } canEdit={canEdit} pending={sourceActionPending} - onAddDetails={() => + onAddDetails={() => { + if (source.sourceType === "local-folder") return; onNavPush({ kind: "keyConfirm", candidate: { @@ -1157,8 +1160,8 @@ function DatabaseSettingsSourcePanel({ displayName: source.sourceName, existingSourceId: source.id, }, - }) - } + }); + }} onAddItems={async () => { await onChangeSourceRole(source.id, "items"); onNavReplace([]); diff --git a/templates/content/app/components/editor/database/shared.tsx b/templates/content/app/components/editor/database/shared.tsx index 6d6d14693a..32c8cf99dd 100644 --- a/templates/content/app/components/editor/database/shared.tsx +++ b/templates/content/app/components/editor/database/shared.tsx @@ -18,6 +18,7 @@ import { IconFilter, IconLayoutGrid, IconLayoutKanban, + IconLayoutSidebar, IconList, IconMinus, IconSearch, @@ -71,6 +72,7 @@ export function databaseViewIcon(type: ContentDatabaseViewType) { if (type === "calendar") return IconCalendar; if (type === "timeline") return IconTimeline; if (type === "form") return IconForms; + if (type === "sidebar") return IconLayoutSidebar; return IconTable; } diff --git a/templates/content/app/components/editor/database/sidebar.test.tsx b/templates/content/app/components/editor/database/sidebar.test.tsx new file mode 100644 index 0000000000..670f7ba235 --- /dev/null +++ b/templates/content/app/components/editor/database/sidebar.test.tsx @@ -0,0 +1,72 @@ +import type { ContentDatabaseItem } from "@shared/api"; +import { renderToStaticMarkup } from "react-dom/server"; +import { MemoryRouter } from "react-router"; +import { describe, expect, it } from "vitest"; + +import { DatabaseSidebarView, databaseSidebarRows } from "./sidebar"; +import type { DatabaseBoardGroup } from "./types"; + +const item = (id: string, title: string) => + ({ + id: `item-${id}`, + databaseId: "database", + document: { + id, + parentId: null, + title, + content: "", + icon: null, + position: 0, + isFavorite: false, + hideFromSearch: false, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + position: 0, + properties: [], + }) as ContentDatabaseItem; + +describe("DatabaseSidebarView", () => { + it("keeps grouped rows in their filtered and sorted group order", () => { + const groups = [ + { id: "todo", label: "Todo", items: [item("first", "First")] }, + { id: "done", label: "Done", items: [item("second", "Second")] }, + ] as DatabaseBoardGroup[]; + + expect( + databaseSidebarRows(groups).map((candidate) => candidate.id), + ).toEqual(["item-first", "item-second"]); + }); + + it("renders compact router links for an ungrouped saved view", () => { + const markup = renderToStaticMarkup( + + {}} + onPreview={() => {}} + /> + , + ); + + expect(markup).toContain('href="/page/page"'); + expect(markup).toContain("Project"); + }); +}); diff --git a/templates/content/app/components/editor/database/sidebar.tsx b/templates/content/app/components/editor/database/sidebar.tsx new file mode 100644 index 0000000000..d561b76572 --- /dev/null +++ b/templates/content/app/components/editor/database/sidebar.tsx @@ -0,0 +1,330 @@ +import { Button } from "@agent-native/toolkit/ui/button"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@agent-native/toolkit/ui/collapsible"; +import { ScrollArea } from "@agent-native/toolkit/ui/scroll-area"; +import type { + ContentDatabaseItem, + ContentDatabaseOpenPagesIn, + ContentDatabasePersonalViewOverrides, + ContentDatabaseResponse, + ContentDatabaseViewConfig, +} from "@shared/api"; +import { + IconChevronDown, + IconChevronRight, + IconFileText, + IconLoader2, +} from "@tabler/icons-react"; +import { useEffect, useState, type MouseEvent } from "react"; +import { Link } from "react-router"; + +import { cn } from "@/lib/utils"; + +import { applyDatabaseView } from "./filter-sort"; +import { + databaseViewGroupingProperty, + databaseViewItemGroups, + databaseVisibleGroups, +} from "./grouping"; +import type { DatabaseBoardGroup } from "./types"; +import { + activeDatabaseView, + defaultDatabaseViewConfig, + normalizeClientDatabaseViewConfig, +} from "./view-config"; + +function applyPersonalSidebarViewOverrides( + savedViewConfig: ContentDatabaseViewConfig, + overrides: ContentDatabasePersonalViewOverrides | null | undefined, +) { + const saved = normalizeClientDatabaseViewConfig(savedViewConfig); + if (!overrides) return saved; + const overridesByViewId = new Map( + overrides.views.map((view) => [view.id, view]), + ); + return normalizeClientDatabaseViewConfig({ + ...saved, + activeViewId: saved.views.some((view) => view.id === overrides.activeViewId) + ? overrides.activeViewId + : saved.activeViewId, + views: saved.views.map((view) => { + const override = overridesByViewId.get(view.id); + return override + ? { + ...view, + sorts: override.sorts, + filters: override.filters, + filterMode: override.filterMode, + } + : view; + }), + }); +} + +export function ContentFilesSidebarView({ + data, + overrides, + isLoading, + labels, + onSelectView, +}: { + data: ContentDatabaseResponse | undefined; + overrides: ContentDatabasePersonalViewOverrides | null | undefined; + isLoading: boolean; + onSelectView?: (viewId: string) => void; + labels: Omit< + Parameters[0], + | "groups" + | "grouped" + | "isLoading" + | "hasActiveConstraints" + | "openPagesIn" + | "onClearResultConstraints" + | "onPreview" + >; +}) { + const viewConfig = applyPersonalSidebarViewOverrides( + data?.database.viewConfig ?? defaultDatabaseViewConfig(), + overrides, + ); + const [selectedViewId, setSelectedViewId] = useState( + () => viewConfig.activeViewId, + ); + useEffect(() => { + setSelectedViewId(viewConfig.activeViewId); + }, [viewConfig.activeViewId]); + const activeView = + viewConfig.views.find((view) => view.id === selectedViewId) ?? + activeDatabaseView(viewConfig); + const items = data + ? applyDatabaseView( + data.items, + data.properties, + "", + activeView.filters, + activeView.sorts, + activeView.filterMode ?? "and", + ) + : []; + const groups = databaseVisibleGroups( + databaseViewItemGroups( + items, + data?.properties ?? [], + activeView.groupByPropertyId, + ), + activeView.hideEmptyGroups === true, + ); + return ( +
+ {viewConfig.views.length > 1 && ( +
+ {viewConfig.views.map((view) => ( + + ))} +
+ )} + {}} + onPreview={() => {}} + /> +
+ ); +} + +export function DatabaseSidebarView({ + groups, + grouped, + isLoading, + hasActiveConstraints, + openPagesIn, + onClearResultConstraints, + onPreview, + loadingLabel, + noMatchesLabel, + clearLabel, + navigationLabel, + untitledLabel, +}: { + groups: DatabaseBoardGroup[]; + grouped: boolean; + isLoading: boolean; + hasActiveConstraints: boolean; + openPagesIn: ContentDatabaseOpenPagesIn; + onClearResultConstraints: () => void; + onPreview: (item: ContentDatabaseItem) => void; + loadingLabel: string; + noMatchesLabel: string; + clearLabel: string; + navigationLabel: string; + untitledLabel: string; +}) { + const [collapsedGroupIds, setCollapsedGroupIds] = useState>( + () => new Set(), + ); + const items = groups.flatMap((group) => group.items); + + function setGroupOpen(groupId: string, open: boolean) { + setCollapsedGroupIds((current) => { + const next = new Set(current); + if (open) next.delete(groupId); + else next.add(groupId); + return next; + }); + } + + if (isLoading) { + return ( +
+ + {loadingLabel} +
+ ); + } + + if (items.length === 0 && hasActiveConstraints) { + return ( +
+ {noMatchesLabel} + +
+ ); + } + + return ( + + + + ); +} + +function DatabaseSidebarRow({ + item, + openPagesIn, + onPreview, + untitledLabel, +}: { + item: ContentDatabaseItem; + openPagesIn: ContentDatabaseOpenPagesIn; + onPreview: (item: ContentDatabaseItem) => void; + untitledLabel: string; +}) { + function handleClick(event: MouseEvent) { + if ( + openPagesIn !== "preview" || + event.defaultPrevented || + event.button !== 0 || + event.metaKey || + event.altKey || + event.ctrlKey || + event.shiftKey + ) { + return; + } + event.preventDefault(); + onPreview(item); + } + + return ( + + {item.document.icon ? ( + + ) : ( + + )} + + {item.document.title || untitledLabel} + + + ); +} + +export function databaseSidebarRows(groups: DatabaseBoardGroup[]) { + return groups.flatMap((group) => group.items); +} diff --git a/templates/content/app/components/editor/database/types.ts b/templates/content/app/components/editor/database/types.ts index 776f07089e..30eb6e7630 100644 --- a/templates/content/app/components/editor/database/types.ts +++ b/templates/content/app/components/editor/database/types.ts @@ -42,6 +42,7 @@ export const DATABASE_VIEW_TYPES: ContentDatabaseViewType[] = [ "list", "timeline", "calendar", + "sidebar", ]; export const DATABASE_OPEN_PAGES_IN: ContentDatabaseOpenPagesIn[] = [ diff --git a/templates/content/app/components/editor/database/view-config.test.ts b/templates/content/app/components/editor/database/view-config.test.ts index 6cda114575..9203768e45 100644 --- a/templates/content/app/components/editor/database/view-config.test.ts +++ b/templates/content/app/components/editor/database/view-config.test.ts @@ -62,6 +62,26 @@ describe("database form view config", () => { expect(form).toMatchObject({ type: "form", formQuestions: [] }); }); + it("normalizes sidebar views and gives them a sidebar default name", () => { + const sidebar = createDatabaseView("", "sidebar", {}, "sidebar"); + expect(sidebar).toMatchObject({ + id: "sidebar", + name: "Sidebar", + type: "sidebar", + collapsedGroupIds: [], + }); + + const normalized = normalizeClientDatabaseViewConfig({ + activeViewId: "sidebar", + views: [sidebar], + sorts: [], + filters: [], + columnWidths: {}, + }); + expect(normalized.activeViewId).toBe("sidebar"); + expect(normalized.views[0]?.type).toBe("sidebar"); + }); + it("duplicates a form view with its question order and required flags", () => { const form = createDatabaseView( "Request", diff --git a/templates/content/app/components/editor/database/view-config.ts b/templates/content/app/components/editor/database/view-config.ts index 6b20939c41..c67b80e9ac 100644 --- a/templates/content/app/components/editor/database/view-config.ts +++ b/templates/content/app/components/editor/database/view-config.ts @@ -299,7 +299,8 @@ function normalizeClientDatabaseView( value.type === "gallery" || value.type === "calendar" || value.type === "timeline" || - value.type === "form" + value.type === "form" || + value.type === "sidebar" ? value.type : "table"; return createDatabaseView( @@ -460,6 +461,7 @@ export function databaseViewDefaultName(type: ContentDatabaseViewType) { if (type === "calendar") return "Calendar"; if (type === "timeline") return "Timeline"; if (type === "form") return "Form"; + if (type === "sidebar") return "Sidebar"; return "Table"; } diff --git a/templates/content/app/components/layout/Layout.tsx b/templates/content/app/components/layout/Layout.tsx index e741ff84b3..57ae92a927 100644 --- a/templates/content/app/components/layout/Layout.tsx +++ b/templates/content/app/components/layout/Layout.tsx @@ -24,6 +24,7 @@ const SIDEBAR_WIDTH_KEY = "sidebar-width"; const DEFAULT_SIDEBAR_WIDTH = 240; const MIN_SIDEBAR_WIDTH = 240; const MAX_SIDEBAR_WIDTH = 480; +const NARROW_DESKTOP_QUERY = "(max-width: 1099px)"; // Routes whose page renders its own custom toolbar (with AgentToggleButton). // Layout still mounts Sidebar + AgentSidebar, but skips its own Header so @@ -41,6 +42,22 @@ function loadSidebarWidth(): number { return DEFAULT_SIDEBAR_WIDTH; } +function useIsNarrowDesktop() { + const [isNarrow, setIsNarrow] = useState( + () => + typeof window !== "undefined" && + window.matchMedia(NARROW_DESKTOP_QUERY).matches, + ); + useEffect(() => { + const media = window.matchMedia(NARROW_DESKTOP_QUERY); + const update = () => setIsNarrow(media.matches); + update(); + media.addEventListener("change", update); + return () => media.removeEventListener("change", update); + }, []); + return isNarrow; +} + export function documentPageIdFromPathname(pathname: string) { return pathname.match(/^\/page\/(.+)/)?.[1] ?? null; } @@ -72,6 +89,7 @@ export function Layout({ children }: LayoutProps) { [activeDocumentId], ); const isMobile = useIsMobile(); + const isNarrowDesktop = useIsNarrowDesktop(); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [sidebarWidth, setSidebarWidth] = useState(loadSidebarWidth); @@ -105,6 +123,12 @@ export function Layout({ children }: LayoutProps) { return () => window.removeEventListener("keydown", onKeyDown); }, [createPage]); + useEffect(() => { + if (isNarrowDesktop) { + window.dispatchEvent(new Event("agent-panel:close")); + } + }, [isNarrowDesktop]); + const mobileSidebarTrigger = isMobile ? ( - - - void handleCreatePage()} > - - {t("sidebar.page")} - - void handleCreateDatabase(null)} - > - - {t("sidebar.database")} - - - - ); - - const renderCollapsedNewButton = () => ( - - - - + + {t("sidebar.newPage")} + + + - + + {t("sidebar.newDatabase")} + + + ) : null; + + const renderCollapsedNewButton = () => + selectedSpace ? ( + + + - {t("sidebar.new")} + {t("sidebar.newPage")} - - void handleCreatePage()} - > - - {t("sidebar.page")} - - void handleCreateDatabase(null)} - > - - {t("sidebar.database")} - - - - ); + ) : null; const renderLocalFilesNavButton = () => ( ( +
+
+ {t("sidebar.workspaces")} +
+ {contentSpaces.map((space) => ( + + ))} +
+ ); + + const renderFilesDatabase = () => ( +
+
+ {t("sidebar.files")} +
+ {selectedSpace ? ( + <> + { + if (!selectedSpace) return; + const current = filesPersonalView.data?.overrides; + updateFilesPersonalView.mutate({ + databaseId: selectedSpace.filesDatabaseId, + overrides: { + version: current?.version ?? 1, + activeViewId: viewId, + views: current?.views ?? [], + }, + }); + }} + labels={{ + loadingLabel: t("sidebar.loadingFiles"), + noMatchesLabel: t("database.noRowsMatchThisView"), + clearLabel: t("database.clearSearchAndFilters"), + navigationLabel: t("sidebar.files"), + untitledLabel: t("sidebar.untitled"), + }} + /> + {renderNewButton()} + + ) : ( +
+ {t("sidebar.noWorkspaces")} +
+ )} +
+ ); + const renderTrashSection = () => { if (trashItems.length === 0) return null; const collapsed = collapsedSections.trash; @@ -1309,58 +1422,9 @@ export function DocumentSidebar({ )} - {localFileMode ? ( - <> - {renderTreeSection({ - id: "local-files", - label: t("sidebar.localFiles"), - nodes: localFileTree, - emptyLabel: t("sidebar.noFilesYet"), - headerActions: renderLocalFilesSectionActions(), - footer: renderNewButton(), - })} - {databaseTree.length > 0 - ? renderTreeSection({ - id: "shared-copies", - label: t("sidebar.sharedCopies"), - nodes: databaseTree, - emptyLabel: t("sidebar.noSharedCopiesYet"), - className: "mt-3", - }) - : null} - {renderTrashSection()} - - ) : ( - <> - {localFileTree.length > 0 && - renderTreeSection({ - id: "local-files", - label: t("sidebar.localFiles"), - nodes: localFileTree, - emptyLabel: t("sidebar.noLocalFilesYet"), - className: "mb-2", - headerActions: renderLocalFilesSectionActions(), - })} - - {renderTreeSection({ - id: "private", - label: t("sidebar.private"), - nodes: privateTree, - emptyLabel: t("sidebar.noPrivatePagesYet"), - footer: renderNewButton(), - })} - - {!isLoading && - renderTreeSection({ - id: "organization", - label: t("sidebar.organization"), - nodes: organizationTree, - emptyLabel: t("sidebar.noOrganizationPagesYet"), - className: "mt-3", - })} - {renderTrashSection()} - - )} + {renderWorkspaceNavigation()} + {renderFilesDatabase()} + {renderTrashSection()} )} diff --git a/templates/content/app/hooks/use-content-database.ts b/templates/content/app/hooks/use-content-database.ts index 0efde0e43e..f260cc2b72 100644 --- a/templates/content/app/hooks/use-content-database.ts +++ b/templates/content/app/hooks/use-content-database.ts @@ -52,6 +52,10 @@ 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; +} + function isContentDatabaseQueryForDocument( queryKey: readonly unknown[], documentId: string, @@ -454,6 +458,18 @@ export function useContentDatabase(documentId: string | null, limit?: number) { ); } +export function useContentDatabaseById(databaseId: string | null) { + return useActionQuery( + "get-content-database", + databaseId ? { databaseId } : undefined, + { + enabled: !!databaseId, + retry: false, + placeholderData: (previous) => previous, + }, + ); +} + export function useCreateContentDatabase(documentId: string | null) { const queryClient = useQueryClient(); return useActionMutation( diff --git a/templates/content/app/hooks/use-content-spaces.ts b/templates/content/app/hooks/use-content-spaces.ts new file mode 100644 index 0000000000..aae758bb82 --- /dev/null +++ b/templates/content/app/hooks/use-content-spaces.ts @@ -0,0 +1,39 @@ +import { useActionMutation, useActionQuery } from "@agent-native/core/client"; +import { useQueryClient } from "@tanstack/react-query"; + +export type ContentSpaceSummary = { + id: string; + name: string; + kind: string; + filesDatabaseId: string; + orgId: string | null; + role: "owner" | "editor" | "viewer"; + catalogItemId: string; + catalogDocumentId: string; +}; + +export type ListContentSpacesResponse = { + catalogDatabaseId: string; + spaces: ContentSpaceSummary[]; +}; + +export function useContentSpaces() { + return useActionQuery( + "list-content-spaces", + undefined, + { + placeholderData: (previous) => previous, + }, + ); +} + +export function useEnsureContentSpaces() { + const queryClient = useQueryClient(); + return useActionMutation("ensure-content-spaces", { + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["action", "list-content-spaces"], + }); + }, + }); +} diff --git a/templates/content/app/i18n-data.ts b/templates/content/app/i18n-data.ts index 1d3bec5a21..15610bda56 100644 --- a/templates/content/app/i18n-data.ts +++ b/templates/content/app/i18n-data.ts @@ -2797,6 +2797,9 @@ const localFilesMessages = { summaryUnchanged: "{{count}} unchanged", summarySkipped: "{{count}} skipped", summaryErrors: "{{count}} errors", + summaryConflicts: "{{count}} conflicts", + conflictNeedsReview: + "Content and the folder both changed; review is required.", pageTitle: "Local files", foldersRemembered: "Folders remembered", linkedCount: "{{count}} linked", @@ -3111,11 +3114,14 @@ const enUS = { "“{{title}}” and all its sub-pages will be permanently deleted. This cannot be undone.", deletePageQuestion: "Delete page?", localFiles: "Local files", + files: "Files", + loadingFiles: "Loading files…", localFilesActions: "Local files actions", localFilesRemoved: "Local files removed", localFilesRemovedDescription: "{{count}} items removed", manageLocalFolders: "Manage folders", new: "New", + newDatabase: "New database", newPage: "New page", nextStep: "Next step", notionConfigureOAuthAuthorize: @@ -3147,6 +3153,7 @@ const enUS = { "Sync documents with your Notion workspace.", notionUploadCredentialsJson: "Upload credentials JSON", noFilesYet: "No files yet", + noWorkspaces: "No workspaces yet", noLocalFilesYet: "No local files yet", noOrganizationPagesYet: "No organization pages yet", noPagesFound: "No pages found", @@ -3172,6 +3179,7 @@ const enUS = { trash: "Trash", favorites: "Favorites", untitled: "Untitled", + workspaces: "Workspaces", }, }; @@ -8859,6 +8867,9 @@ export const messagesByLocale = { }, sidebar: { cannotReorderPages: "无法重新排序页面", + loadingFiles: "正在加载文件…", + newDatabase: "新建数据库", + noWorkspaces: "还没有工作区", collapse: "折叠侧边栏", expand: "展开侧边栏", failedCreatePage: "创建页面失败", @@ -9046,6 +9057,9 @@ export const messagesByLocale = { }, sidebar: { cannotReorderPages: "No se pueden reordenar las páginas", + loadingFiles: "Cargando archivos…", + newDatabase: "Nueva base de datos", + noWorkspaces: "Aún no hay espacios de trabajo", collapse: "Contraer barra lateral", expand: "Expandir barra lateral", failedCreatePage: "No se pudo crear la página", @@ -9234,6 +9248,9 @@ export const messagesByLocale = { }, sidebar: { cannotReorderPages: "Impossible de réordonner les pages", + loadingFiles: "Chargement des fichiers…", + newDatabase: "Nouvelle base de données", + noWorkspaces: "Aucun espace de travail pour le moment", collapse: "Réduire la barre latérale", expand: "Développer la barre latérale", failedCreatePage: "Échec de la création de la page", @@ -9420,6 +9437,9 @@ export const messagesByLocale = { }, sidebar: { cannotReorderPages: "Seiten können nicht neu angeordnet werden", + loadingFiles: "Dateien werden geladen…", + newDatabase: "Neue Datenbank", + noWorkspaces: "Noch keine Arbeitsbereiche", collapse: "Seitenleiste einklappen", expand: "Seitenleiste ausklappen", failedCreatePage: "Seite konnte nicht erstellt werden", @@ -9606,6 +9626,9 @@ export const messagesByLocale = { }, sidebar: { cannotReorderPages: "ページを並べ替えられません", + loadingFiles: "ファイルを読み込んでいます…", + newDatabase: "新しいデータベース", + noWorkspaces: "ワークスペースはまだありません", collapse: "サイドバーを折りたたむ", expand: "サイドバーを展開", failedCreatePage: "ページを作成できませんでした", @@ -9781,6 +9804,9 @@ export const messagesByLocale = { }, sidebar: { cannotReorderPages: "페이지 순서를 변경할 수 없습니다", + loadingFiles: "파일을 불러오는 중…", + newDatabase: "새 데이터베이스", + noWorkspaces: "아직 워크스페이스가 없습니다", collapse: "사이드바 접기", expand: "사이드바 펼치기", failedCreatePage: "페이지를 만들지 못했습니다", @@ -9968,6 +9994,9 @@ export const messagesByLocale = { }, sidebar: { cannotReorderPages: "Não é possível reordenar páginas", + loadingFiles: "Carregando arquivos…", + newDatabase: "Novo banco de dados", + noWorkspaces: "Ainda não há espaços de trabalho", collapse: "Recolher barra lateral", expand: "Expandir barra lateral", failedCreatePage: "Falha ao criar página", @@ -10141,6 +10170,9 @@ export const messagesByLocale = { }, sidebar: { cannotReorderPages: "पेजों को फिर से क्रमबद्ध नहीं किया जा सकता", + loadingFiles: "फ़ाइलें लोड हो रही हैं…", + newDatabase: "नया डेटाबेस", + noWorkspaces: "अभी कोई कार्यस्थान नहीं है", collapse: "साइडबार संकुचित करें", expand: "साइडबार फैलाएं", failedCreatePage: "पेज नहीं बन सका", @@ -10317,6 +10349,9 @@ export const messagesByLocale = { }, sidebar: { cannotReorderPages: "تعذرت إعادة ترتيب الصفحات", + loadingFiles: "جارٍ تحميل الملفات…", + newDatabase: "قاعدة بيانات جديدة", + noWorkspaces: "لا توجد مساحات عمل بعد", collapse: "طي الشريط الجانبي", expand: "توسيع الشريط الجانبي", failedCreatePage: "فشل إنشاء الصفحة", diff --git a/templates/content/app/i18n/zh-TW.ts b/templates/content/app/i18n/zh-TW.ts index e88fdbf976..f5875000bc 100644 --- a/templates/content/app/i18n/zh-TW.ts +++ b/templates/content/app/i18n/zh-TW.ts @@ -1012,6 +1012,8 @@ const messages = { summaryUnchanged: "{{count}} 個未更改", summarySkipped: "已跳過 {{count}} 個", summaryErrors: "{{count}} 個錯誤", + summaryConflicts: "{{count}} 個衝突", + conflictNeedsReview: "Content 和資料夾都已變更;需要進行審查。", pageTitle: "本機檔案", foldersRemembered: "已記住的資料夾", linkedCount: "已連結 {{count}} 個", @@ -1114,7 +1116,10 @@ const messages = { "「{{title}}」及其所有子頁面將被永久刪除。此操作無法復原。", deletePageQuestion: "刪除頁面?", localFiles: "本機檔案", + files: "檔案", + loadingFiles: "正在載入檔案…", new: "新增", + newDatabase: "新增資料庫", newPage: "新頁面", nextStep: "下一步", notionConfigureOAuthAuthorize: "設定 OAuth,然後授權你的工作區。", @@ -1143,6 +1148,7 @@ const messages = { notionSyncDocumentsDescription: "與 Notion 工作區同步檔案。", notionUploadCredentialsJson: "上傳憑證 JSON", noFilesYet: "還沒有檔案", + noWorkspaces: "還沒有工作區", noLocalFilesYet: "還沒有本機檔案", noOrganizationPagesYet: "還沒有組織頁面", noPagesFound: "未找到頁面", @@ -1164,6 +1170,7 @@ const messages = { trash: "垃圾桶", favorites: "釘選", untitled: "無標題", + workspaces: "工作區", }, }; diff --git a/templates/content/app/routes/_app.local-files.tsx b/templates/content/app/routes/_app.local-files.tsx index 88fc545746..d86be22b2c 100644 --- a/templates/content/app/routes/_app.local-files.tsx +++ b/templates/content/app/routes/_app.local-files.tsx @@ -15,7 +15,8 @@ import { IconUpload, } from "@tabler/icons-react"; import { useQueryClient } from "@tanstack/react-query"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useNavigate, useSearchParams } from "react-router"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -128,6 +129,18 @@ interface ImportContentSourceResult { unchanged: Array<{ id: string; path: string; title: string }>; skipped: Array<{ path: string; reason: string }>; errors: Array<{ path: string; reason: string }>; + conflicts?: Array<{ id: string; path: string; title: string }>; +} + +interface LocalFolderConnectionResult { + connected: boolean; + sourceId: string | null; + spaceId: string | null; + filesDatabaseId: string | null; +} + +interface ManifestFolderSyncResult extends ImportContentSourceResult { + requestedDocumentId: string | null; } interface RegisterLocalComponentWorkspaceResult { @@ -234,11 +247,20 @@ function browserDirectoryId() { return `browser-${crypto.randomUUID()}`; } +function opaqueDesktopDirectoryId(value: string) { + let hash = 2166136261; + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return `desktop-folder-${(hash >>> 0).toString(16).padStart(8, "0")}`; +} + function desktopDirectoryFromFolder( folder: DesktopContentFilesFolder, directories: SelectedDirectory[] = [], ): SelectedDirectory { - const id = folder.id ?? `desktop-${folder.path ?? folder.name}`; + const id = folder.id ?? opaqueDesktopDirectoryId(folder.path ?? folder.name); return { id, kind: "desktop", @@ -694,42 +716,6 @@ function upsertDirectory( ); } -function sourcePathForDirectoryFile( - directory: SelectedDirectory, - filePath: string, - directoryCount: number, -) { - if (directoryCount <= 1) return filePath; - return `${directory.sourcePrefix}/${filePath}`; -} - -function filesForImport( - directory: SelectedDirectory, - files: Record, - directoryCount: number, -) { - return Object.fromEntries( - Object.entries(files).map(([path, content]) => [ - sourcePathForDirectoryFile(directory, path, directoryCount), - content, - ]), - ); -} - -function filesForDirectoryExport( - directory: SelectedDirectory, - files: Record, - directoryCount: number, -) { - if (directoryCount <= 1) return files; - const prefix = `${directory.sourcePrefix}/`; - return Object.fromEntries( - Object.entries(files) - .filter(([path]) => path.startsWith(prefix)) - .map(([path, content]) => [path.slice(prefix.length), content]), - ); -} - function resultSummary( result: ImportContentSourceResult, t: ReturnType, @@ -740,6 +726,7 @@ function resultSummary( t("localFiles.summaryUnchanged", { count: result.unchanged.length }), t("localFiles.summarySkipped", { count: result.skipped.length }), t("localFiles.summaryErrors", { count: result.errors.length }), + t("localFiles.summaryConflicts", { count: result.conflicts?.length ?? 0 }), ].join(" | "); } @@ -750,12 +737,18 @@ export function meta() { export default function LocalFilesRoute() { const t = useT(); const queryClient = useQueryClient(); + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const targetSpaceId = searchParams.get("spaceId") || undefined; + const manifestConnectionId = searchParams.get("connectionId") || undefined; + const manifestFile = searchParams.get("file") || undefined; const { data: documents = [] } = useDocuments(); const [directories, setDirectories] = useState([]); const [status, setStatus] = useState({ kind: "idle" }); const [busy, setBusy] = useState(null); const [restoringDirectory, setRestoringDirectory] = useState(false); const supported = useMemo(supportsLocalFolderSync, []); + const manifestBootstrapRef = useRef(null); const documentSourceDirectories = useMemo( () => directories.length === 0 @@ -786,6 +779,59 @@ export default function LocalFilesRoute() { }); }, [localFolderRows]); + useEffect(() => { + if (!manifestConnectionId) return; + const key = `${manifestConnectionId}:${manifestFile ?? ""}:${targetSpaceId ?? ""}`; + if (manifestBootstrapRef.current === key) return; + manifestBootstrapRef.current = key; + setBusy("choose"); + void callAction( + "sync-manifest-local-folder-source" as never, + { + connectionId: manifestConnectionId, + file: manifestFile, + spaceId: targetSpaceId, + dryRun: false, + } as never, + ) + .then((result) => { + queryClient.invalidateQueries({ + queryKey: ["action", "list-documents"], + }); + queryClient.invalidateQueries({ + queryKey: ["action", "list-content-spaces"], + }); + setStatus( + result.conflicts?.length + ? { kind: "preview", result } + : { + kind: "success", + title: t("localFiles.foldersPulled"), + detail: resultSummary(result, t), + }, + ); + if (result.requestedDocumentId) { + navigate(`/page/${result.requestedDocumentId}`, { replace: true }); + } + }) + .catch((err) => { + manifestBootstrapRef.current = null; + setStatus({ + kind: "error", + title: t("localFiles.pullFailed"), + detail: err instanceof Error ? err.message : t("localFiles.tryAgain"), + }); + }) + .finally(() => setBusy(null)); + }, [ + manifestConnectionId, + manifestFile, + navigate, + queryClient, + t, + targetSpaceId, + ]); + useEffect(() => { if (!supported) return; let cancelled = false; @@ -859,6 +905,23 @@ export default function LocalFilesRoute() { ); } + async function ensureDirectoryConnection( + directory: SelectedDirectory, + dryRun = false, + ) { + return callAction( + "connect-local-folder-source" as never, + { + connectionId: directory.id, + label: directory.sourcePrefix || directory.name, + spaceId: targetSpaceId, + createSourceBackedSpace: !targetSpaceId, + truthPolicy: "source_primary", + dryRun, + } as never, + ); + } + async function pullDirectoryFiles( selected: SelectedDirectory, activeDirectories: SelectedDirectory[], @@ -898,33 +961,53 @@ export default function LocalFilesRoute() { queryClient.invalidateQueries({ queryKey: ["resources"] }); } } - if (Object.keys(files).length === 0) { + const connection = await ensureDirectoryConnection( + refreshedDirectory, + dryRun, + ); + if (dryRun && !connection.connected) { + const created = Object.keys(files).map((path) => ({ + id: "", + path, + title: + path + .split("/") + .pop() + ?.replace(/\.mdx?$/i, "") || path, + })); return { directories: nextDirectories, result: { - dryRun, - filesSeen: 0, - created: [], + dryRun: true, + filesSeen: created.length, + created, updated: [], unchanged: [], skipped: [], errors: [], + conflicts: [], }, }; } + if (!connection.sourceId) { + throw new Error("The local folder connection was not created"); + } const result = await callAction( - "import-content-source" as never, + "sync-local-folder-source" as never, { - files: filesForImport( - refreshedDirectory, - files, - nextDirectories.length, - ), + sourceId: connection.sourceId, + files, dryRun, } as never, ); if (!dryRun) { queryClient.invalidateQueries({ queryKey: ["action", "list-documents"] }); + queryClient.invalidateQueries({ + queryKey: ["action", "list-content-spaces"], + }); + queryClient.invalidateQueries({ + queryKey: ["action", "get-content-database"], + }); } return { directories: nextDirectories, result }; } @@ -990,11 +1073,15 @@ export default function LocalFilesRoute() { setBusy(`pull:${selected.id}`); const { result } = await pullDirectoryFiles(selected, nextDirectories); - setStatus({ - kind: "success", - title: t("localFiles.foldersPulled"), - detail: resultSummary(result, t), - }); + setStatus( + result.conflicts?.length + ? { kind: "preview", result } + : { + kind: "success", + title: t("localFiles.foldersPulled"), + detail: resultSummary(result, t), + }, + ); toast.success(t("localFiles.pulledLocalFiles")); await connectLocalComponentWorkspaces([selected]); } catch (err) { @@ -1020,16 +1107,13 @@ export default function LocalFilesRoute() { ) { throw new Error(t("localFiles.writePermissionNotGranted")); } + const connection = await ensureDirectoryConnection(directory); const bundle = await callAction( "export-content-source" as never, - {} as never, + { sourceId: connection.sourceId } as never, { method: "GET" }, ); - const files = filesForDirectoryExport( - directory, - bundle.files, - directories.length, - ); + const files = bundle.files; if (directory.kind === "desktop") { const desktopFiles = getDesktopContentFiles(); if (!desktopFiles) { @@ -1099,11 +1183,15 @@ export default function LocalFilesRoute() { setBusy(`pull:${directory.id}`); try { const { result } = await pullDirectoryFiles(directory, directories); - setStatus({ - kind: "success", - title: t("localFiles.pulledFromFolder"), - detail: resultSummary(result, t), - }); + setStatus( + result.conflicts?.length + ? { kind: "preview", result } + : { + kind: "success", + title: t("localFiles.pulledFromFolder"), + detail: resultSummary(result, t), + }, + ); toast.success(t("localFiles.pulledLocalFiles")); } catch (err) { setStatus({ @@ -1119,6 +1207,11 @@ export default function LocalFilesRoute() { async function handleRemove(directory: SelectedDirectory) { setBusy(`remove:${directory.id}`); try { + const connection = await ensureDirectoryConnection(directory); + await callAction( + "disconnect-local-folder-source" as never, + { sourceId: connection.sourceId } as never, + ); const nextDirectories = directories.filter( (candidate) => candidate.id !== directory.id, ); @@ -1132,6 +1225,12 @@ export default function LocalFilesRoute() { await persistSourceDirectories(nextDirectories); } setDirectories(nextDirectories); + queryClient.invalidateQueries({ + queryKey: ["action", "list-content-spaces"], + }); + queryClient.invalidateQueries({ + queryKey: ["action", "get-content-database"], + }); setStatus({ kind: "success", title: t("localFiles.folderRemoved"), @@ -1266,7 +1365,8 @@ export default function LocalFilesRoute() { {(status.result.skipped.length > 0 || - status.result.errors.length > 0) && ( + status.result.errors.length > 0 || + (status.result.conflicts?.length ?? 0) > 0) && ( <>
@@ -1284,6 +1384,15 @@ export default function LocalFilesRoute() {
))} + {status.result.conflicts?.slice(0, 6).map((item) => ( +
+ {item.path} + + {" "} + - {t("localFiles.conflictNeedsReview")} + +
+ ))} )} diff --git a/templates/content/changelog/2026-07-16-organize-every-workspace-through-a-customizable-files-databa.md b/templates/content/changelog/2026-07-16-organize-every-workspace-through-a-customizable-files-databa.md new file mode 100644 index 0000000000..89d5352ca4 --- /dev/null +++ b/templates/content/changelog/2026-07-16-organize-every-workspace-through-a-customizable-files-databa.md @@ -0,0 +1,6 @@ +--- +type: added +date: 2026-07-16 +--- + +Organize every workspace through a customizable Files database and sync local folders into the same database-backed experience. diff --git a/templates/content/parity/matrix.md b/templates/content/parity/matrix.md index 7469079f94..f63d81f074 100644 --- a/templates/content/parity/matrix.md +++ b/templates/content/parity/matrix.md @@ -15,7 +15,7 @@ This generated matrix tracks whether high-value Content UI operations use the sa | 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 | `export-content-source`, `import-content-source`, `remove-local-file-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` | `local-file-source-truth` | - | +| 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 | @@ -29,3 +29,4 @@ This generated matrix tracks whether high-value Content UI operations use the sa | 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`, `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`, `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.spaces-and-files-catalog | workspace | Provision and navigate Content spaces through Files and Workspaces | action-backed | `backfill-content-files`, `ensure-content-spaces`, `list-content-spaces` | `app/components/sidebar/DocumentSidebar.tsx`, `app/hooks/use-content-spaces.ts` | Personal and organization spaces, their canonical Files databases, and the personal Workspaces catalog are provisioned and reconciled in SQL. | - | - | P0 | covered | `actions/content-spaces.db.test.ts`, `actions/content-files.db.test.ts` | - | - | diff --git a/templates/content/parity/matrix.ts b/templates/content/parity/matrix.ts index 83cce3794a..6f75b39bce 100644 --- a/templates/content/parity/matrix.ts +++ b/templates/content/parity/matrix.ts @@ -35,6 +35,34 @@ export const parityMatrix: ParityRow[] = [ ], evalScenarioIds: ["document-search-edit"], }, + { + id: "workspace.spaces-and-files-catalog", + surface: "workspace", + label: "Provision and navigate Content spaces through Files and Workspaces", + uiEntrypoints: [ + "app/components/sidebar/DocumentSidebar.tsx", + "app/hooks/use-content-spaces.ts", + ], + durableEffect: + "Personal and organization spaces, their canonical Files databases, and the personal Workspaces catalog are provisioned and reconciled in SQL.", + uiImplementation: + "The app sidebar calls the shared space actions and renders the selected Files database through a saved sidebar view.", + status: "action-backed", + actions: [ + "backfill-content-files", + "ensure-content-spaces", + "list-content-spaces", + ], + exception: null, + reliabilityRisk: "none", + spinePriority: "P0", + testCoverage: "covered", + followUpPR: null, + coverageRefs: [ + "actions/content-spaces.db.test.ts", + "actions/content-files.db.test.ts", + ], + }, { id: "sidebar.navigation-and-screen-context", surface: "sidebar", @@ -537,16 +565,24 @@ export const parityMatrix: ParityRow[] = [ "UI reads/writes browser or Desktop folder handles around import/export actions.", status: "action-backed", actions: [ + "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", ], exception: null, reliabilityRisk: "none", spinePriority: "P0", testCoverage: "covered", followUpPR: null, - coverageRefs: ["actions/_local-file-documents.test.ts"], + coverageRefs: [ + "actions/_local-file-documents.test.ts", + "actions/local-folder-source.db.test.ts", + ], evalScenarioIds: ["local-file-source-truth"], }, { diff --git a/templates/content/scripts/dev-database.mjs b/templates/content/scripts/dev-database.mjs index d319266833..f18b1e776d 100644 --- a/templates/content/scripts/dev-database.mjs +++ b/templates/content/scripts/dev-database.mjs @@ -37,8 +37,8 @@ function ensureLocalEnv() { const values = parseEnv(existing); let changed = false; - if (values.get("AGENT_NATIVE_MODE") !== "database") { - values.set("AGENT_NATIVE_MODE", "database"); + if (values.get("AGENT_NATIVE_MODE") === "database") { + values.delete("AGENT_NATIVE_MODE"); changed = true; } const authSecret = values.get("BETTER_AUTH_SECRET") ?? ""; @@ -49,15 +49,17 @@ function ensureLocalEnv() { if (changed) { writeFileSync(envPath, serializeEnv(values)); - console.log("[content dev] Wrote local database-mode env to .env.local"); + console.log("[content dev] Updated local Content env in .env.local"); } return values; } const envValues = ensureLocalEnv(); +const baseEnv = { ...process.env }; +delete baseEnv.AGENT_NATIVE_MODE; +delete baseEnv.AGENT_NATIVE_DATA_MODE; const childEnv = { - ...process.env, - AGENT_NATIVE_MODE: "database", + ...baseEnv, BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET || envValues.get("BETTER_AUTH_SECRET"), }; diff --git a/templates/content/server/db/content-spaces-schema.spec.ts b/templates/content/server/db/content-spaces-schema.spec.ts new file mode 100644 index 0000000000..270be5e150 --- /dev/null +++ b/templates/content/server/db/content-spaces-schema.spec.ts @@ -0,0 +1,51 @@ +import { readFileSync } from "node:fs"; + +import { describe, expect, it } from "vitest"; + +import { + contentSpaceCatalogItems, + contentSpaces, + contentDatabases, + documents, +} from "./schema"; + +const migrationsSource = readFileSync( + new URL("../plugins/db.ts", import.meta.url), + "utf8", +); + +describe("Content space schema foundation", () => { + it("declares the normalized space and workspace catalog records", () => { + expect(contentSpaces.id.name).toBe("id"); + expect(contentSpaces.filesDatabaseId.name).toBe("files_database_id"); + expect(contentSpaces.orgId.name).toBe("org_id"); + expect(contentSpaceCatalogItems.catalogDatabaseId.name).toBe( + "catalog_database_id", + ); + expect(contentSpaceCatalogItems.spaceId.name).toBe("space_id"); + }); + + it("keeps space and system identifiers nullable on legacy records", () => { + expect(documents.spaceId.name).toBe("space_id"); + expect(contentDatabases.spaceId.name).toBe("space_id"); + expect(contentDatabases.systemRole.name).toBe("system_role"); + }); + + it("declares named additive migrations and their hot-path indexes", () => { + expect(migrationsSource).toContain('name: "content-spaces-table"'); + expect(migrationsSource).toContain( + 'name: "content-space-catalog-items-table"', + ); + expect(migrationsSource).toContain('name: "content-space-columns"'); + expect(migrationsSource).toContain( + 'name: "content-space-hot-path-indexes"', + ); + expect(migrationsSource).toContain("documents_space_idx"); + expect(migrationsSource).toContain( + "content_databases_space_system_role_unique", + ); + expect(migrationsSource).toContain( + "content_space_catalog_items_owner_catalog_idx", + ); + }); +}); diff --git a/templates/content/server/db/schema.ts b/templates/content/server/db/schema.ts index 3ff4d9e2fb..b5601fb3ca 100644 --- a/templates/content/server/db/schema.ts +++ b/templates/content/server/db/schema.ts @@ -11,6 +11,7 @@ import { export const documents = table("documents", { id: text("id").primaryKey(), + spaceId: text("space_id"), parentId: text("parent_id"), title: text("title").notNull().default("Untitled"), content: text("content").notNull().default(""), @@ -31,6 +32,58 @@ export const documents = table("documents", { ...ownableColumns(), }); +export const contentSpaces = table( + "content_spaces", + { + id: text("id").primaryKey(), + name: text("name").notNull(), + kind: text("kind").notNull(), + ownerEmail: text("owner_email").notNull(), + orgId: text("org_id"), + filesDatabaseId: text("files_database_id").notNull(), + createdBy: text("created_by").notNull(), + archivedAt: text("archived_at"), + createdAt: text("created_at").notNull().default(now()), + updatedAt: text("updated_at").notNull().default(now()), + }, + (space) => [ + uniqueIndex("content_spaces_files_database_unique").on( + space.filesDatabaseId, + ), + index("content_spaces_owner_org_idx").on(space.ownerEmail, space.orgId), + index("content_spaces_org_idx").on(space.orgId), + ], +); + +export const contentSpaceCatalogItems = table( + "content_space_catalog_items", + { + id: text("id").primaryKey(), + ownerEmail: text("owner_email").notNull(), + catalogDatabaseId: text("catalog_database_id").notNull(), + databaseItemId: text("database_item_id").notNull(), + documentId: text("document_id").notNull(), + spaceId: text("space_id").notNull(), + createdAt: text("created_at").notNull().default(now()), + updatedAt: text("updated_at").notNull().default(now()), + }, + (catalogItem) => [ + uniqueIndex("content_space_catalog_items_catalog_space_unique").on( + catalogItem.catalogDatabaseId, + catalogItem.spaceId, + ), + uniqueIndex("content_space_catalog_items_catalog_item_unique").on( + catalogItem.catalogDatabaseId, + catalogItem.databaseItemId, + ), + index("content_space_catalog_items_owner_catalog_idx").on( + catalogItem.ownerEmail, + catalogItem.catalogDatabaseId, + ), + index("content_space_catalog_items_space_idx").on(catalogItem.spaceId), + ], +); + export const documentVersions = table("document_versions", { id: text("id").primaryKey(), ownerEmail: text("owner_email").notNull().default("local@localhost"), @@ -160,47 +213,67 @@ export const documentPropertyDefinitions = table( }, ); -export const contentDatabases = table("content_databases", { - id: text("id").primaryKey(), - ownerEmail: text("owner_email").notNull().default("local@localhost"), - orgId: text("org_id"), - documentId: text("document_id").notNull(), - ownerDocumentId: text("owner_document_id"), - ownerBlockId: text("owner_block_id"), - title: text("title").notNull().default("Untitled database"), - viewConfigJson: text("view_config_json").notNull().default("{}"), - // Single source of truth for the primary "Content" Blocks field — the one - // backed by `documents.content`. A DB-enforced single-primary invariant: at - // most one property id lives here, so two concurrent seeds can never produce - // two aliasing primaries. NULL means there is currently no primary Blocks - // field (never seeded, or the primary was intentionally deleted). - primaryBlocksPropertyId: text("primary_blocks_property_id"), - // 1 once a database has been seeded with its primary Blocks field at least - // once. Distinguishes "never seeded" (legacy database needing backfill) from - // "primary intentionally deleted" (seeded once, then removed — must NOT be - // reseeded). See delete-document-property. - blocksSeeded: integer("blocks_seeded").notNull().default(0), - deletedAt: text("deleted_at"), - createdAt: text("created_at").notNull().default(now()), - updatedAt: text("updated_at").notNull().default(now()), -}); +export const contentDatabases = table( + "content_databases", + { + id: text("id").primaryKey(), + spaceId: text("space_id"), + ownerEmail: text("owner_email").notNull().default("local@localhost"), + orgId: text("org_id"), + documentId: text("document_id").notNull(), + ownerDocumentId: text("owner_document_id"), + ownerBlockId: text("owner_block_id"), + title: text("title").notNull().default("Untitled database"), + systemRole: text("system_role"), + viewConfigJson: text("view_config_json").notNull().default("{}"), + // Single source of truth for the primary "Content" Blocks field — the one + // backed by `documents.content`. A DB-enforced single-primary invariant: at + // most one property id lives here, so two concurrent seeds can never produce + // two aliasing primaries. NULL means there is currently no primary Blocks + // field (never seeded, or the primary was intentionally deleted). + primaryBlocksPropertyId: text("primary_blocks_property_id"), + // 1 once a database has been seeded with its primary Blocks field at least + // once. Distinguishes "never seeded" (legacy database needing backfill) from + // "primary intentionally deleted" (seeded once, then removed — must NOT be + // reseeded). See delete-document-property. + blocksSeeded: integer("blocks_seeded").notNull().default(0), + deletedAt: text("deleted_at"), + createdAt: text("created_at").notNull().default(now()), + updatedAt: text("updated_at").notNull().default(now()), + }, + (database) => [ + uniqueIndex("content_databases_space_system_role_unique").on( + database.spaceId, + database.systemRole, + ), + ], +); -export const contentDatabaseItems = table("content_database_items", { - id: text("id").primaryKey(), - ownerEmail: text("owner_email").notNull().default("local@localhost"), - orgId: text("org_id"), - databaseId: text("database_id").notNull(), - documentId: text("document_id").notNull(), - position: integer("position").notNull().default(0), - bodyHydrationStatus: text("body_hydration_status") - .notNull() - .default("hydrated"), - bodyHydrationAttemptedAt: text("body_hydration_attempted_at"), - bodyHydrationError: text("body_hydration_error"), - bodyHydrationVersion: text("body_hydration_version"), - createdAt: text("created_at").notNull().default(now()), - updatedAt: text("updated_at").notNull().default(now()), -}); +export const contentDatabaseItems = table( + "content_database_items", + { + id: text("id").primaryKey(), + ownerEmail: text("owner_email").notNull().default("local@localhost"), + orgId: text("org_id"), + databaseId: text("database_id").notNull(), + documentId: text("document_id").notNull(), + position: integer("position").notNull().default(0), + bodyHydrationStatus: text("body_hydration_status") + .notNull() + .default("hydrated"), + bodyHydrationAttemptedAt: text("body_hydration_attempted_at"), + bodyHydrationError: text("body_hydration_error"), + bodyHydrationVersion: text("body_hydration_version"), + createdAt: text("created_at").notNull().default(now()), + updatedAt: text("updated_at").notNull().default(now()), + }, + (item) => [ + uniqueIndex("content_database_items_database_document_unique").on( + item.databaseId, + item.documentId, + ), + ], +); export const contentDatabaseBodyHydrationQueue = table( "content_database_body_hydration_queue", diff --git a/templates/content/server/lib/document-context.ts b/templates/content/server/lib/document-context.ts index 594a41c2f6..893715999f 100644 --- a/templates/content/server/lib/document-context.ts +++ b/templates/content/server/lib/document-context.ts @@ -1,5 +1,5 @@ import { resolveAccess } from "@agent-native/core/sharing"; -import { and, eq, isNull } from "drizzle-orm"; +import { and, asc, eq, isNull, sql } from "drizzle-orm"; import { getDb, schema } from "../db/index.js"; @@ -55,9 +55,23 @@ export async function getDocumentContextPath( eq(schema.contentDatabaseItems.documentId, document.id), isNull(schema.contentDatabases.deletedAt), ), + ) + .orderBy( + sql`CASE WHEN ${schema.contentDatabases.systemRole} IS NULL THEN 0 ELSE 1 END`, + asc(schema.contentDatabases.id), + ); + const [backingDatabase] = await db + .select({ id: schema.contentDatabases.id }) + .from(schema.contentDatabases) + .where( + and( + eq(schema.contentDatabases.documentId, document.id), + isNull(schema.contentDatabases.deletedAt), + ), ); if ( membership && + !(membership.database.systemRole && backingDatabase) && !path.some((entry) => entry.id === membership.database.id) ) { const databaseDocumentAccess = await resolveAccess( diff --git a/templates/content/server/lib/notion-sync.ts b/templates/content/server/lib/notion-sync.ts index 13c5d88292..cb5d18d934 100644 --- a/templates/content/server/lib/notion-sync.ts +++ b/templates/content/server/lib/notion-sync.ts @@ -5,6 +5,7 @@ import crypto from "node:crypto"; import { and, eq, inArray, isNull, lt, or } from "drizzle-orm"; import type { InferSelectModel } from "drizzle-orm"; +import { ensureDocumentFilesMembership } from "../../actions/_content-files.js"; import type { DocumentSyncStatus } from "../../shared/api.js"; import { canonicalizeNfm, nfmToDoc, type PMNode } from "../../shared/nfm.js"; import { getDb, schema } from "../db/index.js"; @@ -492,11 +493,15 @@ async function createLinkedChildDocument(args: { const db = getDb(); const now = nowIso(); const id = nanoid(); + if (!args.parent.spaceId) { + throw new Error("Parent document does not belong to a Content space."); + } let inserted = false; try { await db.insert(schema.documents).values({ id, + spaceId: args.parent.spaceId, ownerEmail: args.parent.ownerEmail, orgId: args.parent.orgId, parentId: args.parent.id, @@ -511,6 +516,7 @@ async function createLinkedChildDocument(args: { updatedAt: now, }); inserted = true; + await ensureDocumentFilesMembership(db, id, now); await inheritShares(args.parent.id, id, now); await upsertSyncLink({ owner: args.owner, diff --git a/templates/content/server/plugins/db.ts b/templates/content/server/plugins/db.ts index 5774fa3e3b..372ee2308e 100644 --- a/templates/content/server/plugins/db.ts +++ b/templates/content/server/plugins/db.ts @@ -694,6 +694,159 @@ const runContentMigrations = runMigrations( owner_email )`, }, + { + version: 70, + name: "content-spaces-table", + sql: `CREATE TABLE IF NOT EXISTS content_spaces ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + kind TEXT NOT NULL, + owner_email TEXT NOT NULL, + org_id TEXT, + files_database_id TEXT NOT NULL, + created_by TEXT NOT NULL, + archived_at TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + )`, + }, + { + version: 71, + name: "content-space-catalog-items-table", + sql: `CREATE TABLE IF NOT EXISTS content_space_catalog_items ( + id TEXT PRIMARY KEY, + owner_email TEXT NOT NULL, + catalog_database_id TEXT NOT NULL, + database_item_id TEXT NOT NULL, + document_id TEXT NOT NULL, + space_id TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + )`, + }, + { + version: 72, + name: "content-space-columns", + sql: `ALTER TABLE documents ADD COLUMN IF NOT EXISTS space_id TEXT; + ALTER TABLE content_databases ADD COLUMN IF NOT EXISTS space_id TEXT; + ALTER TABLE content_databases ADD COLUMN IF NOT EXISTS system_role TEXT`, + }, + { + version: 73, + name: "content-space-hot-path-indexes", + sql: `CREATE UNIQUE INDEX IF NOT EXISTS content_spaces_files_database_unique ON content_spaces (files_database_id); + CREATE INDEX IF NOT EXISTS content_spaces_owner_org_idx ON content_spaces (owner_email, org_id); + CREATE INDEX IF NOT EXISTS content_spaces_org_idx ON content_spaces (org_id); + CREATE UNIQUE INDEX IF NOT EXISTS content_space_catalog_items_catalog_space_unique ON content_space_catalog_items (catalog_database_id, space_id); + CREATE UNIQUE INDEX IF NOT EXISTS content_space_catalog_items_catalog_item_unique ON content_space_catalog_items (catalog_database_id, database_item_id); + CREATE INDEX IF NOT EXISTS content_space_catalog_items_owner_catalog_idx ON content_space_catalog_items (owner_email, catalog_database_id); + CREATE INDEX IF NOT EXISTS content_space_catalog_items_space_idx ON content_space_catalog_items (space_id); + CREATE INDEX IF NOT EXISTS documents_space_idx ON documents (space_id); + CREATE INDEX IF NOT EXISTS content_databases_space_idx ON content_databases (space_id); + CREATE UNIQUE INDEX IF NOT EXISTS content_databases_space_system_role_unique ON content_databases (space_id, system_role)`, + }, + { + version: 74, + name: "content-database-items-canonical-membership", + sql: `DROP INDEX IF EXISTS content_space_catalog_items_catalog_item_unique; + DROP INDEX IF EXISTS content_database_body_hydration_queue_item_idx; + UPDATE content_space_catalog_items + SET database_item_id = ( + SELECT MIN(canonical.id) + FROM content_database_items original + INNER JOIN content_database_items canonical + ON canonical.database_id = original.database_id + AND canonical.document_id = original.document_id + WHERE original.id = content_space_catalog_items.database_item_id + ) + WHERE EXISTS ( + SELECT 1 + FROM content_database_items original + INNER JOIN content_database_items canonical + ON canonical.database_id = original.database_id + AND canonical.document_id = original.document_id + WHERE original.id = content_space_catalog_items.database_item_id + AND canonical.id < original.id + ); + UPDATE content_database_body_hydration_queue + SET database_item_id = ( + SELECT MIN(canonical.id) + FROM content_database_items original + INNER JOIN content_database_items canonical + ON canonical.database_id = original.database_id + AND canonical.document_id = original.document_id + WHERE original.id = content_database_body_hydration_queue.database_item_id + ) + WHERE EXISTS ( + SELECT 1 + FROM content_database_items original + INNER JOIN content_database_items canonical + ON canonical.database_id = original.database_id + AND canonical.document_id = original.document_id + WHERE original.id = content_database_body_hydration_queue.database_item_id + AND canonical.id < original.id + ); + UPDATE content_database_source_rows + SET database_item_id = ( + SELECT MIN(canonical.id) + FROM content_database_items original + INNER JOIN content_database_items canonical + ON canonical.database_id = original.database_id + AND canonical.document_id = original.document_id + WHERE original.id = content_database_source_rows.database_item_id + ) + WHERE EXISTS ( + SELECT 1 + FROM content_database_items original + INNER JOIN content_database_items canonical + ON canonical.database_id = original.database_id + AND canonical.document_id = original.document_id + WHERE original.id = content_database_source_rows.database_item_id + AND canonical.id < original.id + ); + UPDATE content_database_source_change_sets + SET database_item_id = ( + SELECT MIN(canonical.id) + FROM content_database_items original + INNER JOIN content_database_items canonical + ON canonical.database_id = original.database_id + AND canonical.document_id = original.document_id + WHERE original.id = content_database_source_change_sets.database_item_id + ) + WHERE EXISTS ( + SELECT 1 + FROM content_database_items original + INNER JOIN content_database_items canonical + ON canonical.database_id = original.database_id + AND canonical.document_id = original.document_id + WHERE original.id = content_database_source_change_sets.database_item_id + AND canonical.id < original.id + ); + DELETE FROM content_space_catalog_items + WHERE id NOT IN ( + SELECT MIN(id) + FROM content_space_catalog_items + GROUP BY catalog_database_id, database_item_id + ); + DELETE FROM content_database_body_hydration_queue + WHERE id NOT IN ( + SELECT MIN(id) + FROM content_database_body_hydration_queue + GROUP BY database_item_id + ); + CREATE UNIQUE INDEX IF NOT EXISTS content_space_catalog_items_catalog_item_unique + ON content_space_catalog_items (catalog_database_id, database_item_id); + CREATE UNIQUE INDEX IF NOT EXISTS content_database_body_hydration_queue_item_idx + ON content_database_body_hydration_queue (database_item_id); + DELETE FROM content_database_items + WHERE id NOT IN ( + SELECT MIN(id) + FROM content_database_items + GROUP BY database_id, document_id + ); + CREATE UNIQUE INDEX IF NOT EXISTS content_database_items_database_document_unique + ON content_database_items (database_id, document_id)`, + }, ], { table: "content_migrations" }, ); diff --git a/templates/content/shared/api.ts b/templates/content/shared/api.ts index bd73914326..8f5ece9d7b 100644 --- a/templates/content/shared/api.ts +++ b/templates/content/shared/api.ts @@ -96,6 +96,7 @@ export interface ResolveDocumentSyncConflictRequest { export interface DocumentCreateRequest { id?: string; + spaceId?: string; title?: string; parentId?: string | null; content?: string; @@ -289,7 +290,8 @@ export type ContentDatabaseViewType = | "gallery" | "calendar" | "timeline" - | "form"; + | "form" + | "sidebar"; export type ContentDatabaseRowDensity = "compact" | "default" | "comfortable"; export type ContentDatabaseFilterMode = "and" | "or"; @@ -414,7 +416,12 @@ export type ContentDatabaseSourceType = | "mock-local" | "builder-cms" | "local-table" - | "notion-database"; + | "notion-database" + | "local-folder"; +export type ContentDatabaseSourceTruthPolicy = + | "database_primary" + | "source_primary" + | "reviewed_bidirectional"; export type ContentDatabaseSourceSyncState = | "idle" | "linked" @@ -433,7 +440,7 @@ export type ContentDatabaseSourceWriteMode = | "publish_updates"; export type BuilderCmsPublicationTransitionIntent = "publish" | "unpublish"; export const BUILDER_CMS_SAFE_WRITE_MODEL = "agent-native-blog-article-test"; -export type ContentDatabaseSourceChangeDirection = "outbound"; +export type ContentDatabaseSourceChangeDirection = "incoming" | "outbound"; export type ContentDatabaseSourceChangeState = | "proposed" | "pending_push" @@ -471,6 +478,9 @@ export interface ContentDatabaseSourceCapabilities { canStageLocalRevision: boolean; liveWritesEnabled: boolean; readOnlyRefresh: boolean; + canRename?: boolean; + canReveal?: boolean; + canUseLocalComponents?: boolean; } export interface ContentDatabaseSourceFieldMapping { @@ -640,6 +650,9 @@ export interface ContentDatabaseSource { allowPublicationTransitions?: boolean; notes?: string | null; readMode?: "fixture" | "builder-api" | string | null; + connectionId?: string | null; + connectionLabel?: string | null; + truthPolicy?: ContentDatabaseSourceTruthPolicy; liveReadConfigured?: boolean; lastReadEntryCount?: number; lastReadMatchedRowCount?: number; @@ -764,6 +777,7 @@ export interface ContentDatabaseSourceFieldPropertyResponse { export interface CreateDatabaseRequest { documentId?: string; + spaceId?: string; parentId?: string | null; title?: string; description?: string; From d4025e2662d549f42199ede57bd6cb9991cae126 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:07:07 -0400 Subject: [PATCH 02/76] fix(content): harden workspace sync boundaries --- templates/content/actions/_content-files.ts | 24 +- .../content/actions/_database-source-utils.ts | 16 +- .../content/actions/content-files.db.test.ts | 38 +++ .../actions/create-content-database.ts | 4 +- templates/content/actions/create-document.ts | 2 +- .../actions/disconnect-local-folder-source.ts | 65 +++- .../actions/import-content-source.db.test.ts | 40 +++ .../content/actions/import-content-source.ts | 2 + .../actions/local-folder-source.db.test.ts | 202 ++++++++++++ .../resync-content-database-source.db.test.ts | 80 ++++- .../actions/space-aware-writers.db.test.ts | 16 +- .../actions/sync-local-folder-source.ts | 304 ++++++++++-------- 12 files changed, 639 insertions(+), 154 deletions(-) diff --git a/templates/content/actions/_content-files.ts b/templates/content/actions/_content-files.ts index 601ac87534..13fe173729 100644 --- a/templates/content/actions/_content-files.ts +++ b/templates/content/actions/_content-files.ts @@ -7,6 +7,7 @@ import { schema } from "../server/db/index.js"; import { listContentOrganizationMemberships, normalizeContentSpaceEmail, + resolveContentSpaceAccess, } from "./_content-space-access.js"; import { organizationContentSpaceId, @@ -232,12 +233,23 @@ export async function reconcileContentFilesMemberships( const email = normalizeContentSpaceEmail(userEmail); const memberships = await listContentOrganizationMemberships(email); const personalSpaceId = personalContentSpaceId(email); - const orgSpaceIds = new Map( - memberships.map((membership) => [ - membership.orgId, - organizationContentSpaceId(membership.orgId), - ]), - ); + const orgSpaceIds = new Map(); + for (const membership of memberships) { + const spaceId = organizationContentSpaceId(membership.orgId); + try { + await resolveContentSpaceAccess(spaceId, "editor"); + orgSpaceIds.set(membership.orgId, spaceId); + } catch (error) { + if ( + error instanceof Error && + error.message === + `Editor access is required for Content space "${spaceId}"` + ) { + continue; + } + throw error; + } + } const accessibleSpaceIds = [personalSpaceId, ...orgSpaceIds.values()]; const now = new Date().toISOString(); const result: ContentFilesReconciliation = { diff --git a/templates/content/actions/_database-source-utils.ts b/templates/content/actions/_database-source-utils.ts index cd8c8ecb97..83cb9fb69f 100644 --- a/templates/content/actions/_database-source-utils.ts +++ b/templates/content/actions/_database-source-utils.ts @@ -82,7 +82,10 @@ import { } from "./_builder-cms-source-adapter.js"; import { mergeBuilderCmsWriteSettingsIntoJson } from "./_builder-cms-write-settings.js"; import { ensureDocumentsFilesMembership } from "./_content-files.js"; -import { provisionContentSpaces } from "./_content-spaces.js"; +import { + organizationContentSpaceId, + provisionContentSpaces, +} from "./_content-spaces.js"; import { databaseItemsPositionScope, documentsPositionScope, @@ -5144,9 +5147,14 @@ export async function importBuilderCmsEntriesAsDatabaseItems(args: { ); } if (!resolvedDatabaseSpaceId) { - resolvedDatabaseSpaceId = ( - await provisionContentSpaces(db, args.database.ownerEmail) - ).personalSpaceId; + const provisioned = await provisionContentSpaces( + db, + args.database.ownerEmail, + ); + const legacyOrgId = args.database.orgId ?? databaseDocument.orgId; + resolvedDatabaseSpaceId = legacyOrgId + ? organizationContentSpaceId(legacyOrgId) + : provisioned.personalSpaceId; } const databaseSpaceId = resolvedDatabaseSpaceId; if ( diff --git a/templates/content/actions/content-files.db.test.ts b/templates/content/actions/content-files.db.test.ts index de4cd0840a..929066bcbb 100644 --- a/templates/content/actions/content-files.db.test.ts +++ b/templates/content/actions/content-files.db.test.ts @@ -13,6 +13,7 @@ const TEST_DB_PATH = join( ); const OWNER = "files-owner@example.com"; const ORG_ID = "files-org"; +const VIEWER = "files-viewer@example.com"; type Schema = typeof import("../server/db/schema.js"); let getDb: () => any; @@ -99,6 +100,43 @@ async function getFilesDatabase(spaceId: string) { } describe("Content Files membership reconciliation", () => { + it("does not let organization viewers backfill legacy organization pages", async () => { + const viewerOrgId = "files-viewer-org"; + await getDbExec().execute({ + sql: "INSERT INTO organizations (id, name, created_by, created_at) VALUES (?, ?, ?, ?)", + args: [viewerOrgId, "Viewer Org", OWNER, Date.now()], + }); + await getDbExec().execute({ + sql: "INSERT INTO org_members (id, org_id, email, role, joined_at) VALUES (?, ?, ?, ?, ?)", + args: ["viewer-org-owner", viewerOrgId, OWNER, "owner", Date.now()], + }); + await getDbExec().execute({ + sql: "INSERT INTO org_members (id, org_id, email, role, joined_at) VALUES (?, ?, ?, ?, ?)", + args: ["viewer-org-viewer", viewerOrgId, VIEWER, "member", Date.now()], + }); + await runWithRequestContext({ userEmail: OWNER }, () => + provisionContentSpaces(getDb(), OWNER), + ); + await createLegacyDocument({ + id: "viewer-legacy-org", + orgId: viewerOrgId, + title: "Viewer cannot reconcile", + }); + + await runWithRequestContext({ userEmail: VIEWER }, () => + reconcileContentFilesMemberships(getDb(), VIEWER), + ); + + const [legacyDocument] = await getDb() + .select({ spaceId: schema.documents.spaceId }) + .from(schema.documents) + .where(eq(schema.documents.id, "viewer-legacy-org")); + expect(legacyDocument?.spaceId).toBeNull(); + await getDb() + .delete(schema.documents) + .where(eq(schema.documents.id, "viewer-legacy-org")); + }); + it("assigns personal and organization legacy pages to their canonical Files databases", async () => { await createLegacyDocument({ id: "legacy-personal", diff --git a/templates/content/actions/create-content-database.ts b/templates/content/actions/create-content-database.ts index 50761ebd6f..214354cd2b 100644 --- a/templates/content/actions/create-content-database.ts +++ b/templates/content/actions/create-content-database.ts @@ -154,7 +154,7 @@ export async function resolveContentDatabaseSpace( if (!userEmail) throw new Error("no authenticated user"); const provisioned = await provisionContentSpaces(db, userEmail); const spaceId = args.spaceId ?? provisioned.personalSpaceId; - await resolveContentSpaceAccess(spaceId); + await resolveContentSpaceAccess(spaceId, "editor"); return spaceId; } @@ -254,7 +254,7 @@ export async function createContentDatabaseRecord( "A top-level database requires a resolved Content space", ); } - const spaceAccess = await resolveContentSpaceAccess(spaceId); + const spaceAccess = await resolveContentSpaceAccess(spaceId, "editor"); ownerEmail = getRequestUserEmail() ?? ownerEmail; orgId = spaceAccess.space.orgId; visibility = orgId ? "org" : "private"; diff --git a/templates/content/actions/create-document.ts b/templates/content/actions/create-document.ts index 4747376629..1abab1c7ff 100644 --- a/templates/content/actions/create-document.ts +++ b/templates/content/actions/create-document.ts @@ -127,7 +127,7 @@ export default defineAction({ } else { const provisioned = await provisionContentSpaces(db, currentUserEmail); spaceId = args.spaceId ?? provisioned.personalSpaceId; - const spaceAccess = await resolveContentSpaceAccess(spaceId); + const spaceAccess = await resolveContentSpaceAccess(spaceId, "editor"); ownerEmail = currentUserEmail; orgId = spaceAccess.space.orgId; visibility = orgId ? "org" : "private"; diff --git a/templates/content/actions/disconnect-local-folder-source.ts b/templates/content/actions/disconnect-local-folder-source.ts index 830a5320b5..497cb2b5e1 100644 --- a/templates/content/actions/disconnect-local-folder-source.ts +++ b/templates/content/actions/disconnect-local-folder-source.ts @@ -1,6 +1,6 @@ import { defineAction } from "@agent-native/core"; import { writeAppState } from "@agent-native/core/application-state"; -import { and, eq, inArray } from "drizzle-orm"; +import { and, eq, inArray, ne } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; @@ -41,6 +41,36 @@ export default defineAction({ .from(schema.contentDatabaseSourceRows) .where(eq(schema.contentDatabaseSourceRows.sourceId, sourceId)); const documentIds = [...new Set(rows.map((row) => row.documentId))]; + const remainingLocalRows = documentIds.length + ? await db + .select({ + documentId: schema.contentDatabaseSourceRows.documentId, + sourceDisplayKey: schema.contentDatabaseSourceRows.sourceDisplayKey, + sourceValuesJson: schema.contentDatabaseSourceRows.sourceValuesJson, + sourceName: schema.contentDatabaseSources.sourceName, + }) + .from(schema.contentDatabaseSourceRows) + .innerJoin( + schema.contentDatabaseSources, + eq( + schema.contentDatabaseSources.id, + schema.contentDatabaseSourceRows.sourceId, + ), + ) + .where( + and( + inArray(schema.contentDatabaseSourceRows.documentId, documentIds), + ne(schema.contentDatabaseSourceRows.sourceId, sourceId), + eq( + schema.contentDatabaseSources.sourceType, + LOCAL_FOLDER_SOURCE_TYPE, + ), + ), + ) + : []; + const remainingLocalRowByDocument = new Map( + remainingLocalRows.map((row) => [row.documentId, row]), + ); const now = new Date().toISOString(); await db.transaction(async (tx: any) => { @@ -72,7 +102,10 @@ export default defineAction({ await tx .delete(schema.contentDatabaseSources) .where(eq(schema.contentDatabaseSources.id, sourceId)); - if (documentIds.length) { + const documentsToClear = documentIds.filter( + (documentId) => !remainingLocalRowByDocument.has(documentId), + ); + if (documentsToClear.length) { await tx .update(schema.documents) .set({ @@ -86,7 +119,33 @@ export default defineAction({ }) .where( and( - inArray(schema.documents.id, documentIds), + inArray(schema.documents.id, documentsToClear), + eq(schema.documents.spaceId, spaceId), + ), + ); + } + for (const [documentId, remaining] of remainingLocalRowByDocument) { + const sourceValues = JSON.parse(remaining.sourceValuesJson || "{}") as { + relativePath?: unknown; + }; + const relativePath = + typeof sourceValues.relativePath === "string" + ? sourceValues.relativePath + : remaining.sourceDisplayKey; + await tx + .update(schema.documents) + .set({ + sourceMode: "local-files", + sourceKind: "file", + sourcePath: relativePath, + sourceAbsolutePath: null, + sourceRootPath: remaining.sourceName, + sourceUpdatedAt: now, + updatedAt: now, + }) + .where( + and( + eq(schema.documents.id, documentId), eq(schema.documents.spaceId, spaceId), ), ); diff --git a/templates/content/actions/import-content-source.db.test.ts b/templates/content/actions/import-content-source.db.test.ts index 080313f157..20cccf092a 100644 --- a/templates/content/actions/import-content-source.db.test.ts +++ b/templates/content/actions/import-content-source.db.test.ts @@ -20,6 +20,8 @@ let schema: Schema; let importContentSourceAction: typeof import("./import-content-source.js").default; const OWNER = "owner@example.com"; +const VIEWER = "import-viewer@example.com"; +const ORG_ID = "import-viewer-org"; beforeAll(async () => { process.env.DATABASE_URL = `file:${TEST_DB_PATH}`; @@ -60,6 +62,44 @@ function sourceWithDescription(description: string) { } describe("import-content-source descriptions", () => { + it("requires editor access before importing into an organization space", async () => { + await getDbExec().execute({ + sql: "INSERT INTO organizations (id, name, created_by, created_at) VALUES (?, ?, ?, ?)", + args: [ORG_ID, "Import viewer org", OWNER, Date.now()], + }); + await getDbExec().execute({ + sql: "INSERT INTO org_members (id, org_id, email, role, joined_at) VALUES (?, ?, ?, ?, ?)", + args: ["import-viewer-membership", ORG_ID, VIEWER, "member", Date.now()], + }); + + await expect( + runWithRequestContext({ userEmail: VIEWER, orgId: ORG_ID }, () => + importContentSourceAction.run({ + files: { + "content/viewer-import.mdx": serializeContentSourceDocument({ + id: "viewer_import_document", + parentId: null, + title: "Viewer import", + content: "Should not be written", + icon: null, + position: 0, + isFavorite: false, + hideFromSearch: false, + visibility: "org", + }), + }, + dryRun: false, + }), + ), + ).rejects.toThrow("Editor access is required"); + await expect( + getDb() + .select({ id: schema.documents.id }) + .from(schema.documents) + .where(eq(schema.documents.id, "viewer_import_document")), + ).resolves.toEqual([]); + }); + it("persists exported descriptions when creating and updating documents", async () => { const path = "content/description-round-trip--doc_description_roundtrip.mdx"; diff --git a/templates/content/actions/import-content-source.ts b/templates/content/actions/import-content-source.ts index 6c00807ff5..ffd53ad42a 100644 --- a/templates/content/actions/import-content-source.ts +++ b/templates/content/actions/import-content-source.ts @@ -16,6 +16,7 @@ import { type ParsedContentSourceFile, } from "../shared/content-source.js"; import { ensureDocumentsFilesMembership } from "./_content-files.js"; +import { resolveContentSpaceAccess } from "./_content-space-access.js"; import { organizationContentSpaceId, personalContentSpaceId, @@ -203,6 +204,7 @@ export default defineAction({ "The active organization does not have a writable Content space.", ); } + await resolveContentSpaceAccess(defaultSpaceId, "editor"); } const parsed: ParsedContentSourceFile[] = entries.map( ([filePath, source]) => parseContentSourceFile(filePath, source), diff --git a/templates/content/actions/local-folder-source.db.test.ts b/templates/content/actions/local-folder-source.db.test.ts index d44c4cb2e7..dffe4fe1f9 100644 --- a/templates/content/actions/local-folder-source.db.test.ts +++ b/templates/content/actions/local-folder-source.db.test.ts @@ -219,6 +219,144 @@ describe("local-folder Content source", () => { expect(resolvedChangeSet.state).toBe("applied"); }); + it("records concurrent metadata-only edits as a conflict", async () => { + const connection = await runWithRequestContext({ userEmail: OWNER }, () => + connectLocalFolder.run({ + connectionId: "desktop-folder-metadata-conflict", + label: "Metadata conflict docs", + createSourceBackedSpace: true, + truthPolicy: "source_primary", + }), + ); + const baseline = `---\ntitle: Baseline title\ndescription: Baseline description\n---\nBody.`; + const first = await runWithRequestContext({ userEmail: OWNER }, () => + syncLocalFolder.run({ + sourceId: connection.sourceId, + files: { "metadata.md": baseline }, + }), + ); + const documentId = first.created[0]!.id; + await getDb() + .update(schema.documents) + .set({ + title: "Content title", + description: "Content description", + updatedAt: new Date().toISOString(), + }) + .where(eq(schema.documents.id, documentId)); + + const result = await runWithRequestContext({ userEmail: OWNER }, () => + syncLocalFolder.run({ + sourceId: connection.sourceId, + files: { + "metadata.md": `---\ntitle: Folder title\ndescription: Folder description\n---\nBody.`, + }, + }), + ); + + expect(result.conflicts).toEqual([ + expect.objectContaining({ id: documentId, path: "metadata.md" }), + ]); + await expect( + getDb() + .select() + .from(schema.documents) + .where(eq(schema.documents.id, documentId)), + ).resolves.toEqual([ + expect.objectContaining({ + title: "Content title", + description: "Content description", + content: "Body.", + }), + ]); + const [changeSet] = await getDb() + .select() + .from(schema.contentDatabaseSourceChangeSets) + .where( + and( + eq( + schema.contentDatabaseSourceChangeSets.sourceId, + connection.sourceId, + ), + eq(schema.contentDatabaseSourceChangeSets.documentId, documentId), + ), + ); + expect(changeSet.kind).toBe("metadata_update"); + expect(JSON.parse(changeSet.fieldChangesJson)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + field: "title", + currentValue: "Content title", + proposedValue: "Folder title", + }), + expect.objectContaining({ + field: "description", + currentValue: "Content description", + proposedValue: "Folder description", + }), + ]), + ); + }); + + it("replans inside the write transaction so a just-committed edit is not overwritten", async () => { + const connection = await runWithRequestContext({ userEmail: OWNER }, () => + connectLocalFolder.run({ + connectionId: "desktop-folder-transaction-race", + label: "Transaction race docs", + createSourceBackedSpace: true, + truthPolicy: "source_primary", + }), + ); + const first = await runWithRequestContext({ userEmail: OWNER }, () => + syncLocalFolder.run({ + sourceId: connection.sourceId, + files: { "race.md": "Baseline body." }, + }), + ); + const documentId = first.created[0]!.id; + const db = getDb(); + const originalTransaction = db.transaction; + let injectedEdit = false; + db.transaction = async function (...args: any[]) { + if (!injectedEdit) { + injectedEdit = true; + await db + .update(schema.documents) + .set({ + content: "Content edit committed after planning.", + updatedAt: new Date().toISOString(), + }) + .where(eq(schema.documents.id, documentId)); + } + return originalTransaction.apply(this, args); + }; + + try { + const result = await runWithRequestContext({ userEmail: OWNER }, () => + syncLocalFolder.run({ + sourceId: connection.sourceId, + files: { "race.md": "Incoming folder edit." }, + }), + ); + expect(injectedEdit).toBe(true); + expect(result.conflicts).toEqual([ + expect.objectContaining({ id: documentId, path: "race.md" }), + ]); + await expect( + db + .select() + .from(schema.documents) + .where(eq(schema.documents.id, documentId)), + ).resolves.toEqual([ + expect.objectContaining({ + content: "Content edit committed after planning.", + }), + ]); + } finally { + db.transaction = originalTransaction; + } + }); + it("tracks stable-id renames and reviews source deletions without deleting the global page", async () => { const connection = await runWithRequestContext({ userEmail: OWNER }, () => connectLocalFolder.run({ @@ -464,4 +602,68 @@ describe("local-folder Content source", () => { sourcePath: null, }); }); + + it("preserves document source metadata when another folder source remains", async () => { + const first = await runWithRequestContext({ userEmail: OWNER }, () => + connectLocalFolder.run({ + connectionId: "desktop-folder-shared-a", + label: "Shared folder A", + createSourceBackedSpace: true, + truthPolicy: "source_primary", + }), + ); + const second = await runWithRequestContext({ userEmail: OWNER }, () => + connectLocalFolder.run({ + connectionId: "desktop-folder-shared-b", + label: "Shared folder B", + spaceId: first.spaceId, + truthPolicy: "source_primary", + }), + ); + const sharedDocument = `---\nid: shared-folder-page\n---\n# Shared page\n\nBody.`; + await runWithRequestContext({ userEmail: OWNER }, () => + syncLocalFolder.run({ + sourceId: first.sourceId, + files: { "from-a.md": sharedDocument }, + }), + ); + await runWithRequestContext({ userEmail: OWNER }, () => + syncLocalFolder.run({ + sourceId: second.sourceId, + files: { "from-b.md": sharedDocument }, + }), + ); + + await runWithRequestContext({ userEmail: OWNER }, () => + disconnectLocalFolder.run({ sourceId: first.sourceId }), + ); + + await expect( + getDb() + .select() + .from(schema.contentDatabaseSourceRows) + .where( + and( + eq(schema.contentDatabaseSourceRows.sourceId, second.sourceId), + eq( + schema.contentDatabaseSourceRows.documentId, + "shared-folder-page", + ), + ), + ), + ).resolves.toHaveLength(1); + await expect( + getDb() + .select() + .from(schema.documents) + .where(eq(schema.documents.id, "shared-folder-page")), + ).resolves.toEqual([ + expect.objectContaining({ + sourceMode: "local-files", + sourceKind: "file", + sourcePath: "from-b.md", + sourceRootPath: "Shared folder B", + }), + ]); + }); }); diff --git a/templates/content/actions/resync-content-database-source.db.test.ts b/templates/content/actions/resync-content-database-source.db.test.ts index a8d6141fcd..414edf893a 100644 --- a/templates/content/actions/resync-content-database-source.db.test.ts +++ b/templates/content/actions/resync-content-database-source.db.test.ts @@ -10,7 +10,8 @@ import { join } from "node:path"; // guard:allow-unscoped — isolated SQLite fixtures intentionally inspect rows directly. import { getDbExec } from "@agent-native/core/db"; -import { and, eq, ne } from "drizzle-orm"; +import { runWithRequestContext } from "@agent-native/core/server"; +import { and, eq, ne, or } from "drizzle-orm"; import { afterAll, afterEach, @@ -1573,6 +1574,83 @@ it("records freshly imported Builder row identities even when title and URL keys expect(concurrentSourceRows).toHaveLength(2); }); +it("repairs a legacy organization database into its organization space", async () => { + builderReadMock.mode = "full"; + builderReadMock.calls = []; + const db = getDb(); + const now = new Date().toISOString(); + const orgId = "legacy-builder-org"; + const databaseId = "legacy_org_builder_database"; + const databaseDocumentId = "legacy_org_builder_document"; + await getDbExec().execute({ + sql: "INSERT INTO organizations (id, name, created_by, created_at) VALUES (?, ?, ?, ?)", + args: [orgId, "Legacy Builder Org", OWNER, Date.now()], + }); + await getDbExec().execute({ + sql: "INSERT INTO org_members (id, org_id, email, role, joined_at) VALUES (?, ?, ?, ?, ?)", + args: ["legacy-builder-org-owner", orgId, OWNER, "owner", Date.now()], + }); + await db.insert(schema.documents).values({ + id: databaseDocumentId, + spaceId: null, + ownerEmail: OWNER, + orgId, + title: "Legacy org Builder database", + visibility: "org", + createdAt: now, + updatedAt: now, + }); + await db.insert(schema.contentDatabases).values({ + id: databaseId, + spaceId: null, + ownerEmail: OWNER, + orgId, + documentId: databaseDocumentId, + title: "Legacy org Builder database", + createdAt: now, + updatedAt: now, + }); + const [database] = await db + .select() + .from(schema.contentDatabases) + .where(eq(schema.contentDatabases.id, databaseId)); + const read = await ( + await import("./_builder-cms-read-client.js") + ).readBuilderCmsContentEntries({ model: "collection-duplicates" }); + const entries = read.state === "live" ? read.entries.slice(0, 1) : []; + + await runWithRequestContext({ userEmail: OWNER, orgId }, () => + importBuilderEntries({ + database, + entries, + now, + sourceTable: "collection-duplicates", + existingSourceRows: [], + }), + ); + + const { organizationContentSpaceId } = await import("./_content-spaces.js"); + const expectedSpaceId = organizationContentSpaceId(orgId); + const [repairedDatabase] = await db + .select({ spaceId: schema.contentDatabases.spaceId }) + .from(schema.contentDatabases) + .where(eq(schema.contentDatabases.id, databaseId)); + const repairedDocuments = await db + .select({ id: schema.documents.id, spaceId: schema.documents.spaceId }) + .from(schema.documents) + .where( + or( + eq(schema.documents.id, databaseDocumentId), + eq(schema.documents.parentId, databaseDocumentId), + ), + ); + expect(repairedDatabase?.spaceId).toBe(expectedSpaceId); + expect(repairedDocuments).toHaveLength(2); + expect( + repairedDocuments.every((row) => row.spaceId === expectedSpaceId), + ).toBe(true); +}); + it("resync advances Builder partial reads with a cursor and converges on the final page", async () => { builderReadMock.mode = "paged"; builderReadMock.calls = []; diff --git a/templates/content/actions/space-aware-writers.db.test.ts b/templates/content/actions/space-aware-writers.db.test.ts index ff34a6aba5..697a58a930 100644 --- a/templates/content/actions/space-aware-writers.db.test.ts +++ b/templates/content/actions/space-aware-writers.db.test.ts @@ -28,6 +28,7 @@ let organizationContentSpaceId: typeof import("./_content-spaces.js").organizati const OWNER = "owner@example.com"; const MEMBER = "member@example.com"; +const VIEWER = "viewer@example.com"; const OUTSIDER = "outsider@example.com"; beforeAll(async () => { @@ -127,9 +128,10 @@ describe("space-aware document writers", () => { ).rejects.toThrow("parent Content space"); }); - it("lets an ordinary org member create an owned org-visible root page but denies outsiders", async () => { + it("requires organization editor access for root page and database creation", async () => { const orgId = "org-shared-writers"; - await addOrganizationMember({ orgId, email: MEMBER }); + await addOrganizationMember({ orgId, email: MEMBER, role: "admin" }); + await addOrganizationMember({ orgId, email: VIEWER }); const spaceId = organizationContentSpaceId(orgId); const created = await runWithRequestContext( { userEmail: MEMBER, orgId }, @@ -165,6 +167,16 @@ describe("space-aware document writers", () => { filesMemberships(createdDatabase.database.documentId), ).resolves.toHaveLength(1); + await expect( + runWithRequestContext({ userEmail: VIEWER, orgId }, () => + createDocument.run({ title: "Viewer page", spaceId }), + ), + ).rejects.toThrow("Editor access is required"); + await expect( + runWithRequestContext({ userEmail: VIEWER, orgId }, () => + createContentDatabase.run({ title: "Viewer database", spaceId }), + ), + ).rejects.toThrow("Editor access is required"); await expect( runWithRequestContext({ userEmail: OUTSIDER }, () => createDocument.run({ title: "No entry", spaceId }), diff --git a/templates/content/actions/sync-local-folder-source.ts b/templates/content/actions/sync-local-folder-source.ts index 277f1144d8..109e771774 100644 --- a/templates/content/actions/sync-local-folder-source.ts +++ b/templates/content/actions/sync-local-folder-source.ts @@ -191,34 +191,45 @@ export default defineAction({ ); } - const storedRows = await db - .select() - .from(schema.contentDatabaseSourceRows) - .where(eq(schema.contentDatabaseSourceRows.sourceId, sourceId)); - const rowByPath = new Map( - storedRows.map((row) => [ - String(parseJson(row.sourceValuesJson).relativePath ?? ""), - row, - ]), - ); - const rowByDocumentId = new Map( - storedRows.map((row) => [row.documentId, row]), - ); const explicitIds = valid.flatMap((file) => (file.id ? [file.id] : [])); - const linkedIds = storedRows.map((row) => row.documentId); - const candidateIds = [...new Set([...explicitIds, ...linkedIds])]; - const existingDocuments = candidateIds.length - ? await db - .select() - .from(schema.documents) - .where(inArray(schema.documents.id, candidateIds)) - : []; - const documentById = new Map( - existingDocuments.map((document) => [document.id, document]), - ); + type SourceRow = typeof schema.contentDatabaseSourceRows.$inferSelect; + type Document = typeof schema.documents.$inferSelect; + const loadSnapshot = async (queryDb: any) => { + const storedRows = (await queryDb + .select() + .from(schema.contentDatabaseSourceRows) + .where( + eq(schema.contentDatabaseSourceRows.sourceId, sourceId), + )) as SourceRow[]; + const rowByPath = new Map( + storedRows.map((row) => [ + String(parseJson(row.sourceValuesJson).relativePath ?? ""), + row, + ]), + ); + const rowByDocumentId = new Map( + storedRows.map((row) => [row.documentId, row]), + ); + const linkedIds = storedRows.map((row) => row.documentId); + const candidateIds = [...new Set([...explicitIds, ...linkedIds])]; + const existingDocuments = ( + candidateIds.length + ? await queryDb + .select() + .from(schema.documents) + .where(inArray(schema.documents.id, candidateIds)) + : [] + ) as Document[]; + const documentById = new Map( + existingDocuments.map((document) => [document.id, document]), + ); + return { storedRows, rowByPath, rowByDocumentId, documentById }; + }; + + const initialSnapshot = await loadSnapshot(db); for (const id of explicitIds) { - const existing = documentById.get(id); + const existing = initialSnapshot.documentById.get(id); if (!existing) continue; if (existing.spaceId !== target.database.spaceId) { throw new Error( @@ -238,124 +249,144 @@ export default defineAction({ const conflicts: Array<{ id: string; path: string; title: string }> = []; const outbound: Array<{ id: string; path: string; title: string }> = []; - const plans = valid.map((file, index) => { - const pathRow = rowByPath.get(file.path); - const id = - file.id ?? - pathRow?.documentId ?? - opaqueId("content_local_file", `${sourceId}:${file.path}`); - const existing = documentById.get(id); - const previousRow = rowByDocumentId.get(id) ?? pathRow; - const previousValues = parseJson(previousRow?.sourceValuesJson); - const previousHash = - typeof previousValues.contentHash === "string" - ? previousValues.contentHash - : null; - const previousMetadataHash = - typeof previousValues.metadataHash === "string" - ? previousValues.metadataHash - : null; - const incomingHash = contentHash(file.content); - const incomingMetadataHash = metadataHash(file); - const localHash = existing ? contentHash(existing.content) : null; - const localMetadataHash = existing ? metadataHash(existing) : null; - const incomingChanged = - previousHash !== incomingHash || - previousMetadataHash !== incomingMetadataHash; - const localChanged = - !!existing && - ((!!previousHash && localHash !== previousHash) || - (!!previousMetadataHash && - localMetadataHash !== previousMetadataHash)); - const conflict = - !!existing && - !!previousHash && - incomingChanged && - (localChanged || policy === "database_primary") && - localHash !== incomingHash; - const keepContent = - !!existing && - !!previousHash && - localChanged && - !incomingChanged && - policy !== "source_primary"; - const applyIncoming = - incomingChanged || (localChanged && policy === "source_primary"); - return { - file, - index, - id, - existing, - previousRow, - previousHash, - incomingHash, - incomingMetadataHash, - localHash, - localMetadataHash, - incomingChanged, - localChanged, - conflict, - keepContent, - applyIncoming, - }; - }); - - const currentDocumentIds = new Set(plans.map((plan) => plan.id)); - const missingRows = storedRows.filter( - (row) => !currentDocumentIds.has(row.documentId), - ); - for (const row of missingRows) { - const values = parseJson(row.sourceValuesJson); - const path = String(values.relativePath ?? row.sourceDisplayKey ?? ""); - const document = documentById.get(row.documentId); - conflicts.push({ - id: row.documentId, - path, - title: document?.title ?? path, + const buildPlans = (snapshot: Awaited>) => + valid.map((file, index) => { + const pathRow = snapshot.rowByPath.get(file.path); + const id = + file.id ?? + pathRow?.documentId ?? + opaqueId("content_local_file", `${sourceId}:${file.path}`); + const existing = snapshot.documentById.get(id); + const previousRow = snapshot.rowByDocumentId.get(id) ?? pathRow; + const previousValues = parseJson(previousRow?.sourceValuesJson); + const previousHash = + typeof previousValues.contentHash === "string" + ? previousValues.contentHash + : null; + const previousMetadataHash = + typeof previousValues.metadataHash === "string" + ? previousValues.metadataHash + : null; + const incomingHash = contentHash(file.content); + const incomingMetadataHash = metadataHash(file); + const localHash = existing ? contentHash(existing.content) : null; + const localMetadataHash = existing ? metadataHash(existing) : null; + const incomingChanged = + previousHash !== incomingHash || + previousMetadataHash !== incomingMetadataHash; + const localChanged = + !!existing && + ((!!previousHash && localHash !== previousHash) || + (!!previousMetadataHash && + localMetadataHash !== previousMetadataHash)); + const conflict = + !!existing && + !!previousHash && + incomingChanged && + (localChanged || policy === "database_primary") && + (localHash !== incomingHash || + localMetadataHash !== incomingMetadataHash); + const keepContent = + !!existing && + !!previousHash && + localChanged && + !incomingChanged && + policy !== "source_primary"; + const applyIncoming = + incomingChanged || (localChanged && policy === "source_primary"); + return { + file, + index, + id, + existing, + previousRow, + previousHash, + incomingHash, + incomingMetadataHash, + localHash, + localMetadataHash, + incomingChanged, + localChanged, + conflict, + keepContent, + applyIncoming, + }; }); - } - for (const plan of plans) { - if (plan.conflict) { + let plans = buildPlans(initialSnapshot); + const initialDocumentIds = new Set(plans.map((plan) => plan.id)); + let missingRows = initialSnapshot.storedRows.filter( + (row) => !initialDocumentIds.has(row.documentId), + ); + const classifyPlans = ( + snapshot: Awaited>, + ) => { + created.length = 0; + updated.length = 0; + unchanged.length = 0; + conflicts.length = 0; + outbound.length = 0; + for (const row of missingRows) { + const values = parseJson(row.sourceValuesJson); + const path = String(values.relativePath ?? row.sourceDisplayKey ?? ""); + const document = snapshot.documentById.get(row.documentId); conflicts.push({ - id: plan.id, - path: plan.file.path, - title: plan.file.title, - }); - } else if (!plan.existing) { - created.push({ - id: plan.id, - path: plan.file.path, - title: plan.file.title, - }); - } else if ( - plan.applyIncoming || - plan.existing.title !== plan.file.title || - plan.existing.sourcePath !== plan.file.path - ) { - updated.push({ - id: plan.id, - path: plan.file.path, - title: plan.file.title, - }); - } else { - unchanged.push({ - id: plan.id, - path: plan.file.path, - title: plan.file.title, + id: row.documentId, + path, + title: document?.title ?? path, }); } - if (plan.keepContent) { - outbound.push({ - id: plan.id, - path: plan.file.path, - title: plan.file.title, - }); + + for (const plan of plans) { + if (plan.conflict) { + conflicts.push({ + id: plan.id, + path: plan.file.path, + title: plan.file.title, + }); + } else if (!plan.existing) { + created.push({ + id: plan.id, + path: plan.file.path, + title: plan.file.title, + }); + } else if ( + plan.applyIncoming || + plan.existing.title !== plan.file.title || + plan.existing.sourcePath !== plan.file.path + ) { + updated.push({ + id: plan.id, + path: plan.file.path, + title: plan.file.title, + }); + } else { + unchanged.push({ + id: plan.id, + path: plan.file.path, + title: plan.file.title, + }); + } + if (plan.keepContent) { + outbound.push({ + id: plan.id, + path: plan.file.path, + title: plan.file.title, + }); + } } - } + }; + classifyPlans(initialSnapshot); if (!dryRun) { await db.transaction(async (tx: any) => { + const transactionSnapshot = await loadSnapshot(tx); + plans = buildPlans(transactionSnapshot); + const currentDocumentIds = new Set(plans.map((plan) => plan.id)); + missingRows = transactionSnapshot.storedRows.filter( + (row) => !currentDocumentIds.has(row.documentId), + ); + classifyPlans(transactionSnapshot); for (const plan of plans) { if (plan.conflict) { const changeSetId = opaqueId( @@ -370,7 +401,10 @@ export default defineAction({ sourceId, databaseItemId: plan.previousRow?.databaseItemId ?? null, documentId: plan.id, - kind: "body_update", + kind: + plan.localHash === plan.incomingHash + ? "metadata_update" + : "body_update", direction: "incoming", state: "proposed", pushMode: "none", From ba2618ea315725de64919058d0d00845c89e54ae Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:23:43 -0400 Subject: [PATCH 03/76] fix(content): preserve reviewed workspace revisions --- templates/content/actions/_content-spaces.ts | 10 + .../content/actions/content-spaces.db.test.ts | 74 +++++ .../actions/disconnect-local-folder-source.ts | 87 +++--- .../actions/import-content-source.db.test.ts | 10 + .../actions/local-folder-source.db.test.ts | 283 ++++++++++++++++++ .../actions/resolve-local-folder-conflict.ts | 125 ++++++-- .../actions/sync-local-folder-source.ts | 15 +- 7 files changed, 549 insertions(+), 55 deletions(-) diff --git a/templates/content/actions/_content-spaces.ts b/templates/content/actions/_content-spaces.ts index c58e763f1c..07ad66c6f2 100644 --- a/templates/content/actions/_content-spaces.ts +++ b/templates/content/actions/_content-spaces.ts @@ -298,7 +298,16 @@ export async function provisionContentSpaces( ).databaseId, })), ]; + const provisionableOrganizationSpaceIds = new Set( + memberships + .filter( + (membership) => + membership.role === "owner" || membership.role === "admin", + ) + .map((membership) => organizationContentSpaceId(membership.orgId)), + ); for (const space of spaces.slice(1)) { + if (!provisionableOrganizationSpaceIds.has(space.id)) continue; const files = await ensureSystemDatabase({ db: tx, spaceId: space.id, @@ -425,6 +434,7 @@ export async function provisionContentSpaces( db, }); for (const membership of memberships) { + if (membership.role !== "owner" && membership.role !== "admin") continue; const spaceId = organizationContentSpaceId(membership.orgId); const [database] = await db .select({ diff --git a/templates/content/actions/content-spaces.db.test.ts b/templates/content/actions/content-spaces.db.test.ts index 03bd26ae94..6ff58bf00b 100644 --- a/templates/content/actions/content-spaces.db.test.ts +++ b/templates/content/actions/content-spaces.db.test.ts @@ -222,9 +222,83 @@ describe("Content space provisioning", () => { ]); }); + it("does not create or seed organization resources from a viewer session", async () => { + const orgId = "org-viewer-provisioning"; + const spaceId = organizationContentSpaceId(orgId); + await addOrganization(orgId, "Viewer Provisioning"); + await addMember("viewer-provisioning", orgId, MEMBER); + + const viewerResult = await runWithRequestContext( + { userEmail: MEMBER }, + () => provisionContentSpaces(getDb(), MEMBER), + ); + expect(viewerResult.spaceIds).toContain(spaceId); + await expect( + getDb() + .select() + .from(schema.contentSpaces) + .where(eq(schema.contentSpaces.id, spaceId)), + ).resolves.toEqual([]); + await expect( + getDb() + .select() + .from(schema.contentDatabases) + .where(eq(schema.contentDatabases.spaceId, spaceId)), + ).resolves.toEqual([]); + + await addMember("owner-viewer-provisioning", orgId, OWNER, "owner"); + await runWithRequestContext({ userEmail: OWNER }, () => + provisionContentSpaces(getDb(), OWNER), + ); + const [filesDatabase] = await getDb() + .select() + .from(schema.contentDatabases) + .where( + and( + eq(schema.contentDatabases.spaceId, spaceId), + eq(schema.contentDatabases.systemRole, "files"), + ), + ); + await getDb() + .delete(schema.documentPropertyDefinitions) + .where( + eq(schema.documentPropertyDefinitions.databaseId, filesDatabase!.id), + ); + await getDb() + .update(schema.contentDatabases) + .set({ primaryBlocksPropertyId: null, blocksSeeded: 0 }) + .where(eq(schema.contentDatabases.id, filesDatabase!.id)); + + await runWithRequestContext({ userEmail: MEMBER }, () => + provisionContentSpaces(getDb(), MEMBER), + ); + await expect( + getDb() + .select() + .from(schema.documentPropertyDefinitions) + .where( + eq(schema.documentPropertyDefinitions.databaseId, filesDatabase!.id), + ), + ).resolves.toEqual([]); + await runWithRequestContext({ userEmail: MEMBER }, async () => { + await expect(resolveContentSpaceAccess(spaceId)).resolves.toMatchObject({ + role: "viewer", + }); + await expect(listContentSpacesAction.run({})).resolves.toMatchObject({ + spaces: expect.arrayContaining([ + expect.objectContaining({ id: spaceId, role: "viewer" }), + ]), + }); + }); + }); + it("does not let a stale catalog reference grant a non-member visibility", async () => { await addOrganization("org-shared", "Shared"); + await addMember("owner-shared", "org-shared", OWNER, "owner"); await addMember("member-shared", "org-shared", MEMBER); + await runWithRequestContext({ userEmail: OWNER }, () => + provisionContentSpaces(getDb(), OWNER), + ); await runWithRequestContext({ userEmail: MEMBER }, () => provisionContentSpaces(getDb(), MEMBER), ); diff --git a/templates/content/actions/disconnect-local-folder-source.ts b/templates/content/actions/disconnect-local-folder-source.ts index 497cb2b5e1..a70f5a35ef 100644 --- a/templates/content/actions/disconnect-local-folder-source.ts +++ b/templates/content/actions/disconnect-local-folder-source.ts @@ -36,44 +36,18 @@ export default defineAction({ } await resolveContentSpaceAccess(target.database.spaceId, "editor"); const spaceId = target.database.spaceId; - const rows = await db - .select({ documentId: schema.contentDatabaseSourceRows.documentId }) - .from(schema.contentDatabaseSourceRows) - .where(eq(schema.contentDatabaseSourceRows.sourceId, sourceId)); - const documentIds = [...new Set(rows.map((row) => row.documentId))]; - const remainingLocalRows = documentIds.length - ? await db - .select({ - documentId: schema.contentDatabaseSourceRows.documentId, - sourceDisplayKey: schema.contentDatabaseSourceRows.sourceDisplayKey, - sourceValuesJson: schema.contentDatabaseSourceRows.sourceValuesJson, - sourceName: schema.contentDatabaseSources.sourceName, - }) - .from(schema.contentDatabaseSourceRows) - .innerJoin( - schema.contentDatabaseSources, - eq( - schema.contentDatabaseSources.id, - schema.contentDatabaseSourceRows.sourceId, - ), - ) - .where( - and( - inArray(schema.contentDatabaseSourceRows.documentId, documentIds), - ne(schema.contentDatabaseSourceRows.sourceId, sourceId), - eq( - schema.contentDatabaseSources.sourceType, - LOCAL_FOLDER_SOURCE_TYPE, - ), - ), - ) - : []; - const remainingLocalRowByDocument = new Map( - remainingLocalRows.map((row) => [row.documentId, row]), - ); + let disconnectedDocuments = 0; const now = new Date().toISOString(); await db.transaction(async (tx: any) => { + const rows = await tx + .select({ documentId: schema.contentDatabaseSourceRows.documentId }) + .from(schema.contentDatabaseSourceRows) + .where(eq(schema.contentDatabaseSourceRows.sourceId, sourceId)); + const documentIds: string[] = [ + ...new Set(rows.map((row: any) => String(row.documentId))), + ]; + disconnectedDocuments = documentIds.length; await tx .delete(schema.contentDatabaseSourceExecutions) .where(eq(schema.contentDatabaseSourceExecutions.sourceId, sourceId)); @@ -102,6 +76,47 @@ export default defineAction({ await tx .delete(schema.contentDatabaseSources) .where(eq(schema.contentDatabaseSources.id, sourceId)); + type RemainingLocalRow = { + documentId: string; + sourceDisplayKey: string | null; + sourceValuesJson: string | null; + sourceName: string; + }; + const remainingLocalRows: RemainingLocalRow[] = documentIds.length + ? await tx + .select({ + documentId: schema.contentDatabaseSourceRows.documentId, + sourceDisplayKey: + schema.contentDatabaseSourceRows.sourceDisplayKey, + sourceValuesJson: + schema.contentDatabaseSourceRows.sourceValuesJson, + sourceName: schema.contentDatabaseSources.sourceName, + }) + .from(schema.contentDatabaseSourceRows) + .innerJoin( + schema.contentDatabaseSources, + eq( + schema.contentDatabaseSources.id, + schema.contentDatabaseSourceRows.sourceId, + ), + ) + .where( + and( + inArray( + schema.contentDatabaseSourceRows.documentId, + documentIds, + ), + ne(schema.contentDatabaseSourceRows.sourceId, sourceId), + eq( + schema.contentDatabaseSources.sourceType, + LOCAL_FOLDER_SOURCE_TYPE, + ), + ), + ) + : []; + const remainingLocalRowByDocument = new Map( + remainingLocalRows.map((row) => [row.documentId, row]), + ); const documentsToClear = documentIds.filter( (documentId) => !remainingLocalRowByDocument.has(documentId), ); @@ -155,7 +170,7 @@ export default defineAction({ return { success: true, sourceId, - disconnectedDocuments: documentIds.length, + disconnectedDocuments, localFilesDeleted: 0, }; }, diff --git a/templates/content/actions/import-content-source.db.test.ts b/templates/content/actions/import-content-source.db.test.ts index 20cccf092a..869f6e1da7 100644 --- a/templates/content/actions/import-content-source.db.test.ts +++ b/templates/content/actions/import-content-source.db.test.ts @@ -18,6 +18,7 @@ type Schema = typeof import("../server/db/schema.js"); let getDb: () => any; let schema: Schema; let importContentSourceAction: typeof import("./import-content-source.js").default; +let provisionContentSpaces: typeof import("./_content-spaces.js").provisionContentSpaces; const OWNER = "owner@example.com"; const VIEWER = "import-viewer@example.com"; @@ -30,6 +31,8 @@ beforeAll(async () => { schema = dbModule.schema; importContentSourceAction = (await import("./import-content-source.js")) .default; + provisionContentSpaces = (await import("./_content-spaces.js")) + .provisionContentSpaces; const plugin = (await import("../server/plugins/db.js")).default; await plugin(undefined as any); await getDbExec().execute(`CREATE TABLE IF NOT EXISTS organizations ( @@ -71,6 +74,13 @@ describe("import-content-source descriptions", () => { sql: "INSERT INTO org_members (id, org_id, email, role, joined_at) VALUES (?, ?, ?, ?, ?)", args: ["import-viewer-membership", ORG_ID, VIEWER, "member", Date.now()], }); + await getDbExec().execute({ + sql: "INSERT INTO org_members (id, org_id, email, role, joined_at) VALUES (?, ?, ?, ?, ?)", + args: ["import-owner-membership", ORG_ID, OWNER, "owner", Date.now()], + }); + await runWithRequestContext({ userEmail: OWNER, orgId: ORG_ID }, () => + provisionContentSpaces(getDb(), OWNER), + ); await expect( runWithRequestContext({ userEmail: VIEWER, orgId: ORG_ID }, () => diff --git a/templates/content/actions/local-folder-source.db.test.ts b/templates/content/actions/local-folder-source.db.test.ts index dffe4fe1f9..7ec7c142c6 100644 --- a/templates/content/actions/local-folder-source.db.test.ts +++ b/templates/content/actions/local-folder-source.db.test.ts @@ -296,6 +296,42 @@ describe("local-folder Content source", () => { }), ]), ); + + const nextResult = await runWithRequestContext({ userEmail: OWNER }, () => + syncLocalFolder.run({ + sourceId: connection.sourceId, + files: { + "metadata.md": `---\ntitle: Newer folder title\ndescription: Newer folder description\n---\nBody.`, + }, + }), + ); + expect(nextResult.conflicts).toHaveLength(1); + const proposals = await getDb() + .select() + .from(schema.contentDatabaseSourceChangeSets) + .where( + and( + eq( + schema.contentDatabaseSourceChangeSets.sourceId, + connection.sourceId, + ), + eq(schema.contentDatabaseSourceChangeSets.documentId, documentId), + ), + ); + expect(proposals).toHaveLength(2); + expect(new Set(proposals.map((proposal) => proposal.id)).size).toBe(2); + expect( + proposals.map((proposal) => JSON.parse(proposal.fieldChangesJson)), + ).toEqual( + expect.arrayContaining([ + expect.arrayContaining([ + expect.objectContaining({ proposedValue: "Folder title" }), + ]), + expect.arrayContaining([ + expect.objectContaining({ proposedValue: "Newer folder title" }), + ]), + ]), + ); }); it("replans inside the write transaction so a just-committed edit is not overwritten", async () => { @@ -357,6 +393,100 @@ describe("local-folder Content source", () => { } }); + it("refuses to accept a staged folder revision after Content changes again", async () => { + const connection = await runWithRequestContext({ userEmail: OWNER }, () => + connectLocalFolder.run({ + connectionId: "desktop-folder-resolution-race", + label: "Resolution race docs", + createSourceBackedSpace: true, + truthPolicy: "source_primary", + }), + ); + const first = await runWithRequestContext({ userEmail: OWNER }, () => + syncLocalFolder.run({ + sourceId: connection.sourceId, + files: { "resolve-race.md": "Baseline body." }, + }), + ); + const documentId = first.created[0]!.id; + await getDb() + .update(schema.documents) + .set({ + title: "Content title at review", + content: "Content body at review.", + updatedAt: new Date().toISOString(), + }) + .where(eq(schema.documents.id, documentId)); + await runWithRequestContext({ userEmail: OWNER }, () => + syncLocalFolder.run({ + sourceId: connection.sourceId, + files: { "resolve-race.md": "Folder body under review." }, + }), + ); + const [changeSet] = await getDb() + .select() + .from(schema.contentDatabaseSourceChangeSets) + .where( + and( + eq( + schema.contentDatabaseSourceChangeSets.sourceId, + connection.sourceId, + ), + eq(schema.contentDatabaseSourceChangeSets.documentId, documentId), + ), + ); + + const db = getDb(); + const originalTransaction = db.transaction; + let injectedEdit = false; + db.transaction = async function (...args: any[]) { + if (!injectedEdit) { + injectedEdit = true; + await db + .update(schema.documents) + .set({ + title: "Content title after review", + content: "Content body committed after review.", + updatedAt: new Date().toISOString(), + }) + .where(eq(schema.documents.id, documentId)); + } + return originalTransaction.apply(this, args); + }; + + try { + await expect( + runWithRequestContext({ userEmail: OWNER }, () => + resolveLocalFolderConflict.run({ + changeSetId: changeSet.id, + decision: "accept_source", + sourceContent: "Folder body under review.", + }), + ), + ).rejects.toThrow("Content changed after this conflict was reviewed"); + expect(injectedEdit).toBe(true); + await expect( + db + .select() + .from(schema.documents) + .where(eq(schema.documents.id, documentId)), + ).resolves.toEqual([ + expect.objectContaining({ + title: "Content title after review", + content: "Content body committed after review.", + }), + ]); + await expect( + db + .select() + .from(schema.contentDatabaseSourceChangeSets) + .where(eq(schema.contentDatabaseSourceChangeSets.id, changeSet.id)), + ).resolves.toEqual([expect.objectContaining({ state: "proposed" })]); + } finally { + db.transaction = originalTransaction; + } + }); + it("tracks stable-id renames and reviews source deletions without deleting the global page", async () => { const connection = await runWithRequestContext({ userEmail: OWNER }, () => connectLocalFolder.run({ @@ -448,6 +578,88 @@ describe("local-folder Content source", () => { ).resolves.toHaveLength(0); }); + it("keeps a remaining folder link when accepting deletion from another source", async () => { + const first = await runWithRequestContext({ userEmail: OWNER }, () => + connectLocalFolder.run({ + connectionId: "desktop-folder-delete-shared-a", + label: "Delete shared A", + createSourceBackedSpace: true, + truthPolicy: "source_primary", + }), + ); + const second = await runWithRequestContext({ userEmail: OWNER }, () => + connectLocalFolder.run({ + connectionId: "desktop-folder-delete-shared-b", + label: "Delete shared B", + spaceId: first.spaceId, + truthPolicy: "source_primary", + }), + ); + const source = `---\nid: shared-delete-page\n---\nBody.`; + await runWithRequestContext({ userEmail: OWNER }, () => + syncLocalFolder.run({ + sourceId: first.sourceId, + files: { "from-a.md": source }, + }), + ); + await runWithRequestContext({ userEmail: OWNER }, () => + syncLocalFolder.run({ + sourceId: second.sourceId, + files: { "from-b.md": source }, + }), + ); + await runWithRequestContext({ userEmail: OWNER }, () => + syncLocalFolder.run({ sourceId: first.sourceId, files: {} }), + ); + const [changeSet] = await getDb() + .select() + .from(schema.contentDatabaseSourceChangeSets) + .where( + and( + eq(schema.contentDatabaseSourceChangeSets.sourceId, first.sourceId), + eq( + schema.contentDatabaseSourceChangeSets.documentId, + "shared-delete-page", + ), + ), + ); + + await runWithRequestContext({ userEmail: OWNER }, () => + resolveLocalFolderConflict.run({ + changeSetId: changeSet.id, + decision: "accept_source", + }), + ); + + await expect( + getDb() + .select() + .from(schema.contentDatabaseSourceRows) + .where( + and( + eq(schema.contentDatabaseSourceRows.sourceId, second.sourceId), + eq( + schema.contentDatabaseSourceRows.documentId, + "shared-delete-page", + ), + ), + ), + ).resolves.toHaveLength(1); + await expect( + getDb() + .select() + .from(schema.documents) + .where(eq(schema.documents.id, "shared-delete-page")), + ).resolves.toEqual([ + expect.objectContaining({ + sourceMode: "local-files", + sourceKind: "file", + sourcePath: "from-b.md", + sourceRootPath: "Delete shared B", + }), + ]); + }); + it("keeps database-primary edits and stages them for export", async () => { const connection = await runWithRequestContext({ userEmail: OWNER }, () => connectLocalFolder.run({ @@ -666,4 +878,75 @@ describe("local-folder Content source", () => { }), ]); }); + + it("rechecks remaining folder links inside the disconnect transaction", async () => { + const first = await runWithRequestContext({ userEmail: OWNER }, () => + connectLocalFolder.run({ + connectionId: "desktop-folder-disconnect-race-a", + label: "Disconnect race A", + createSourceBackedSpace: true, + truthPolicy: "source_primary", + }), + ); + const second = await runWithRequestContext({ userEmail: OWNER }, () => + connectLocalFolder.run({ + connectionId: "desktop-folder-disconnect-race-b", + label: "Disconnect race B", + spaceId: first.spaceId, + truthPolicy: "source_primary", + }), + ); + const sharedDocument = `---\nid: disconnect-race-page\n---\nBody.`; + for (const [sourceId, path] of [ + [first.sourceId, "a.md"], + [second.sourceId, "b.md"], + ] as const) { + await runWithRequestContext({ userEmail: OWNER }, () => + syncLocalFolder.run({ + sourceId, + files: { [path]: sharedDocument }, + }), + ); + } + + const db = getDb(); + const originalTransaction = db.transaction; + let removedCompetingSource = false; + db.transaction = async function (...args: any[]) { + if (!removedCompetingSource) { + removedCompetingSource = true; + await db + .delete(schema.contentDatabaseSourceRows) + .where( + eq(schema.contentDatabaseSourceRows.sourceId, second.sourceId), + ); + await db + .delete(schema.contentDatabaseSources) + .where(eq(schema.contentDatabaseSources.id, second.sourceId)); + } + return originalTransaction.apply(this, args); + }; + try { + await runWithRequestContext({ userEmail: OWNER }, () => + disconnectLocalFolder.run({ sourceId: first.sourceId }), + ); + } finally { + db.transaction = originalTransaction; + } + + expect(removedCompetingSource).toBe(true); + await expect( + db + .select() + .from(schema.documents) + .where(eq(schema.documents.id, "disconnect-race-page")), + ).resolves.toEqual([ + expect.objectContaining({ + sourceMode: "database", + sourceKind: null, + sourcePath: null, + sourceRootPath: null, + }), + ]); + }); }); diff --git a/templates/content/actions/resolve-local-folder-conflict.ts b/templates/content/actions/resolve-local-folder-conflict.ts index 3f3aff1f6c..7e5ba1b13c 100644 --- a/templates/content/actions/resolve-local-folder-conflict.ts +++ b/templates/content/actions/resolve-local-folder-conflict.ts @@ -2,7 +2,7 @@ import { createHash } from "node:crypto"; import { defineAction } from "@agent-native/core"; import { writeAppState } from "@agent-native/core/application-state"; -import { and, eq } from "drizzle-orm"; +import { and, eq, ne } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; @@ -15,6 +15,20 @@ function hash(content: string) { return createHash("sha256").update(content).digest("hex"); } +function metadataHash(value: { + title: string; + description?: string | null; + icon?: string | null; +}) { + return hash( + JSON.stringify({ + title: value.title, + description: value.description ?? "", + icon: value.icon ?? null, + }), + ); +} + function parseObject(value: string | null | undefined) { if (!value) return {} as Record; try { @@ -110,6 +124,14 @@ export default defineAction({ typeof bodyChange.proposedHash === "string" ? bodyChange.proposedHash : null; + const reviewedContentHash = + typeof bodyChange.currentHash === "string" + ? bodyChange.currentHash + : null; + const reviewedMetadataHash = + typeof bodyChange.currentMetadataHash === "string" + ? bodyChange.currentMetadataHash + : null; if (!sourceDeletion && !proposedHash) throw new Error("Conflict is missing its source hash"); if ( @@ -130,6 +152,15 @@ export default defineAction({ "The supplied folder revision changed after review; refresh before resolving", ); } + if ( + decision === "accept_source" && + !sourceDeletion && + (!reviewedContentHash || !reviewedMetadataHash) + ) { + throw new Error( + "Conflict is missing its reviewed Content revision; refresh before resolving", + ); + } const now = new Date().toISOString(); await db.transaction(async (tx: any) => { @@ -145,50 +176,110 @@ export default defineAction({ ), ), ); + const [remainingLocalRow] = await tx + .select({ + sourceDisplayKey: schema.contentDatabaseSourceRows.sourceDisplayKey, + sourceValuesJson: schema.contentDatabaseSourceRows.sourceValuesJson, + sourceName: schema.contentDatabaseSources.sourceName, + }) + .from(schema.contentDatabaseSourceRows) + .innerJoin( + schema.contentDatabaseSources, + eq( + schema.contentDatabaseSources.id, + schema.contentDatabaseSourceRows.sourceId, + ), + ) + .where( + and( + eq( + schema.contentDatabaseSourceRows.documentId, + target.document.id, + ), + ne(schema.contentDatabaseSourceRows.sourceId, target.source.id), + eq( + schema.contentDatabaseSources.sourceType, + LOCAL_FOLDER_SOURCE_TYPE, + ), + ), + ) + .orderBy(schema.contentDatabaseSources.id) + .limit(1); + const remainingValues = parseObject( + remainingLocalRow?.sourceValuesJson, + ); + const remainingPath = + typeof remainingValues.relativePath === "string" + ? remainingValues.relativePath + : remainingLocalRow?.sourceDisplayKey; await tx .update(schema.documents) - .set({ - sourceMode: null, - sourceKind: null, - sourcePath: null, - sourceRootPath: null, - sourceUpdatedAt: null, - updatedAt: now, - }) + .set( + remainingLocalRow + ? { + sourceMode: "local-files", + sourceKind: "file", + sourcePath: remainingPath ?? null, + sourceRootPath: remainingLocalRow.sourceName, + sourceUpdatedAt: now, + updatedAt: now, + } + : { + sourceMode: null, + sourceKind: null, + sourcePath: null, + sourceRootPath: null, + sourceUpdatedAt: null, + updatedAt: now, + }, + ) .where(eq(schema.documents.id, target.document.id)); } else if (decision === "accept_source") { + const [currentDocument] = await tx + .select() + .from(schema.documents) + .where(eq(schema.documents.id, target.document.id)); + if ( + !currentDocument || + hash(currentDocument.content) !== reviewedContentHash || + metadataHash(currentDocument) !== reviewedMetadataHash + ) { + throw new Error( + "Content changed after this conflict was reviewed; refresh before resolving", + ); + } const metadata = proposedMetadata(target.changeSet.fieldChangesJson); const resolvedTitle = Object.prototype.hasOwnProperty.call( metadata, "title", ) ? (metadata.title ?? "") - : target.document.title; + : currentDocument.title; const resolvedDescription = Object.prototype.hasOwnProperty.call( metadata, "description", ) ? (metadata.description ?? "") - : (target.document.description ?? ""); + : (currentDocument.description ?? ""); const resolvedIcon = Object.prototype.hasOwnProperty.call( metadata, "icon", ) ? (metadata.icon ?? null) - : (target.document.icon ?? null); + : (currentDocument.icon ?? null); await tx .insert(schema.documentVersions) .values({ id: `content_document_version_${createHash("sha256") .update( - `${target.document.id}:${target.document.updatedAt}:${proposedHash}`, + `${currentDocument.id}:${currentDocument.updatedAt}:${proposedHash}`, ) .digest("hex") .slice(0, 32)}`, - ownerEmail: target.document.ownerEmail, - documentId: target.document.id, - title: target.document.title, - content: target.document.content, + ownerEmail: currentDocument.ownerEmail, + documentId: currentDocument.id, + title: currentDocument.title, + content: currentDocument.content, createdAt: now, }) .onConflictDoNothing(); diff --git a/templates/content/actions/sync-local-folder-source.ts b/templates/content/actions/sync-local-folder-source.ts index 109e771774..82e989ee25 100644 --- a/templates/content/actions/sync-local-folder-source.ts +++ b/templates/content/actions/sync-local-folder-source.ts @@ -82,13 +82,20 @@ function sourceValues(args: { }); } -function bodyChangeJson(args: { currentHash: string; incomingHash: string }) { +function bodyChangeJson(args: { + currentHash: string; + incomingHash: string; + currentMetadataHash: string; + incomingMetadataHash: string; +}) { return JSON.stringify({ summary: "Local folder and Content both changed since the last sync.", currentExcerpt: null, proposedExcerpt: null, currentHash: args.currentHash, proposedHash: args.incomingHash, + currentMetadataHash: args.currentMetadataHash, + proposedMetadataHash: args.incomingMetadataHash, }); } @@ -391,7 +398,7 @@ export default defineAction({ if (plan.conflict) { const changeSetId = opaqueId( "content_source_change", - `${sourceId}:${plan.id}:${plan.incomingHash}`, + `${sourceId}:${plan.id}:${plan.incomingHash}:${plan.incomingMetadataHash}`, ); await tx .insert(schema.contentDatabaseSourceChangeSets) @@ -416,6 +423,8 @@ export default defineAction({ bodyChangeJson: bodyChangeJson({ currentHash: plan.localHash!, incomingHash: plan.incomingHash, + currentMetadataHash: plan.localMetadataHash!, + incomingMetadataHash: plan.incomingMetadataHash, }), createdAt: now, updatedAt: now, @@ -447,6 +456,8 @@ export default defineAction({ bodyChangeJson: bodyChangeJson({ currentHash: plan.incomingHash, incomingHash: plan.localHash!, + currentMetadataHash: plan.incomingMetadataHash, + incomingMetadataHash: plan.localMetadataHash!, }), createdAt: now, updatedAt: now, From 444188f4983accf0cdc4a1d3ec12cb10682dd5b3 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:41:07 -0400 Subject: [PATCH 04/76] fix(content): align workspace authorization context --- templates/content/actions/_content-files.ts | 7 +- .../components/sidebar/DocumentSidebar.tsx | 27 ++++- .../sidebar/select-content-space.test.ts | 103 ++++++++++++++++++ .../sidebar/select-content-space.ts | 13 +++ 4 files changed, 147 insertions(+), 3 deletions(-) create mode 100644 templates/content/app/components/sidebar/select-content-space.test.ts create mode 100644 templates/content/app/components/sidebar/select-content-space.ts diff --git a/templates/content/actions/_content-files.ts b/templates/content/actions/_content-files.ts index 13fe173729..0d9841ad2e 100644 --- a/templates/content/actions/_content-files.ts +++ b/templates/content/actions/_content-files.ts @@ -169,7 +169,12 @@ export async function ensureDocumentFilesMembership( const filesDatabases = await db .select() .from(schema.contentDatabases) - .where(eq(schema.contentDatabases.systemRole, "files")); + .where( + and( + eq(schema.contentDatabases.systemRole, "files"), + eq(schema.contentDatabases.spaceId, document.spaceId), + ), + ); return reconcileDocuments({ db, documents: [document], filesDatabases, now }); } diff --git a/templates/content/app/components/sidebar/DocumentSidebar.tsx b/templates/content/app/components/sidebar/DocumentSidebar.tsx index 753376c7c6..de9025efb9 100644 --- a/templates/content/app/components/sidebar/DocumentSidebar.tsx +++ b/templates/content/app/components/sidebar/DocumentSidebar.tsx @@ -11,7 +11,11 @@ import { ExtensionSlot, ExtensionsSidebarSection, } from "@agent-native/core/client/extensions"; -import { OrgSwitcher } from "@agent-native/core/client/org"; +import { + OrgSwitcher, + useOrg, + useSwitchOrg, +} from "@agent-native/core/client/org"; import { closestCenter, DndContext, @@ -115,6 +119,7 @@ import { } from "./document-sidebar-sections"; import { DocumentSidebarIcon, DocumentTreeItem } from "./DocumentTreeItem"; import { NotionButton } from "./NotionButton"; +import { selectContentSpace } from "./select-content-space"; function nanoid(size = 12): string { const chars = @@ -231,6 +236,8 @@ export function DocumentSidebar({ const updateDocument = useUpdateDocument(); const contentSpacesQuery = useContentSpaces(); const ensureContentSpaces = useEnsureContentSpaces(); + const { data: activeOrg } = useOrg(); + const switchOrg = useSwitchOrg(); const contentSpaces = contentSpacesQuery.data?.spaces ?? []; const spaceProvisionAttemptedRef = useRef(false); useEffect(() => { @@ -255,6 +262,21 @@ export function DocumentSidebar({ contentSpaces.find((space) => space.id === storedSpaceId) ?? contentSpaces[0] ?? null; + const handleSelectContentSpace = useCallback( + async (space: (typeof contentSpaces)[number]) => { + try { + await selectContentSpace({ + space, + activeOrgId: activeOrg?.orgId, + switchOrg: (orgId) => switchOrg.mutateAsync(orgId), + persistSelection: setStoredSpaceId, + }); + } catch (error) { + toast.error(error instanceof Error ? error.message : String(error)); + } + }, + [activeOrg?.orgId, setStoredSpaceId, switchOrg, t], + ); useEffect(() => { if (!selectedSpace) return; void setClientAppState( @@ -1049,7 +1071,8 @@ export function DocumentSidebar({ ? "bg-accent text-accent-foreground" : "text-muted-foreground hover:bg-accent/50 hover:text-foreground", )} - onClick={() => setStoredSpaceId(space.id)} + onClick={() => void handleSelectContentSpace(space)} + disabled={switchOrg.isPending} > {space.name} diff --git a/templates/content/app/components/sidebar/select-content-space.test.ts b/templates/content/app/components/sidebar/select-content-space.test.ts new file mode 100644 index 0000000000..24e2fc86b5 --- /dev/null +++ b/templates/content/app/components/sidebar/select-content-space.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { ContentSpaceSummary } from "@/hooks/use-content-spaces"; + +import { selectContentSpace } from "./select-content-space"; + +function space( + overrides: Partial = {}, +): ContentSpaceSummary { + return { + id: "space_1", + name: "Workspace", + kind: "organization", + filesDatabaseId: "database_1", + orgId: "org_1", + role: "owner", + catalogItemId: "catalog_item_1", + catalogDocumentId: "catalog_document_1", + ...overrides, + }; +} + +describe("selectContentSpace", () => { + it("switches organization context before persisting another org workspace", async () => { + const events: string[] = []; + const switchOrg = vi.fn(async (orgId: string | null) => { + events.push(`switch:${orgId}`); + }); + const persistSelection = vi.fn((spaceId: string) => { + events.push(`persist:${spaceId}`); + }); + + await selectContentSpace({ + space: space(), + activeOrgId: "org_2", + switchOrg, + persistSelection, + }); + + expect(events).toEqual(["switch:org_1", "persist:space_1"]); + }); + + it("switches explicitly when the active organization is still loading", async () => { + const switchOrg = vi.fn(async () => undefined); + const persistSelection = vi.fn(); + + await selectContentSpace({ + space: space({ kind: "personal", orgId: null }), + activeOrgId: undefined, + switchOrg, + persistSelection, + }); + + expect(switchOrg).toHaveBeenCalledWith(null); + expect(persistSelection).toHaveBeenCalledWith("space_1"); + }); + + it("switches to personal context for a personal workspace", async () => { + const switchOrg = vi.fn(async () => undefined); + const persistSelection = vi.fn(); + + await selectContentSpace({ + space: space({ kind: "personal", orgId: null }), + activeOrgId: "org_1", + switchOrg, + persistSelection, + }); + + expect(switchOrg).toHaveBeenCalledWith(null); + expect(persistSelection).toHaveBeenCalledWith("space_1"); + }); + + it("does not persist a selection when organization switching fails", async () => { + const error = new Error("Organization switch failed"); + const persistSelection = vi.fn(); + + await expect( + selectContentSpace({ + space: space(), + activeOrgId: "org_2", + switchOrg: async () => Promise.reject(error), + persistSelection, + }), + ).rejects.toBe(error); + + expect(persistSelection).not.toHaveBeenCalled(); + }); + + it("persists immediately when the organization context already matches", async () => { + const switchOrg = vi.fn(async () => undefined); + const persistSelection = vi.fn(); + + await selectContentSpace({ + space: space(), + activeOrgId: "org_1", + switchOrg, + persistSelection, + }); + + expect(switchOrg).not.toHaveBeenCalled(); + expect(persistSelection).toHaveBeenCalledWith("space_1"); + }); +}); diff --git a/templates/content/app/components/sidebar/select-content-space.ts b/templates/content/app/components/sidebar/select-content-space.ts new file mode 100644 index 0000000000..bad69be3d3 --- /dev/null +++ b/templates/content/app/components/sidebar/select-content-space.ts @@ -0,0 +1,13 @@ +import type { ContentSpaceSummary } from "@/hooks/use-content-spaces"; + +export async function selectContentSpace(args: { + space: ContentSpaceSummary; + activeOrgId: string | null | undefined; + switchOrg: (orgId: string | null) => Promise; + persistSelection: (spaceId: string) => void; +}) { + if (args.activeOrgId !== args.space.orgId) { + await args.switchOrg(args.space.orgId); + } + args.persistSelection(args.space.id); +} From 24c6e0a7b3e17b768d34b8b3ce1e00cc2158402f Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:49:24 -0400 Subject: [PATCH 05/76] fix(content): close local sync race boundaries --- .../actions/disconnect-local-folder-source.ts | 2 - .../actions/local-folder-source.db.test.ts | 79 +++++++++++++++++++ .../actions/sync-local-folder-source.ts | 41 +++++++--- 3 files changed, 109 insertions(+), 13 deletions(-) diff --git a/templates/content/actions/disconnect-local-folder-source.ts b/templates/content/actions/disconnect-local-folder-source.ts index a70f5a35ef..ca2f615981 100644 --- a/templates/content/actions/disconnect-local-folder-source.ts +++ b/templates/content/actions/disconnect-local-folder-source.ts @@ -127,7 +127,6 @@ export default defineAction({ sourceMode: "database", sourceKind: null, sourcePath: null, - sourceAbsolutePath: null, sourceRootPath: null, sourceUpdatedAt: now, updatedAt: now, @@ -153,7 +152,6 @@ export default defineAction({ sourceMode: "local-files", sourceKind: "file", sourcePath: relativePath, - sourceAbsolutePath: null, sourceRootPath: remaining.sourceName, sourceUpdatedAt: now, updatedAt: now, diff --git a/templates/content/actions/local-folder-source.db.test.ts b/templates/content/actions/local-folder-source.db.test.ts index 7ec7c142c6..6c67fbe475 100644 --- a/templates/content/actions/local-folder-source.db.test.ts +++ b/templates/content/actions/local-folder-source.db.test.ts @@ -393,6 +393,85 @@ describe("local-folder Content source", () => { } }); + it("rechecks explicit ids inside the transaction before updating a document", async () => { + const target = await runWithRequestContext({ userEmail: OWNER }, () => + connectLocalFolder.run({ + connectionId: "desktop-folder-explicit-id-race-target", + label: "Explicit ID race target", + createSourceBackedSpace: true, + truthPolicy: "source_primary", + }), + ); + const other = await runWithRequestContext({ userEmail: OWNER }, () => + connectLocalFolder.run({ + connectionId: "desktop-folder-explicit-id-race-other", + label: "Explicit ID race other", + createSourceBackedSpace: true, + truthPolicy: "source_primary", + }), + ); + const documentId = "explicit-id-created-after-planning"; + const db = getDb(); + const originalTransaction = db.transaction; + let injectedDocument = false; + db.transaction = async function (...args: any[]) { + if (!injectedDocument) { + injectedDocument = true; + const now = new Date().toISOString(); + await db.insert(schema.documents).values({ + id: documentId, + spaceId: other.spaceId, + ownerEmail: OWNER, + orgId: null, + visibility: "private", + title: "Other workspace document", + content: "Must remain untouched.", + createdAt: now, + updatedAt: now, + }); + } + return originalTransaction.apply(this, args); + }; + + try { + await expect( + runWithRequestContext({ userEmail: OWNER }, () => + syncLocalFolder.run({ + sourceId: target.sourceId, + files: { + "race.md": `---\nid: ${documentId}\n---\nIncoming body.`, + }, + }), + ), + ).rejects.toThrow("belongs to another Content space"); + expect(injectedDocument).toBe(true); + await expect( + db + .select() + .from(schema.documents) + .where(eq(schema.documents.id, documentId)), + ).resolves.toEqual([ + expect.objectContaining({ + spaceId: other.spaceId, + content: "Must remain untouched.", + }), + ]); + await expect( + db + .select() + .from(schema.contentDatabaseSourceRows) + .where( + and( + eq(schema.contentDatabaseSourceRows.sourceId, target.sourceId), + eq(schema.contentDatabaseSourceRows.documentId, documentId), + ), + ), + ).resolves.toHaveLength(0); + } finally { + db.transaction = originalTransaction; + } + }); + it("refuses to accept a staged folder revision after Content changes again", async () => { const connection = await runWithRequestContext({ userEmail: OWNER }, () => connectLocalFolder.run({ diff --git a/templates/content/actions/sync-local-folder-source.ts b/templates/content/actions/sync-local-folder-source.ts index 82e989ee25..95123b297b 100644 --- a/templates/content/actions/sync-local-folder-source.ts +++ b/templates/content/actions/sync-local-folder-source.ts @@ -175,6 +175,7 @@ export default defineAction({ throw new Error(`Local folder source "${sourceId}" not found`); } await resolveContentSpaceAccess(target.database.spaceId, "editor"); + const targetSpaceId = target.database.spaceId; const parsed = entries.map(([path, value]) => parseContentSourceFile(path, value), @@ -235,16 +236,22 @@ export default defineAction({ const initialSnapshot = await loadSnapshot(db); - for (const id of explicitIds) { - const existing = initialSnapshot.documentById.get(id); - if (!existing) continue; - if (existing.spaceId !== target.database.spaceId) { - throw new Error( - `Document "${id}" belongs to another Content space and cannot be imported here`, - ); + const validateExplicitIds = async ( + snapshot: Awaited>, + ) => { + for (const id of explicitIds) { + const existing = snapshot.documentById.get(id); + if (!existing) continue; + if (existing.spaceId !== targetSpaceId) { + throw new Error( + `Document "${id}" belongs to another Content space and cannot be imported here`, + ); + } + await assertAccess("document", id, "editor"); } - await assertAccess("document", id, "editor"); - } + }; + + await validateExplicitIds(initialSnapshot); const metadata = parseJson(target.source.metadataJson); const policy = truthPolicy(metadata.truthPolicy); @@ -388,6 +395,7 @@ export default defineAction({ if (!dryRun) { await db.transaction(async (tx: any) => { const transactionSnapshot = await loadSnapshot(tx); + await validateExplicitIds(transactionSnapshot); plans = buildPlans(transactionSnapshot); const currentDocumentIds = new Set(plans.map((plan) => plan.id)); missingRows = transactionSnapshot.storedRows.filter( @@ -507,7 +515,7 @@ export default defineAction({ createdAt: now, }) .onConflictDoNothing(); - await tx + const reboundDocuments = await tx .update(schema.documents) .set({ ...(plan.applyIncoming @@ -526,7 +534,18 @@ export default defineAction({ sourceUpdatedAt: now, updatedAt: now, }) - .where(eq(schema.documents.id, plan.id)); + .where( + and( + eq(schema.documents.id, plan.id), + eq(schema.documents.spaceId, targetSpaceId), + ), + ) + .returning({ id: schema.documents.id }); + if (reboundDocuments.length !== 1) { + throw new Error( + `Document "${plan.id}" left this Content space during local-folder sync`, + ); + } } } From 4511e980df5d603342d47ea7753a7f28f4232fbe Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:06:01 -0400 Subject: [PATCH 06/76] fix(content): harden workspace migration boundaries --- packages/core/src/cli/content-local.spec.ts | 39 +++ packages/core/src/cli/content-local.ts | 37 ++- .../src/cli/skills-content/content-skill.ts | 61 ++-- packages/core/src/cli/skills.spec.ts | 28 +- packages/core/src/cli/skills.ts | 60 +++- templates/content/actions/_content-files.ts | 3 +- templates/content/actions/_content-spaces.ts | 21 +- .../content/actions/add-database-item.ts | 7 +- .../content/actions/content-spaces.db.test.ts | 48 +++ .../actions/create-content-database.ts | 10 +- templates/content/actions/create-document.ts | 5 +- .../actions/local-folder-source.db.test.ts | 313 ++++++++++++++++++ .../actions/resolve-local-folder-conflict.ts | 82 ++++- .../actions/space-aware-writers.db.test.ts | 84 +++++ .../actions/sync-local-folder-source.ts | 43 ++- .../components/sidebar/DocumentSidebar.tsx | 19 +- .../sidebar/select-content-space.test.ts | 52 ++- .../sidebar/select-content-space.ts | 18 + 18 files changed, 849 insertions(+), 81 deletions(-) diff --git a/packages/core/src/cli/content-local.spec.ts b/packages/core/src/cli/content-local.spec.ts index 718d7b8a6b..77539e76ce 100644 --- a/packages/core/src/cli/content-local.spec.ts +++ b/packages/core/src/cli/content-local.spec.ts @@ -127,6 +127,45 @@ describe("content local CLI", () => { expect(manifest.apps.content).not.toHaveProperty("mode"); }); + it("migrates every legacy local-files root before removing the app mode", async () => { + const root = tmpDir(); + for (const directory of ["docs", "blog", "resources"]) { + fs.mkdirSync(path.join(root, directory), { recursive: true }); + } + writeJson(path.join(root, "agent-native.json"), { + version: 1, + apps: { + content: { + mode: "local-files", + roots: [ + { name: "Docs", path: "docs" }, + { name: "Blog", path: "blog" }, + { name: "Resources", path: "resources" }, + ], + }, + }, + }); + + await prepareContentLocalLaunch({ + cwd: root, + target: "docs", + dryRun: false, + }); + + const manifest = JSON.parse( + fs.readFileSync(path.join(root, "agent-native.json"), "utf8"), + ); + expect(manifest.apps.content).not.toHaveProperty("mode"); + expect(manifest.apps.content.roots).toHaveLength(3); + for (const manifestRoot of manifest.apps.content.roots) { + expect(manifestRoot.source).toMatchObject({ + type: "local-folder", + truthPolicy: "source_primary", + }); + expect(manifestRoot.source.connectionId).toMatch(/^local-folder:/); + } + }); + it("accumulates repeated file targets under a CLI-created root", async () => { const root = tmpDir(); fs.mkdirSync(path.join(root, "docs"), { recursive: true }); diff --git a/packages/core/src/cli/content-local.ts b/packages/core/src/cli/content-local.ts index 8362a55a63..67d5259324 100644 --- a/packages/core/src/cli/content-local.ts +++ b/packages/core/src/cli/content-local.ts @@ -51,8 +51,14 @@ function titleFromPath(value: string) { } function localFolderConnectionId(absoluteRootPath: string) { + let canonicalRootPath = path.resolve(absoluteRootPath); + try { + canonicalRootPath = fsSync.realpathSync(canonicalRootPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } const digest = createHash("sha256") - .update(fsSync.realpathSync(absoluteRootPath)) + .update(canonicalRootPath) .digest("base64url") .slice(0, 24); return `${LOCAL_FOLDER_CONNECTION_PREFIX}${digest}`; @@ -166,6 +172,7 @@ async function readManifest( function upsertContentManifest( manifest: Record, + workspaceRoot: string, rootPath: string, options: { connectionId: string; @@ -222,13 +229,38 @@ function upsertContentManifest( truthPolicy: "source_primary", }, }; - const roots = + let roots = rootIndex === -1 ? [...existingRoots, root] : existingRoots.map((entry, index) => index === rootIndex ? { ...entry, ...root } : entry, ); + if (existingContent.mode === "local-files") { + roots = roots.map((entry) => { + if (entry === root || typeof entry.path !== "string") return entry; + const existingSource = isRecord(entry.source) ? entry.source : {}; + return { + ...entry, + source: { + ...existingSource, + type: "local-folder", + connectionId: + typeof existingSource.connectionId === "string" && + existingSource.connectionId + ? existingSource.connectionId + : localFolderConnectionId( + path.resolve(workspaceRoot, entry.path), + ), + truthPolicy: + typeof existingSource.truthPolicy === "string" + ? existingSource.truthPolicy + : "source_primary", + }, + }; + }); + } + delete existingContent.mode; apps[CONTENT_APP_ID] = { ...existingContent, @@ -281,6 +313,7 @@ export async function prepareContentLocalLaunch(options: { const connectionId = localFolderConnectionId(target.absoluteRootPath); const manifest = upsertContentManifest( await readManifest(manifestPath), + target.workspaceRoot, target.rootPath, { connectionId, diff --git a/packages/core/src/cli/skills-content/content-skill.ts b/packages/core/src/cli/skills-content/content-skill.ts index 648bf23bab..f9542c53df 100644 --- a/packages/core/src/cli/skills-content/content-skill.ts +++ b/packages/core/src/cli/skills-content/content-skill.ts @@ -2,8 +2,9 @@ export const CONTENT_SKILL_MD = `--- name: content description: >- Use Content for repo-backed Markdown/MDX docs, blogs, resources, rich - document editing, local components, shareable copies, and Content local-file - workspaces. Prefer Content actions over raw filesystem writes when available. + document editing, local components, shareable copies, and database-backed + local-folder sources. Prefer Content actions over raw filesystem writes when + available. metadata: visibility: exported --- @@ -14,21 +15,22 @@ Use the Content app when a workflow is about authoring, editing, reviewing, or publishing Markdown/MDX documents: docs sites, blogs, resource libraries, marketing pages, internal notes, and local MDX components. Content gives the agent a document tree, a rich editor, normal document actions, and optional -local-file source of truth. +local-folder synchronization. ## Choose The Path - Use Content actions when the Content MCP/action tools are available: \`list-documents\`, \`search-documents\`, \`get-document\`, \`pull-document\`, \`create-document\`, \`edit-document\`, - \`update-document\`, \`delete-document\`, \`share-local-file-document\`, + \`update-document\`, \`delete-document\`, + \`sync-manifest-local-folder-source\`, \`sync-local-folder-source\`, \`list-local-component-files\`, and \`write-local-component-file\`. - Use \`pull-document\` or \`get-document\` before editing a page. Use \`edit-document\` for precise find/replace changes and \`update-document\` for full rewrites or new content. -- In Local File Mode, Content actions read and write the repo files declared in - \`agent-native.json\`; SQL remains cache/history/search glue, not the source of - truth for those pages. +- Local folders declared in \`agent-native.json\` are sources for ordinary + SQL-backed Content pages. Use the trusted bridge's pull/check/push workflows to + synchronize them; normal document actions always operate on the database. - If Content tools are not visible and no local Content app or Desktop bridge is running, treat this skill as repo-editing guidance. Edit configured \`.md\`/\`.mdx\` files directly, preserve frontmatter and MDX imports, and tell @@ -40,16 +42,15 @@ Prefer JSON input for action calls: \`\`\`bash pnpm action list-documents -pnpm action get-document '{"id":"local-file:..."}' -pnpm action edit-document '{"id":"local-file:...","find":"old copy","replace":"new copy"}' -pnpm action update-document '{"id":"local-file:...","content":"# Updated\\n\\nBody"}' -pnpm action share-local-file-document '{"id":"local-file:..."}' +pnpm action get-document '{"id":"document-id"}' +pnpm action edit-document '{"id":"document-id","find":"old copy","replace":"new copy"}' +pnpm action update-document '{"id":"document-id","content":"# Updated\\n\\nBody"}' \`\`\` Run \`refresh-list\` after create/update/delete operations when you need the open Content UI sidebar to repaint immediately. -## Local File Mode +## Local Folder Sources Install into an existing repo with: @@ -58,20 +59,30 @@ npx @agent-native/core@latest skills add content --mode local-files --scope proj \`\`\` The installer copies this skill and writes or updates \`agent-native.json\` with -Content roots for \`docs/\`, \`blog/\`, \`content/\`, and \`resources/\`, plus a -\`components/\` folder for local MDX components. A typical manifest looks like: +database-backed local-folder sources for \`docs/\`, \`blog/\`, \`content/\`, and +\`resources/\`, plus a \`components/\` folder for local MDX components. A trusted +local bridge imports these files into the workspace's canonical Files database. +Run \`sync-manifest-local-folder-source\` with a root's generated +\`source.connectionId\`, or launch \`agent-native content local-files \`, +to connect and pull it. A typical root looks like: \`\`\`json { "version": 1, "apps": { "content": { - "mode": "local-files", "roots": [ - { "name": "Docs", "path": "docs", "kind": "docs", "extensions": [".md", ".mdx"] }, - { "name": "Blog", "path": "blog", "kind": "blog", "extensions": [".md", ".mdx"] }, - { "name": "Content", "path": "content", "kind": "content", "extensions": [".md", ".mdx"] }, - { "name": "Resources", "path": "resources", "kind": "resources", "extensions": [".md", ".mdx"] } + { + "name": "Docs", + "path": "docs", + "kind": "docs", + "extensions": [".md", ".mdx"], + "source": { + "type": "local-folder", + "connectionId": "local-folder:", + "truthPolicy": "source_primary" + } + } ], "components": "components", "extensions": "extensions", @@ -81,9 +92,9 @@ Content roots for \`docs/\`, \`blog/\`, \`content/\`, and \`resources/\`, plus a } \`\`\` -Local File Mode does not make the host language model local, and the hosted -Content app cannot read private repo files by itself. File access requires a -local Content app, Agent Native Desktop, or another trusted local bridge. +Local-folder synchronization does not make the host language model local, and +the hosted Content app cannot read private repo files by itself. File access +requires a local Content app, Agent Native Desktop, or another trusted bridge. ## MDX And Components @@ -99,9 +110,9 @@ local Content app, Agent Native Desktop, or another trusted local bridge. ## Boundaries -- Moving, renaming, and reordering local-file pages are not first-class Content - UI operations yet. Use normal file operations when the user asks for those, - then let Content rediscover the file tree. +- A database page and its source file can change independently. Use folder + check/pull/push and resolve reported conflicts rather than silently overwriting + either revision. - Do not push/pull Notion, Builder.io, or other provider-backed content unless the user explicitly asks for provider sync. - Do not paste secrets, private provider data, or credential-looking values into diff --git a/packages/core/src/cli/skills.spec.ts b/packages/core/src/cli/skills.spec.ts index 6495220d58..50d9634bf0 100644 --- a/packages/core/src/cli/skills.spec.ts +++ b/packages/core/src/cli/skills.spec.ts @@ -401,13 +401,12 @@ describe("agent-native skills", () => { expect(result.localManifestPath).toBe(manifestPath); expect(result.commands).toContain(`write ${manifestPath}`); expect(fs.readFileSync(path.join(skillDir, "SKILL.md"), "utf-8")).toContain( - "Default storage for this installation: Content Local File Mode.", + "Default storage for this installation is Content's SQL database.", ); expect(manifest).toMatchObject({ version: 1, apps: { content: { - mode: "local-files", components: "components", extensions: "extensions", }, @@ -419,6 +418,14 @@ describe("agent-native skills", () => { "content", "resources", ]); + for (const root of manifest.apps.content.roots) { + expect(root.source).toMatchObject({ + type: "local-folder", + truthPolicy: "source_primary", + }); + expect(root.source.connectionId).toMatch(/^local-folder:/); + } + expect(manifest.apps.content.mode).toBeUndefined(); expect(manifest.mode).toBeUndefined(); }); @@ -471,13 +478,20 @@ describe("agent-native skills", () => { fs.readFileSync(path.join(root, "agent-native.json"), "utf-8"), ); expect(manifest.apps.content.roots).toEqual([ - { + expect.objectContaining({ name: "Knowledge", path: "knowledge", kind: "docs", extensions: [".mdx"], - }, + source: expect.objectContaining({ + type: "local-folder", + truthPolicy: "source_primary", + }), + }), ]); + expect(manifest.apps.content.roots[0].source.connectionId).toMatch( + /^local-folder:/, + ); expect(manifest.apps.content.components).toEqual(["blocks"]); expect(manifest.apps.content.extensions).toBe("extensions"); expect(manifest.mode).toBe("workspace"); @@ -522,7 +536,8 @@ describe("agent-native skills", () => { expect.arrayContaining(["--mode", "local-files"]), ); expect(manifest.mode).toBeUndefined(); - expect(manifest.apps.content.mode).toBe("local-files"); + expect(manifest.apps.content.mode).toBeUndefined(); + expect(manifest.apps.content.roots[0].source.type).toBe("local-folder"); }); it("accepts Content app-skill manifests with local-files mode", async () => { @@ -553,7 +568,8 @@ describe("agent-native skills", () => { expect(result.mcpUrl).toBe(""); expect(result.mcpClients).toEqual([]); expect(manifest.mode).toBeUndefined(); - expect(manifest.apps.content.mode).toBe("local-files"); + expect(manifest.apps.content.mode).toBeUndefined(); + expect(manifest.apps.content.roots[0].source.type).toBe("local-folder"); }); it("accepts design-exploration aliases for the built-in Design skill", async () => { diff --git a/packages/core/src/cli/skills.ts b/packages/core/src/cli/skills.ts index 054d9b4753..eb6ffa7337 100644 --- a/packages/core/src/cli/skills.ts +++ b/packages/core/src/cli/skills.ts @@ -119,7 +119,7 @@ export const BUILT_IN_APP_SKILLS = { id: "content", displayName: "Content", description: - "Edit docs, blogs, resources, and MDX content through the Content app, including repo-backed Local File Mode.", + "Edit docs, blogs, resources, and MDX content through the Content app, including database-backed local-folder sources.", hosted: { url: "https://content.agent-native.com", mcpUrl: "https://content.agent-native.com/mcp", @@ -128,7 +128,7 @@ export const BUILT_IN_APP_SKILLS = { auth: { mode: "oauth", setup: - "Authenticate with the Content MCP connector in the host app. Local File Mode requires a local Content app, Agent Native Desktop, or trusted local bridge for filesystem access.", + "Authenticate with the Content MCP connector in the host app. Local-folder synchronization requires a local Content app, Agent Native Desktop, or trusted local bridge for filesystem access.", }, surfaces: [ { @@ -941,15 +941,15 @@ function contentModeInstructionBlock(input: { if (input.mode === "local-files") { return `## Installed Mode -Default storage for this installation: Content Local File Mode. This repo should -have an \`agent-native.json\` file with \`apps.content.mode: "local-files"\`; -the installer writes one if missing and fills in default roots for \`docs/\`, -\`blog/\`, \`content/\`, and \`resources/\`. Prefer Content document actions -when a local Content app, -Agent Native Desktop, or another trusted local bridge exposes them. If those -tools are not currently available, edit the configured Markdown/MDX files and -local components directly, preserving frontmatter, imports, JSX, and unknown MDX -syntax. The hosted Content app cannot read private repo files by itself.`; +Default storage for this installation is Content's SQL database. This repo's +\`agent-native.json\` declares \`docs/\`, \`blog/\`, \`content/\`, and +\`resources/\` as local-folder sources with opaque connection ids; it does not +select a separate application mode. A trusted local Content app or Agent Native +Desktop bridge imports those files into their workspace's canonical Files +database, after which normal Content document actions read and edit the SQL-backed +pages. Use \`sync-manifest-local-folder-source\` with each root's generated +connection id, or launch \`agent-native content local-files \`, to connect +and pull it. The hosted Content app cannot read private repo files by itself.`; } if (input.mode === "self-hosted") { return `## Installed Mode @@ -1139,8 +1139,23 @@ function shouldWriteContentLocalFilesManifest( return targetId === "content" && mode === "local-files"; } +function contentLocalFolderConnectionId(baseDir: string, rootPath: string) { + const absoluteRootPath = path.resolve(baseDir, rootPath); + let canonicalRootPath = absoluteRootPath; + try { + canonicalRootPath = fs.realpathSync(absoluteRootPath); + } catch (error: any) { + if (error?.code !== "ENOENT") throw error; + } + return `local-folder:${createHash("sha256") + .update(canonicalRootPath) + .digest("base64url") + .slice(0, 24)}`; +} + function mergeContentLocalFilesManifest( existing: unknown, + baseDir: string, ): Record { const manifest = isJsonRecord(existing) ? { ...existing } : {}; if (manifest.version === undefined) manifest.version = 1; @@ -1148,10 +1163,29 @@ function mergeContentLocalFilesManifest( const apps = isJsonRecord(manifest.apps) ? { ...manifest.apps } : {}; const contentApp = isJsonRecord(apps.content) ? { ...apps.content } : {}; const defaults = defaultContentLocalFilesAppConfig(); - contentApp.mode = "local-files"; if (!Array.isArray(contentApp.roots) || contentApp.roots.length === 0) { contentApp.roots = defaults.roots; } + contentApp.roots = contentApp.roots.map((root: unknown) => { + if (!isJsonRecord(root) || typeof root.path !== "string") return root; + const source = isJsonRecord(root.source) ? root.source : {}; + return { + ...root, + source: { + ...source, + type: "local-folder", + connectionId: + typeof source.connectionId === "string" && source.connectionId + ? source.connectionId + : contentLocalFolderConnectionId(baseDir, root.path), + truthPolicy: + typeof source.truthPolicy === "string" + ? source.truthPolicy + : "source_primary", + }, + }; + }); + delete contentApp.mode; if (contentApp.components === undefined) { contentApp.components = defaults.components; } @@ -1181,7 +1215,7 @@ function writeContentLocalFilesManifest( ); } } - const manifest = mergeContentLocalFilesManifest(existing); + const manifest = mergeContentLocalFilesManifest(existing, baseDir); if (!options.dryRun) { fs.writeFileSync( manifestPath, diff --git a/templates/content/actions/_content-files.ts b/templates/content/actions/_content-files.ts index 0d9841ad2e..59decb0cd7 100644 --- a/templates/content/actions/_content-files.ts +++ b/templates/content/actions/_content-files.ts @@ -153,6 +153,7 @@ export async function ensureDocumentFilesMembership( db: Db, documentId: string, now = new Date().toISOString(), + accessContext?: { userEmail?: string; orgId?: string }, ) { const [document] = await db .select() @@ -160,7 +161,7 @@ export async function ensureDocumentFilesMembership( .where( and( eq(schema.documents.id, documentId), - accessFilter(schema.documents, schema.documentShares), + accessFilter(schema.documents, schema.documentShares, accessContext), ), ); if (!document) throw new Error(`Document "${documentId}" not found`); diff --git a/templates/content/actions/_content-spaces.ts b/templates/content/actions/_content-spaces.ts index 07ad66c6f2..eba1e62def 100644 --- a/templates/content/actions/_content-spaces.ts +++ b/templates/content/actions/_content-spaces.ts @@ -335,10 +335,17 @@ export async function provisionContentSpaces( }) .onConflictDoNothing(); result.created.spaces += 1; - } else if (existingSpace.filesDatabaseId !== files.id) { + } else if ( + existingSpace.name !== space.name || + existingSpace.filesDatabaseId !== files.id + ) { await tx .update(schema.contentSpaces) - .set({ filesDatabaseId: files.id, updatedAt: now }) + .set({ + name: space.name, + filesDatabaseId: files.id, + updatedAt: now, + }) .where(eq(schema.contentSpaces.id, space.id)); } } @@ -369,6 +376,16 @@ export async function provisionContentSpaces( }, result.created, ); + await tx + .update(schema.documents) + .set({ title: space.name, updatedAt: now }) + .where( + and( + eq(schema.documents.id, referenceDocumentId), + eq(schema.documents.ownerEmail, email), + sql`${schema.documents.title} <> ${space.name}`, + ), + ); const catalogItemId = await ensureDatabaseItem({ db: tx, databaseId: catalog.id, diff --git a/templates/content/actions/add-database-item.ts b/templates/content/actions/add-database-item.ts index 2da133cb92..12d2f6bfe2 100644 --- a/templates/content/actions/add-database-item.ts +++ b/templates/content/actions/add-database-item.ts @@ -60,6 +60,9 @@ export default defineAction({ const now = new Date().toISOString(); const databaseSpaceId = database.spaceId ?? (databaseDocument.spaceId as string | null); + if (!databaseSpaceId) { + throw new Error("Database does not belong to a Content space."); + } if (databaseSpaceId && (!database.spaceId || !databaseDocument.spaceId)) { await db.transaction(async (tx) => { if (!database.spaceId) { @@ -197,9 +200,7 @@ export default defineAction({ .insert(schema.documentPropertyValues) .values(propertyValueRows); } - if (databaseSpaceId) { - await ensureDocumentFilesMembership(tx, documentId, now); - } + await ensureDocumentFilesMembership(tx, documentId, now); }); }), ); diff --git a/templates/content/actions/content-spaces.db.test.ts b/templates/content/actions/content-spaces.db.test.ts index 6ff58bf00b..ae8df47543 100644 --- a/templates/content/actions/content-spaces.db.test.ts +++ b/templates/content/actions/content-spaces.db.test.ts @@ -222,6 +222,54 @@ describe("Content space provisioning", () => { ]); }); + it("propagates organization renames to the space and workspace reference", async () => { + const orgId = "org-renamed"; + const spaceId = organizationContentSpaceId(orgId); + await addOrganization(orgId, "Before rename"); + await addMember("owner-renamed", orgId, OWNER, "owner"); + const provisioned = await runWithRequestContext({ userEmail: OWNER }, () => + provisionContentSpaces(getDb(), OWNER), + ); + + await getDbExec().execute({ + sql: "UPDATE organizations SET name = ? WHERE id = ?", + args: ["After rename", orgId], + }); + const rerun = await runWithRequestContext({ userEmail: OWNER }, () => + provisionContentSpaces(getDb(), OWNER), + ); + expect(rerun.created).toEqual({ + spaces: 0, + databases: 0, + documents: 0, + catalogItems: 0, + }); + + const [space] = await getDb() + .select() + .from(schema.contentSpaces) + .where(eq(schema.contentSpaces.id, spaceId)); + expect(space?.name).toBe("After rename"); + + const [reference] = await getDb() + .select({ title: schema.documents.title }) + .from(schema.contentSpaceCatalogItems) + .innerJoin( + schema.documents, + eq(schema.documents.id, schema.contentSpaceCatalogItems.documentId), + ) + .where( + and( + eq( + schema.contentSpaceCatalogItems.catalogDatabaseId, + provisioned.catalogDatabaseId, + ), + eq(schema.contentSpaceCatalogItems.spaceId, spaceId), + ), + ); + expect(reference?.title).toBe("After rename"); + }); + it("does not create or seed organization resources from a viewer session", async () => { const orgId = "org-viewer-provisioning"; const spaceId = organizationContentSpaceId(orgId); diff --git a/templates/content/actions/create-content-database.ts b/templates/content/actions/create-content-database.ts index 214354cd2b..69ac589edd 100644 --- a/templates/content/actions/create-content-database.ts +++ b/templates/content/actions/create-content-database.ts @@ -204,7 +204,10 @@ export async function createContentDatabaseRecord( .set({ spaceId, updatedAt: now }) .where(eq(schema.contentDatabases.id, existing.id)); } - await ensureDocumentFilesMembership(db, documentId, now); + await ensureDocumentFilesMembership(db, documentId, now, { + userEmail: getRequestUserEmail(), + orgId: orgId ?? undefined, + }); return existing.id; } @@ -333,7 +336,10 @@ export async function createContentDatabaseRecord( // Every database is seeded with one primary "Content" Blocks field, backed // by `documents.content`, so each row's body is a first-class property. await seedDefaultBlocksField({ databaseId, ownerEmail, orgId, now, db }); - await ensureDocumentFilesMembership(db, documentId, now); + await ensureDocumentFilesMembership(db, documentId, now, { + userEmail: getRequestUserEmail(), + orgId: orgId ?? undefined, + }); return databaseId; } diff --git a/templates/content/actions/create-document.ts b/templates/content/actions/create-document.ts index 1abab1c7ff..939d061cc9 100644 --- a/templates/content/actions/create-document.ts +++ b/templates/content/actions/create-document.ts @@ -189,7 +189,10 @@ export default defineAction({ })), ); } - await ensureDocumentFilesMembership(tx, id, now); + await ensureDocumentFilesMembership(tx, id, now, { + userEmail: currentUserEmail, + orgId: orgId ?? undefined, + }); }); }, ); diff --git a/templates/content/actions/local-folder-source.db.test.ts b/templates/content/actions/local-folder-source.db.test.ts index 6c67fbe475..c03aea261a 100644 --- a/templates/content/actions/local-folder-source.db.test.ts +++ b/templates/content/actions/local-folder-source.db.test.ts @@ -472,6 +472,128 @@ describe("local-folder Content source", () => { } }); + it("rejects a malformed linked row that points into another Content space", async () => { + const target = await runWithRequestContext({ userEmail: OWNER }, () => + connectLocalFolder.run({ + connectionId: "desktop-folder-linked-cross-space-target", + label: "Linked cross-space target", + createSourceBackedSpace: true, + truthPolicy: "source_primary", + }), + ); + const other = await runWithRequestContext({ userEmail: OWNER }, () => + connectLocalFolder.run({ + connectionId: "desktop-folder-linked-cross-space-other", + label: "Linked cross-space other", + createSourceBackedSpace: true, + truthPolicy: "source_primary", + }), + ); + const now = new Date().toISOString(); + await getDb().insert(schema.documents).values({ + id: "malformed-linked-other-space-page", + spaceId: other.spaceId, + ownerEmail: OWNER, + orgId: null, + visibility: "private", + title: "Other space page", + content: "Must remain untouched.", + createdAt: now, + updatedAt: now, + }); + await getDb() + .insert(schema.contentDatabaseSourceRows) + .values({ + id: "malformed-linked-cross-space-row", + ownerEmail: OWNER, + sourceId: target.sourceId, + databaseItemId: "malformed-linked-cross-space-item", + documentId: "malformed-linked-other-space-page", + sourceRowId: "linked.md", + sourceQualifiedId: "local-folder://example/linked.md", + sourceDisplayKey: "linked.md", + sourceValuesJson: JSON.stringify({ relativePath: "linked.md" }), + provenance: "test corruption", + syncState: "linked", + freshness: "fresh", + createdAt: now, + updatedAt: now, + }); + + await expect( + runWithRequestContext({ userEmail: OWNER }, () => + syncLocalFolder.run({ + sourceId: target.sourceId, + files: { "linked.md": "Incoming body." }, + }), + ), + ).rejects.toThrow("belongs to another Content space"); + await expect( + getDb() + .select() + .from(schema.documents) + .where(eq(schema.documents.id, "malformed-linked-other-space-page")), + ).resolves.toEqual([ + expect.objectContaining({ + spaceId: other.spaceId, + content: "Must remain untouched.", + }), + ]); + }); + + it("aborts sync if the source is disconnected after planning", async () => { + const connection = await runWithRequestContext({ userEmail: OWNER }, () => + connectLocalFolder.run({ + connectionId: "desktop-folder-sync-disconnect-race", + label: "Sync disconnect race", + createSourceBackedSpace: true, + truthPolicy: "source_primary", + }), + ); + const db = getDb(); + const originalTransaction = db.transaction; + let disconnected = false; + db.transaction = async function (...args: any[]) { + if (!disconnected) { + disconnected = true; + await db + .delete(schema.contentDatabaseSources) + .where(eq(schema.contentDatabaseSources.id, connection.sourceId)); + } + return originalTransaction.apply(this, args); + }; + + try { + await expect( + runWithRequestContext({ userEmail: OWNER }, () => + syncLocalFolder.run({ + sourceId: connection.sourceId, + files: { + "race.md": "---\nid: sync-after-disconnect-page\n---\nBody.", + }, + }), + ), + ).rejects.toThrow("was disconnected before sync"); + expect(disconnected).toBe(true); + await expect( + db + .select() + .from(schema.documents) + .where(eq(schema.documents.id, "sync-after-disconnect-page")), + ).resolves.toHaveLength(0); + await expect( + db + .select() + .from(schema.contentDatabaseSourceRows) + .where( + eq(schema.contentDatabaseSourceRows.sourceId, connection.sourceId), + ), + ).resolves.toHaveLength(0); + } finally { + db.transaction = originalTransaction; + } + }); + it("refuses to accept a staged folder revision after Content changes again", async () => { const connection = await runWithRequestContext({ userEmail: OWNER }, () => connectLocalFolder.run({ @@ -564,6 +686,197 @@ describe("local-folder Content source", () => { } finally { db.transaction = originalTransaction; } + + await runWithRequestContext({ userEmail: OWNER }, () => + syncLocalFolder.run({ + sourceId: connection.sourceId, + files: { "resolve-race.md": "Folder body under review." }, + }), + ); + const proposed = await getDb() + .select() + .from(schema.contentDatabaseSourceChangeSets) + .where( + and( + eq( + schema.contentDatabaseSourceChangeSets.sourceId, + connection.sourceId, + ), + eq(schema.contentDatabaseSourceChangeSets.documentId, documentId), + eq(schema.contentDatabaseSourceChangeSets.state, "proposed"), + ), + ); + expect(proposed).toHaveLength(2); + const refreshed = proposed.find( + (candidate) => candidate.id !== changeSet.id, + ); + expect(refreshed).toBeDefined(); + await runWithRequestContext({ userEmail: OWNER }, () => + resolveLocalFolderConflict.run({ + changeSetId: refreshed!.id, + decision: "accept_source", + sourceContent: "Folder body under review.", + }), + ); + await expect( + getDb() + .select() + .from(schema.documents) + .where(eq(schema.documents.id, documentId)), + ).resolves.toEqual([ + expect.objectContaining({ content: "Folder body under review." }), + ]); + }); + + it("aborts resolution if the source is disconnected after review", async () => { + const connection = await runWithRequestContext({ userEmail: OWNER }, () => + connectLocalFolder.run({ + connectionId: "desktop-folder-resolve-disconnect-race", + label: "Resolve disconnect race", + createSourceBackedSpace: true, + truthPolicy: "source_primary", + }), + ); + const first = await runWithRequestContext({ userEmail: OWNER }, () => + syncLocalFolder.run({ + sourceId: connection.sourceId, + files: { "resolve.md": "Baseline." }, + }), + ); + const documentId = first.created[0]!.id; + await getDb() + .update(schema.documents) + .set({ content: "Content edit.", updatedAt: new Date().toISOString() }) + .where(eq(schema.documents.id, documentId)); + await runWithRequestContext({ userEmail: OWNER }, () => + syncLocalFolder.run({ + sourceId: connection.sourceId, + files: { "resolve.md": "Folder edit." }, + }), + ); + const [changeSet] = await getDb() + .select() + .from(schema.contentDatabaseSourceChangeSets) + .where( + and( + eq( + schema.contentDatabaseSourceChangeSets.sourceId, + connection.sourceId, + ), + eq(schema.contentDatabaseSourceChangeSets.documentId, documentId), + ), + ); + const db = getDb(); + const originalTransaction = db.transaction; + let disconnected = false; + db.transaction = async function (...args: any[]) { + if (!disconnected) { + disconnected = true; + await db + .delete(schema.contentDatabaseSources) + .where(eq(schema.contentDatabaseSources.id, connection.sourceId)); + } + return originalTransaction.apply(this, args); + }; + + try { + await expect( + runWithRequestContext({ userEmail: OWNER }, () => + resolveLocalFolderConflict.run({ + changeSetId: changeSet.id, + decision: "accept_source", + sourceContent: "Folder edit.", + }), + ), + ).rejects.toThrow("was disconnected before resolution"); + expect(disconnected).toBe(true); + await expect( + db + .select() + .from(schema.documents) + .where(eq(schema.documents.id, documentId)), + ).resolves.toEqual([ + expect.objectContaining({ content: "Content edit." }), + ]); + } finally { + db.transaction = originalTransaction; + } + }); + + it("allows only one decision to claim an open folder conflict", async () => { + const connection = await runWithRequestContext({ userEmail: OWNER }, () => + connectLocalFolder.run({ + connectionId: "desktop-folder-resolve-decision-race", + label: "Resolve decision race", + createSourceBackedSpace: true, + truthPolicy: "source_primary", + }), + ); + const first = await runWithRequestContext({ userEmail: OWNER }, () => + syncLocalFolder.run({ + sourceId: connection.sourceId, + files: { "resolve.md": "Baseline." }, + }), + ); + const documentId = first.created[0]!.id; + await getDb() + .update(schema.documents) + .set({ content: "Content edit.", updatedAt: new Date().toISOString() }) + .where(eq(schema.documents.id, documentId)); + await runWithRequestContext({ userEmail: OWNER }, () => + syncLocalFolder.run({ + sourceId: connection.sourceId, + files: { "resolve.md": "Folder edit." }, + }), + ); + const [changeSet] = await getDb() + .select() + .from(schema.contentDatabaseSourceChangeSets) + .where( + and( + eq( + schema.contentDatabaseSourceChangeSets.sourceId, + connection.sourceId, + ), + eq(schema.contentDatabaseSourceChangeSets.documentId, documentId), + ), + ); + const db = getDb(); + const originalTransaction = db.transaction; + let decided = false; + db.transaction = async function (...args: any[]) { + if (!decided) { + decided = true; + await db + .update(schema.contentDatabaseSourceChangeSets) + .set({ state: "rejected", updatedAt: new Date().toISOString() }) + .where(eq(schema.contentDatabaseSourceChangeSets.id, changeSet.id)); + } + return originalTransaction.apply(this, args); + }; + + try { + await expect( + runWithRequestContext({ userEmail: OWNER }, () => + resolveLocalFolderConflict.run({ + changeSetId: changeSet.id, + decision: "accept_source", + sourceContent: "Folder edit.", + }), + ), + ).rejects.toThrow("changed before resolution"); + expect(decided).toBe(true); + await expect( + db + .select() + .from(schema.documents) + .where(eq(schema.documents.id, documentId)), + ).resolves.toEqual([ + expect.objectContaining({ content: "Content edit." }), + ]); + } finally { + db.transaction = originalTransaction; + } }); it("tracks stable-id renames and reviews source deletions without deleting the global page", async () => { diff --git a/templates/content/actions/resolve-local-folder-conflict.ts b/templates/content/actions/resolve-local-folder-conflict.ts index 7e5ba1b13c..74c0e80d72 100644 --- a/templates/content/actions/resolve-local-folder-conflict.ts +++ b/templates/content/actions/resolve-local-folder-conflict.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import { defineAction } from "@agent-native/core"; import { writeAppState } from "@agent-native/core/application-state"; +import { assertAccess } from "@agent-native/core/sharing"; import { and, eq, ne } from "drizzle-orm"; import { z } from "zod"; @@ -113,11 +114,16 @@ export default defineAction({ target.source.sourceType !== LOCAL_FOLDER_SOURCE_TYPE || target.changeSet.direction !== "incoming" || target.changeSet.state !== "proposed" || - !target.database.spaceId + !target.database.spaceId || + target.database.systemRole !== "files" || + target.database.deletedAt || + target.document.spaceId !== target.database.spaceId ) { throw new Error(`Open local-folder conflict "${changeSetId}" not found`); } - await resolveContentSpaceAccess(target.database.spaceId, "editor"); + const targetSpaceId = target.database.spaceId; + await resolveContentSpaceAccess(targetSpaceId, "editor"); + await assertAccess("document", target.document.id, "editor"); const bodyChange = parseObject(target.changeSet.bodyChangeJson); const sourceDeletion = bodyChange.operation === "source_delete"; const proposedHash = @@ -164,6 +170,47 @@ export default defineAction({ const now = new Date().toISOString(); await db.transaction(async (tx: any) => { + const claimedChangeSets = await tx + .update(schema.contentDatabaseSourceChangeSets) + .set({ + state: decision === "accept_source" ? "applied" : "rejected", + updatedAt: now, + }) + .where( + and( + eq(schema.contentDatabaseSourceChangeSets.id, changeSetId), + eq( + schema.contentDatabaseSourceChangeSets.sourceId, + target.source.id, + ), + eq(schema.contentDatabaseSourceChangeSets.state, "proposed"), + ), + ) + .returning({ id: schema.contentDatabaseSourceChangeSets.id }); + if (claimedChangeSets.length !== 1) { + throw new Error( + `Open local-folder conflict "${changeSetId}" changed before resolution`, + ); + } + const claimedSources = await tx + .update(schema.contentDatabaseSources) + .set({ updatedAt: now }) + .where( + and( + eq(schema.contentDatabaseSources.id, target.source.id), + eq(schema.contentDatabaseSources.databaseId, target.database.id), + eq( + schema.contentDatabaseSources.sourceType, + LOCAL_FOLDER_SOURCE_TYPE, + ), + ), + ) + .returning({ id: schema.contentDatabaseSources.id }); + if (claimedSources.length !== 1) { + throw new Error( + `Local folder source "${target.source.id}" was disconnected before resolution`, + ); + } if (decision === "accept_source" && sourceDeletion) { await tx .delete(schema.contentDatabaseSourceRows) @@ -233,12 +280,22 @@ export default defineAction({ updatedAt: now, }, ) - .where(eq(schema.documents.id, target.document.id)); + .where( + and( + eq(schema.documents.id, target.document.id), + eq(schema.documents.spaceId, targetSpaceId), + ), + ); } else if (decision === "accept_source") { const [currentDocument] = await tx .select() .from(schema.documents) - .where(eq(schema.documents.id, target.document.id)); + .where( + and( + eq(schema.documents.id, target.document.id), + eq(schema.documents.spaceId, targetSpaceId), + ), + ); if ( !currentDocument || hash(currentDocument.content) !== reviewedContentHash || @@ -337,13 +394,6 @@ export default defineAction({ .where(eq(schema.contentDatabaseSourceRows.id, sourceRow.id)); } } - await tx - .update(schema.contentDatabaseSourceChangeSets) - .set({ - state: decision === "accept_source" ? "applied" : "rejected", - updatedAt: now, - }) - .where(eq(schema.contentDatabaseSourceChangeSets.id, changeSetId)); const remaining = await tx .select({ id: schema.contentDatabaseSourceChangeSets.id }) .from(schema.contentDatabaseSourceChangeSets) @@ -357,7 +407,7 @@ export default defineAction({ eq(schema.contentDatabaseSourceChangeSets.state, "proposed"), ), ); - await tx + const refreshedSources = await tx .update(schema.contentDatabaseSources) .set({ syncState: remaining.length ? "error" : "linked", @@ -370,7 +420,13 @@ export default defineAction({ : "Content was kept; push the retained revision to the folder when ready.", updatedAt: now, }) - .where(eq(schema.contentDatabaseSources.id, target.source.id)); + .where(eq(schema.contentDatabaseSources.id, target.source.id)) + .returning({ id: schema.contentDatabaseSources.id }); + if (refreshedSources.length !== 1) { + throw new Error( + `Local folder source "${target.source.id}" was disconnected during resolution`, + ); + } }); await writeAppState("refresh-signal", { ts: Date.now() }); return { diff --git a/templates/content/actions/space-aware-writers.db.test.ts b/templates/content/actions/space-aware-writers.db.test.ts index 697a58a930..4cde92681f 100644 --- a/templates/content/actions/space-aware-writers.db.test.ts +++ b/templates/content/actions/space-aware-writers.db.test.ts @@ -184,6 +184,46 @@ describe("space-aware document writers", () => { ).rejects.toThrow("Not authorized"); }); + it("creates canonical Files memberships when the target organization differs from the active organization", async () => { + const activeOrgId = "org-active-writers"; + const targetOrgId = "org-target-writers"; + await addOrganizationMember({ + orgId: activeOrgId, + email: MEMBER, + role: "admin", + }); + await addOrganizationMember({ + orgId: targetOrgId, + email: MEMBER, + role: "admin", + }); + const targetSpaceId = organizationContentSpaceId(targetOrgId); + + const page = await runWithRequestContext( + { userEmail: MEMBER, orgId: activeOrgId }, + () => + createDocument.run({ + title: "Cross-organization page", + spaceId: targetSpaceId, + }), + ); + await expect(filesMemberships(page.id)).resolves.toEqual([ + expect.objectContaining({ spaceId: targetSpaceId }), + ]); + + const createdDatabase = await runWithRequestContext( + { userEmail: MEMBER, orgId: activeOrgId }, + () => + createContentDatabase.run({ + title: "Cross-organization database", + spaceId: targetSpaceId, + }), + ); + await expect( + filesMemberships(createdDatabase.database.documentId), + ).resolves.toEqual([expect.objectContaining({ spaceId: targetSpaceId })]); + }); + it("keeps databases and their rows in one space and repairs converted page membership", async () => { const page = await runWithRequestContext({ userEmail: OWNER }, () => createDocument.run({ title: "Convert me" }), @@ -221,4 +261,48 @@ describe("space-aware document writers", () => { filesMemberships(row.createdDocumentId!), ).resolves.toHaveLength(1); }); + + it("rejects database row creation when a legacy database has no Content space", async () => { + const now = new Date().toISOString(); + await getDb().insert(schema.documents).values({ + id: "legacy-unscoped-database-document", + ownerEmail: OWNER, + orgId: null, + spaceId: null, + parentId: null, + title: "Legacy unscoped database", + content: "", + position: 0, + visibility: "private", + createdAt: now, + updatedAt: now, + }); + await getDb().insert(schema.contentDatabases).values({ + id: "legacy-unscoped-database", + ownerEmail: OWNER, + orgId: null, + spaceId: null, + documentId: "legacy-unscoped-database-document", + title: "Legacy unscoped database", + createdAt: now, + updatedAt: now, + }); + + await expect( + runWithRequestContext({ userEmail: OWNER }, () => + addDatabaseItem.run({ + databaseId: "legacy-unscoped-database", + title: "Must not be created", + }), + ), + ).rejects.toThrow("does not belong to a Content space"); + await expect( + getDb() + .select() + .from(schema.documents) + .where( + eq(schema.documents.parentId, "legacy-unscoped-database-document"), + ), + ).resolves.toHaveLength(0); + }); }); diff --git a/templates/content/actions/sync-local-folder-source.ts b/templates/content/actions/sync-local-folder-source.ts index 95123b297b..f4db522dbc 100644 --- a/templates/content/actions/sync-local-folder-source.ts +++ b/templates/content/actions/sync-local-folder-source.ts @@ -236,10 +236,14 @@ export default defineAction({ const initialSnapshot = await loadSnapshot(db); - const validateExplicitIds = async ( + const validateDocumentIds = async ( snapshot: Awaited>, ) => { - for (const id of explicitIds) { + const documentIds = new Set([ + ...explicitIds, + ...snapshot.storedRows.map((row) => row.documentId), + ]); + for (const id of documentIds) { const existing = snapshot.documentById.get(id); if (!existing) continue; if (existing.spaceId !== targetSpaceId) { @@ -251,7 +255,7 @@ export default defineAction({ } }; - await validateExplicitIds(initialSnapshot); + await validateDocumentIds(initialSnapshot); const metadata = parseJson(target.source.metadataJson); const policy = truthPolicy(metadata.truthPolicy); @@ -394,8 +398,27 @@ export default defineAction({ if (!dryRun) { await db.transaction(async (tx: any) => { + const claimedSources = await tx + .update(schema.contentDatabaseSources) + .set({ updatedAt: now }) + .where( + and( + eq(schema.contentDatabaseSources.id, sourceId), + eq(schema.contentDatabaseSources.databaseId, target.database.id), + eq( + schema.contentDatabaseSources.sourceType, + LOCAL_FOLDER_SOURCE_TYPE, + ), + ), + ) + .returning({ id: schema.contentDatabaseSources.id }); + if (claimedSources.length !== 1) { + throw new Error( + `Local folder source "${sourceId}" was disconnected before sync`, + ); + } const transactionSnapshot = await loadSnapshot(tx); - await validateExplicitIds(transactionSnapshot); + await validateDocumentIds(transactionSnapshot); plans = buildPlans(transactionSnapshot); const currentDocumentIds = new Set(plans.map((plan) => plan.id)); missingRows = transactionSnapshot.storedRows.filter( @@ -406,7 +429,7 @@ export default defineAction({ if (plan.conflict) { const changeSetId = opaqueId( "content_source_change", - `${sourceId}:${plan.id}:${plan.incomingHash}:${plan.incomingMetadataHash}`, + `${sourceId}:${plan.id}:${plan.incomingHash}:${plan.incomingMetadataHash}:${plan.localHash}:${plan.localMetadataHash}`, ); await tx .insert(schema.contentDatabaseSourceChangeSets) @@ -661,7 +684,7 @@ export default defineAction({ }) .onConflictDoNothing(); } - await tx + const refreshedSources = await tx .update(schema.contentDatabaseSources) .set({ syncState: conflicts.length ? "error" : "linked", @@ -675,7 +698,13 @@ export default defineAction({ : null, updatedAt: now, }) - .where(eq(schema.contentDatabaseSources.id, sourceId)); + .where(eq(schema.contentDatabaseSources.id, sourceId)) + .returning({ id: schema.contentDatabaseSources.id }); + if (refreshedSources.length !== 1) { + throw new Error( + `Local folder source "${sourceId}" was disconnected during sync`, + ); + } }); await writeAppState("refresh-signal", { ts: Date.now() }); } diff --git a/templates/content/app/components/sidebar/DocumentSidebar.tsx b/templates/content/app/components/sidebar/DocumentSidebar.tsx index de9025efb9..06d99ea136 100644 --- a/templates/content/app/components/sidebar/DocumentSidebar.tsx +++ b/templates/content/app/components/sidebar/DocumentSidebar.tsx @@ -119,7 +119,10 @@ import { } from "./document-sidebar-sections"; import { DocumentSidebarIcon, DocumentTreeItem } from "./DocumentTreeItem"; import { NotionButton } from "./NotionButton"; -import { selectContentSpace } from "./select-content-space"; +import { + contentSpaceForActiveOrg, + selectContentSpace, +} from "./select-content-space"; function nanoid(size = 12): string { const chars = @@ -258,10 +261,16 @@ export function DocumentSidebar({ SELECTED_CONTENT_SPACE_STORAGE_KEY, null, ); - const selectedSpace = - contentSpaces.find((space) => space.id === storedSpaceId) ?? - contentSpaces[0] ?? - null; + const selectedSpace = contentSpaceForActiveOrg({ + spaces: contentSpaces, + storedSpaceId, + activeOrgId: activeOrg?.orgId, + }); + useEffect(() => { + if (selectedSpace && selectedSpace.id !== storedSpaceId) { + setStoredSpaceId(selectedSpace.id); + } + }, [selectedSpace, setStoredSpaceId, storedSpaceId]); const handleSelectContentSpace = useCallback( async (space: (typeof contentSpaces)[number]) => { try { diff --git a/templates/content/app/components/sidebar/select-content-space.test.ts b/templates/content/app/components/sidebar/select-content-space.test.ts index 24e2fc86b5..cd48c7e75d 100644 --- a/templates/content/app/components/sidebar/select-content-space.test.ts +++ b/templates/content/app/components/sidebar/select-content-space.test.ts @@ -2,7 +2,10 @@ import { describe, expect, it, vi } from "vitest"; import type { ContentSpaceSummary } from "@/hooks/use-content-spaces"; -import { selectContentSpace } from "./select-content-space"; +import { + contentSpaceForActiveOrg, + selectContentSpace, +} from "./select-content-space"; function space( overrides: Partial = {}, @@ -101,3 +104,50 @@ describe("selectContentSpace", () => { expect(persistSelection).toHaveBeenCalledWith("space_1"); }); }); + +describe("contentSpaceForActiveOrg", () => { + it("keeps the stored workspace when its organization is active", () => { + const selected = space({ id: "space_2", orgId: "org_1" }); + expect( + contentSpaceForActiveOrg({ + spaces: [space(), selected], + storedSpaceId: selected.id, + activeOrgId: "org_1", + }), + ).toBe(selected); + }); + + it("reconciles an independently switched organization before querying Files", () => { + const oldSpace = space({ id: "old", orgId: "org_1" }); + const newSpace = space({ id: "new", orgId: "org_2" }); + expect( + contentSpaceForActiveOrg({ + spaces: [oldSpace, newSpace], + storedSpaceId: oldSpace.id, + activeOrgId: "org_2", + }), + ).toBe(newSpace); + }); + + it("waits for active organization context instead of querying a stale Files database", () => { + expect( + contentSpaceForActiveOrg({ + spaces: [space()], + storedSpaceId: "space_1", + activeOrgId: undefined, + }), + ).toBeNull(); + }); + + it("prefers the personal workspace when switching out of an organization", () => { + const folder = space({ id: "folder", kind: "source", orgId: null }); + const personal = space({ id: "personal", kind: "personal", orgId: null }); + expect( + contentSpaceForActiveOrg({ + spaces: [folder, personal], + storedSpaceId: "missing", + activeOrgId: null, + }), + ).toBe(personal); + }); +}); diff --git a/templates/content/app/components/sidebar/select-content-space.ts b/templates/content/app/components/sidebar/select-content-space.ts index bad69be3d3..6db7ca75db 100644 --- a/templates/content/app/components/sidebar/select-content-space.ts +++ b/templates/content/app/components/sidebar/select-content-space.ts @@ -1,5 +1,23 @@ import type { ContentSpaceSummary } from "@/hooks/use-content-spaces"; +export function contentSpaceForActiveOrg(args: { + spaces: ContentSpaceSummary[]; + storedSpaceId: string | null; + activeOrgId: string | null | undefined; +}) { + if (args.activeOrgId === undefined) return null; + const stored = args.spaces.find((space) => space.id === args.storedSpaceId); + if (stored?.orgId === args.activeOrgId) return stored; + const matching = args.spaces.filter( + (space) => space.orgId === args.activeOrgId, + ); + return args.activeOrgId === null + ? (matching.find((space) => space.kind === "personal") ?? + matching[0] ?? + null) + : (matching[0] ?? null); +} + export async function selectContentSpace(args: { space: ContentSpaceSummary; activeOrgId: string | null | undefined; From a7ff10bac207ff8c6d819a52ff907a3ecd5a8bb4 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:45:07 -0400 Subject: [PATCH 07/76] fix(core): keep Content Cloudflare build deployable --- .changeset/tidy-clouds-build.md | 5 +++++ packages/core/src/vite/client.spec.ts | 2 ++ packages/core/src/vite/client.ts | 2 ++ 3 files changed, 9 insertions(+) create mode 100644 .changeset/tidy-clouds-build.md diff --git a/.changeset/tidy-clouds-build.md b/.changeset/tidy-clouds-build.md new file mode 100644 index 0000000000..d4c8fe4a0d --- /dev/null +++ b/.changeset/tidy-clouds-build.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Keep Cloudflare template builds deployable when browser-only editor and chat packages import input-rule and message-part runtime helpers. diff --git a/packages/core/src/vite/client.spec.ts b/packages/core/src/vite/client.spec.ts index 8ad2d507bb..b31714bc5c 100644 --- a/packages/core/src/vite/client.spec.ts +++ b/packages/core/src/vite/client.spec.ts @@ -1094,7 +1094,9 @@ describe("Vite SSR stubs", () => { expect(code).toContain("export const EditorContent = stub;"); expect(code).toContain("export const createNodeFromContent = stub;"); expect(code).toContain("export const format = stub;"); + expect(code).toContain("export const InputRule = stub;"); expect(code).toContain("export const useMessagePartReasoning = stub;"); + expect(code).toContain("export const useMessagePartRuntime = stub;"); }); }); diff --git a/packages/core/src/vite/client.ts b/packages/core/src/vite/client.ts index 18756dfef9..03be4c62f5 100644 --- a/packages/core/src/vite/client.ts +++ b/packages/core/src/vite/client.ts @@ -1969,6 +1969,7 @@ function ssrStubPlugin(packages: string[]): Plugin | null { "FitAddon", "Fragment", "Image", + "InputRule", "Link", "Map", "Markdown", @@ -2022,6 +2023,7 @@ function ssrStubPlugin(packages: string[]): Plugin | null { "useEditor", "useLocalRuntime", "useMessagePartReasoning", + "useMessagePartRuntime", "useMessagePartText", "useMessageRuntime", "useThread", From 0c081e4a0ca71067907a3acb4498015f95b09295 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:45:44 -0400 Subject: [PATCH 08/76] chore: drop consumed workspace changeset --- .changeset/calm-files-share.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/calm-files-share.md diff --git a/.changeset/calm-files-share.md b/.changeset/calm-files-share.md deleted file mode 100644 index 65be1757e7..0000000000 --- a/.changeset/calm-files-share.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@agent-native/core": minor ---- - -Let Content connect manifest-declared local folders to its normal database-backed workspace without enabling a separate local-file data mode. From 87360744398c1d4b171697a1c1e1592315786573 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 17 Jul 2026 08:24:44 -0400 Subject: [PATCH 09/76] fix(content): keep workspace setup from showing empty --- .../components/sidebar/DocumentSidebar.tsx | 37 +++++++++++-- .../sidebar/select-content-space.test.ts | 52 +++++++++++++++++++ .../sidebar/select-content-space.ts | 26 ++++++++++ .../content/app/hooks/use-content-spaces.ts | 4 +- ...tay-in-a-loading-state-until-automatic-.md | 6 +++ 5 files changed, 119 insertions(+), 6 deletions(-) create mode 100644 templates/content/changelog/2026-07-17-workspace-files-now-stay-in-a-loading-state-until-automatic-.md diff --git a/templates/content/app/components/sidebar/DocumentSidebar.tsx b/templates/content/app/components/sidebar/DocumentSidebar.tsx index 06d99ea136..37ab1bb320 100644 --- a/templates/content/app/components/sidebar/DocumentSidebar.tsx +++ b/templates/content/app/components/sidebar/DocumentSidebar.tsx @@ -120,6 +120,7 @@ import { import { DocumentSidebarIcon, DocumentTreeItem } from "./DocumentTreeItem"; import { NotionButton } from "./NotionButton"; import { + contentSpaceAvailability, contentSpaceForActiveOrg, selectContentSpace, } from "./select-content-space"; @@ -239,7 +240,8 @@ export function DocumentSidebar({ const updateDocument = useUpdateDocument(); const contentSpacesQuery = useContentSpaces(); const ensureContentSpaces = useEnsureContentSpaces(); - const { data: activeOrg } = useOrg(); + const activeOrgQuery = useOrg(); + const activeOrg = activeOrgQuery.data; const switchOrg = useSwitchOrg(); const contentSpaces = contentSpacesQuery.data?.spaces ?? []; const spaceProvisionAttemptedRef = useRef(false); @@ -266,6 +268,25 @@ export function DocumentSidebar({ storedSpaceId, activeOrgId: activeOrg?.orgId, }); + const contentSpaceState = contentSpaceAvailability({ + hasSelectedSpace: Boolean(selectedSpace), + contentSpacesLoading: contentSpacesQuery.isLoading, + contentSpacesFetching: contentSpacesQuery.isFetching, + contentSpacesError: contentSpacesQuery.isError, + activeOrganizationResolved: activeOrgQuery.isSuccess, + provisioningAttempted: spaceProvisionAttemptedRef.current, + provisioningPending: ensureContentSpaces.isPending, + provisioningError: ensureContentSpaces.isError, + }); + const handleRetryContentSpaces = useCallback(() => { + if (contentSpacesQuery.isError) { + spaceProvisionAttemptedRef.current = false; + void contentSpacesQuery.refetch(); + return; + } + spaceProvisionAttemptedRef.current = true; + ensureContentSpaces.mutate({}); + }, [contentSpacesQuery, ensureContentSpaces]); useEffect(() => { if (selectedSpace && selectedSpace.id !== storedSpaceId) { setStoredSpaceId(selectedSpace.id); @@ -1096,7 +1117,7 @@ export function DocumentSidebar({
{t("sidebar.files")}
- {selectedSpace ? ( + {contentSpaceState === "ready" && selectedSpace ? ( <> {renderNewButton()} - ) : ( + ) : contentSpaceState === "loading" ? (
- {t("sidebar.noWorkspaces")} + {t("sidebar.loadingFiles")}
+ ) : ( + )} ); diff --git a/templates/content/app/components/sidebar/select-content-space.test.ts b/templates/content/app/components/sidebar/select-content-space.test.ts index cd48c7e75d..8a9bd16319 100644 --- a/templates/content/app/components/sidebar/select-content-space.test.ts +++ b/templates/content/app/components/sidebar/select-content-space.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import type { ContentSpaceSummary } from "@/hooks/use-content-spaces"; import { + contentSpaceAvailability, contentSpaceForActiveOrg, selectContentSpace, } from "./select-content-space"; @@ -151,3 +152,54 @@ describe("contentSpaceForActiveOrg", () => { ).toBe(personal); }); }); + +describe("contentSpaceAvailability", () => { + const settledMissingSpace = { + hasSelectedSpace: false, + contentSpacesLoading: false, + contentSpacesFetching: false, + contentSpacesError: false, + activeOrganizationResolved: true, + provisioningAttempted: true, + provisioningPending: false, + provisioningError: false, + }; + + it("keeps the Files sidebar loading before automatic provisioning starts", () => { + expect( + contentSpaceAvailability({ + ...settledMissingSpace, + provisioningAttempted: false, + }), + ).toBe("loading"); + }); + + it("keeps loading until the post-provision list refetch settles", () => { + expect( + contentSpaceAvailability({ + ...settledMissingSpace, + contentSpacesFetching: true, + }), + ).toBe("loading"); + }); + + it("surfaces provisioning failures instead of claiming there are no workspaces", () => { + expect( + contentSpaceAvailability({ + ...settledMissingSpace, + provisioningError: true, + }), + ).toBe("error"); + expect(contentSpaceAvailability(settledMissingSpace)).toBe("error"); + }); + + it("renders Files as soon as the active workspace is available", () => { + expect( + contentSpaceAvailability({ + ...settledMissingSpace, + hasSelectedSpace: true, + provisioningError: true, + }), + ).toBe("ready"); + }); +}); diff --git a/templates/content/app/components/sidebar/select-content-space.ts b/templates/content/app/components/sidebar/select-content-space.ts index 6db7ca75db..4a5afbb65c 100644 --- a/templates/content/app/components/sidebar/select-content-space.ts +++ b/templates/content/app/components/sidebar/select-content-space.ts @@ -1,5 +1,31 @@ import type { ContentSpaceSummary } from "@/hooks/use-content-spaces"; +export type ContentSpaceAvailability = "loading" | "ready" | "error"; + +export function contentSpaceAvailability(args: { + hasSelectedSpace: boolean; + contentSpacesLoading: boolean; + contentSpacesFetching: boolean; + contentSpacesError: boolean; + activeOrganizationResolved: boolean; + provisioningAttempted: boolean; + provisioningPending: boolean; + provisioningError: boolean; +}): ContentSpaceAvailability { + if (args.hasSelectedSpace) return "ready"; + if (args.contentSpacesError || args.provisioningError) return "error"; + if ( + args.contentSpacesLoading || + args.contentSpacesFetching || + !args.activeOrganizationResolved || + !args.provisioningAttempted || + args.provisioningPending + ) { + return "loading"; + } + return "error"; +} + export function contentSpaceForActiveOrg(args: { spaces: ContentSpaceSummary[]; storedSpaceId: string | null; diff --git a/templates/content/app/hooks/use-content-spaces.ts b/templates/content/app/hooks/use-content-spaces.ts index aae758bb82..ee0b52e69c 100644 --- a/templates/content/app/hooks/use-content-spaces.ts +++ b/templates/content/app/hooks/use-content-spaces.ts @@ -30,8 +30,8 @@ export function useContentSpaces() { export function useEnsureContentSpaces() { const queryClient = useQueryClient(); return useActionMutation("ensure-content-spaces", { - onSuccess: () => { - queryClient.invalidateQueries({ + onSuccess: async () => { + await queryClient.refetchQueries({ queryKey: ["action", "list-content-spaces"], }); }, diff --git a/templates/content/changelog/2026-07-17-workspace-files-now-stay-in-a-loading-state-until-automatic-.md b/templates/content/changelog/2026-07-17-workspace-files-now-stay-in-a-loading-state-until-automatic-.md new file mode 100644 index 0000000000..50ead7651f --- /dev/null +++ b/templates/content/changelog/2026-07-17-workspace-files-now-stay-in-a-loading-state-until-automatic-.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-07-17 +--- + +Workspace files now stay in a loading state until automatic setup finishes, with a retry when setup fails From 18a0a89c4a00c2b8632a398c7ffff6b74468f466 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:25:43 -0400 Subject: [PATCH 10/76] fix(core): await action mutation success handlers --- .changeset/await-action-success-callbacks.md | 5 ++ .../src/client/use-action-mutation.spec.tsx | 75 +++++++++++++++++++ packages/core/src/client/use-action.ts | 2 +- .../components/sidebar/DocumentSidebar.tsx | 11 ++- .../sidebar/select-content-space.test.ts | 11 +++ .../sidebar/select-content-space.ts | 9 ++- 6 files changed, 109 insertions(+), 4 deletions(-) create mode 100644 .changeset/await-action-success-callbacks.md create mode 100644 packages/core/src/client/use-action-mutation.spec.tsx diff --git a/.changeset/await-action-success-callbacks.md b/.changeset/await-action-success-callbacks.md new file mode 100644 index 0000000000..20f4baba3e --- /dev/null +++ b/.changeset/await-action-success-callbacks.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Keep action mutations pending until asynchronous success callbacks finish. diff --git a/packages/core/src/client/use-action-mutation.spec.tsx b/packages/core/src/client/use-action-mutation.spec.tsx new file mode 100644 index 0000000000..b66bbfcb33 --- /dev/null +++ b/packages/core/src/client/use-action-mutation.spec.tsx @@ -0,0 +1,75 @@ +// @vitest-environment happy-dom + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { useActionMutation } from "./use-action.js"; + +describe("useActionMutation", () => { + const roots: ReturnType[] = []; + const containers: HTMLDivElement[] = []; + + afterEach(() => { + for (const root of roots) act(() => root.unmount()); + for (const container of containers) container.remove(); + roots.length = 0; + containers.length = 0; + vi.unstubAllGlobals(); + }); + + it("keeps mutateAsync pending until an async success callback finishes", async () => { + let finishSuccess: (() => void) | undefined; + const successFinished = new Promise((resolve) => { + finishSuccess = resolve; + }); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { + headers: { "Content-Type": "application/json" }, + }), + ), + ); + + let mutation: + | ReturnType>> + | undefined; + function Probe() { + mutation = useActionMutation>("save-record", { + onSuccess: () => successFinished, + }); + return null; + } + + const container = document.createElement("div"); + document.body.appendChild(container); + containers.push(container); + const root = createRoot(container); + roots.push(root); + const queryClient = new QueryClient({ + defaultOptions: { mutations: { retry: false } }, + }); + + await act(async () => + root.render( + + + , + ), + ); + + let settled = false; + const result = mutation!.mutateAsync({}).then((value) => { + settled = true; + return value; + }); + await act(async () => Promise.resolve()); + expect(settled).toBe(false); + + finishSuccess?.(); + await expect(result).resolves.toEqual({ ok: true }); + expect(settled).toBe(true); + }); +}); diff --git a/packages/core/src/client/use-action.ts b/packages/core/src/client/use-action.ts index daed675288..f920254948 100644 --- a/packages/core/src/client/use-action.ts +++ b/packages/core/src/client/use-action.ts @@ -617,7 +617,7 @@ export function useActionMutation< if (!skipActionQueryInvalidation) { queryClient.invalidateQueries({ queryKey: ["action"] }); } - (onSuccess as Function)?.(...args); + return (onSuccess as Function)?.(...args); }, }); } diff --git a/templates/content/app/components/sidebar/DocumentSidebar.tsx b/templates/content/app/components/sidebar/DocumentSidebar.tsx index 37ab1bb320..2c4a6fdd91 100644 --- a/templates/content/app/components/sidebar/DocumentSidebar.tsx +++ b/templates/content/app/components/sidebar/DocumentSidebar.tsx @@ -274,11 +274,16 @@ export function DocumentSidebar({ contentSpacesFetching: contentSpacesQuery.isFetching, contentSpacesError: contentSpacesQuery.isError, activeOrganizationResolved: activeOrgQuery.isSuccess, + activeOrganizationError: activeOrgQuery.isError, provisioningAttempted: spaceProvisionAttemptedRef.current, provisioningPending: ensureContentSpaces.isPending, provisioningError: ensureContentSpaces.isError, }); const handleRetryContentSpaces = useCallback(() => { + if (activeOrgQuery.isError) { + void activeOrgQuery.refetch(); + return; + } if (contentSpacesQuery.isError) { spaceProvisionAttemptedRef.current = false; void contentSpacesQuery.refetch(); @@ -286,7 +291,7 @@ export function DocumentSidebar({ } spaceProvisionAttemptedRef.current = true; ensureContentSpaces.mutate({}); - }, [contentSpacesQuery, ensureContentSpaces]); + }, [activeOrgQuery, contentSpacesQuery, ensureContentSpaces]); useEffect(() => { if (selectedSpace && selectedSpace.id !== storedSpaceId) { setStoredSpaceId(selectedSpace.id); @@ -1154,7 +1159,9 @@ export function DocumentSidebar({ compact onRetry={handleRetryContentSpaces} retrying={ - contentSpacesQuery.isFetching || ensureContentSpaces.isPending + activeOrgQuery.isFetching || + contentSpacesQuery.isFetching || + ensureContentSpaces.isPending } /> )} diff --git a/templates/content/app/components/sidebar/select-content-space.test.ts b/templates/content/app/components/sidebar/select-content-space.test.ts index 8a9bd16319..455694b853 100644 --- a/templates/content/app/components/sidebar/select-content-space.test.ts +++ b/templates/content/app/components/sidebar/select-content-space.test.ts @@ -160,6 +160,7 @@ describe("contentSpaceAvailability", () => { contentSpacesFetching: false, contentSpacesError: false, activeOrganizationResolved: true, + activeOrganizationError: false, provisioningAttempted: true, provisioningPending: false, provisioningError: false, @@ -193,6 +194,16 @@ describe("contentSpaceAvailability", () => { expect(contentSpaceAvailability(settledMissingSpace)).toBe("error"); }); + it("surfaces active organization failures instead of loading forever", () => { + expect( + contentSpaceAvailability({ + ...settledMissingSpace, + activeOrganizationResolved: false, + activeOrganizationError: true, + }), + ).toBe("error"); + }); + it("renders Files as soon as the active workspace is available", () => { expect( contentSpaceAvailability({ diff --git a/templates/content/app/components/sidebar/select-content-space.ts b/templates/content/app/components/sidebar/select-content-space.ts index 4a5afbb65c..1d8d27471e 100644 --- a/templates/content/app/components/sidebar/select-content-space.ts +++ b/templates/content/app/components/sidebar/select-content-space.ts @@ -8,12 +8,19 @@ export function contentSpaceAvailability(args: { contentSpacesFetching: boolean; contentSpacesError: boolean; activeOrganizationResolved: boolean; + activeOrganizationError: boolean; provisioningAttempted: boolean; provisioningPending: boolean; provisioningError: boolean; }): ContentSpaceAvailability { if (args.hasSelectedSpace) return "ready"; - if (args.contentSpacesError || args.provisioningError) return "error"; + if ( + args.contentSpacesError || + args.activeOrganizationError || + args.provisioningError + ) { + return "error"; + } if ( args.contentSpacesLoading || args.contentSpacesFetching || From 73bfbaf6b6928427522486d0c6bd1ef055cae2d6 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:29:09 -0400 Subject: [PATCH 11/76] chore: drop consumed Cloudflare changeset --- .changeset/tidy-clouds-build.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/tidy-clouds-build.md diff --git a/.changeset/tidy-clouds-build.md b/.changeset/tidy-clouds-build.md deleted file mode 100644 index d4c8fe4a0d..0000000000 --- a/.changeset/tidy-clouds-build.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@agent-native/core": patch ---- - -Keep Cloudflare template builds deployable when browser-only editor and chat packages import input-rule and message-part runtime helpers. From 1ecef7abe1853ee1bfe1c00fc4525d0ec8e9dde6 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:06:30 -0400 Subject: [PATCH 12/76] fix(content): complete workspace switching --- .../fence-incompatible-browser-clients.md | 5 + .../src/client/build-compatibility.spec.ts | 81 +++++++++++ .../core/src/client/build-compatibility.ts | 77 ++++++++++ packages/core/src/client/index.ts | 7 + packages/core/src/client/use-action.spec.ts | 53 +++++++ packages/core/src/client/use-action.ts | 31 ++++ packages/core/src/deploy/build.spec.ts | 2 + packages/core/src/deploy/build.ts | 2 +- .../core/src/server/action-routes.spec.ts | 74 ++++++++++ packages/core/src/server/action-routes.ts | 49 ++++++- .../core/src/shared/mcp-embed-headers.spec.ts | 4 + packages/core/src/shared/mcp-embed-headers.ts | 2 +- packages/core/src/vite/client.spec.ts | 20 +++ packages/core/src/vite/client.ts | 17 +++ templates/content/actions/_content-files.ts | 15 +- templates/content/actions/_content-spaces.ts | 10 -- templates/content/actions/_database-utils.ts | 12 +- templates/content/actions/_property-utils.ts | 1 + .../content/actions/content-files.db.test.ts | 82 ++++++++++- .../content/actions/content-spaces.db.test.ts | 59 ++++---- .../content/actions/list-content-spaces.ts | 21 ++- .../editor/database/DatabaseView.tsx | 54 +++++++ .../components/sidebar/DocumentSidebar.tsx | 18 ++- .../sidebar/select-content-space.test.ts | 133 +++++++++++++++++- .../sidebar/select-content-space.ts | 19 +++ templates/content/app/entry.client.tsx | 2 + .../content/app/hooks/use-content-spaces.ts | 1 + ...ow-opens-the-selected-files-database-pr.md | 6 + templates/content/shared/api.ts | 1 + templates/content/vite.config.ts | 1 + 30 files changed, 787 insertions(+), 72 deletions(-) create mode 100644 .changeset/fence-incompatible-browser-clients.md create mode 100644 packages/core/src/client/build-compatibility.spec.ts create mode 100644 packages/core/src/client/build-compatibility.ts create mode 100644 templates/content/changelog/2026-07-17-workspace-switching-now-opens-the-selected-files-database-pr.md diff --git a/.changeset/fence-incompatible-browser-clients.md b/.changeset/fence-incompatible-browser-clients.md new file mode 100644 index 0000000000..b2d397e24f --- /dev/null +++ b/.changeset/fence-incompatible-browser-clients.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Fence incompatible cached browser clients from newer action backends and reload version-aware tabs with a cache-busted build. diff --git a/packages/core/src/client/build-compatibility.spec.ts b/packages/core/src/client/build-compatibility.spec.ts new file mode 100644 index 0000000000..4018e33a0a --- /dev/null +++ b/packages/core/src/client/build-compatibility.spec.ts @@ -0,0 +1,81 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + BUILD_CACHE_BUSTER_PARAM, + reloadForClientCompatibilityMismatch, + stripBuildCompatibilityCacheBuster, +} from "./build-compatibility.js"; + +function compatibilityWindow( + href = "https://content.example/page/one?view=all", +) { + const values = new Map(); + const replace = vi.fn(); + const replaceState = vi.fn(); + return { + win: { + location: { href, replace }, + history: { state: { key: "value" }, replaceState }, + sessionStorage: { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + }, + } as any, + replace, + replaceState, + }; +} + +afterEach(() => vi.restoreAllMocks()); + +describe("client build compatibility recovery", () => { + it("hard-navigates once to a cache-busted copy of the current page", () => { + const { win, replace } = compatibilityWindow(); + + expect( + reloadForClientCompatibilityMismatch("build-2", "spaces-v1", win), + ).toBe(true); + expect(replace).toHaveBeenCalledOnce(); + const target = new URL(replace.mock.calls[0]![0]); + expect(target.pathname).toBe("/page/one"); + expect(target.searchParams.get("view")).toBe("all"); + expect(target.searchParams.get(BUILD_CACHE_BUSTER_PARAM)).toBe("build-2"); + + expect( + reloadForClientCompatibilityMismatch("build-2", "spaces-v1", win), + ).toBe(false); + expect(replace).toHaveBeenCalledOnce(); + }); + + it("uses an in-memory loop guard when Safari storage access throws", () => { + const { win, replace } = compatibilityWindow(); + win.sessionStorage = { + getItem: () => { + throw new Error("storage unavailable"); + }, + setItem: () => { + throw new Error("storage unavailable"); + }, + }; + + expect( + reloadForClientCompatibilityMismatch("build-3", "spaces-v1", win), + ).toBe(true); + expect( + reloadForClientCompatibilityMismatch("build-3", "spaces-v1", win), + ).toBe(false); + expect(replace).toHaveBeenCalledOnce(); + }); + + it("removes only the compatibility cache buster after hydration", () => { + const { win, replaceState } = compatibilityWindow( + "https://content.example/page/one?view=all&__an_build=build-2", + ); + + stripBuildCompatibilityCacheBuster(win); + + const target = new URL(replaceState.mock.calls[0]![2]); + expect(target.searchParams.get("view")).toBe("all"); + expect(target.searchParams.has(BUILD_CACHE_BUSTER_PARAM)).toBe(false); + }); +}); diff --git a/packages/core/src/client/build-compatibility.ts b/packages/core/src/client/build-compatibility.ts new file mode 100644 index 0000000000..d7aed62052 --- /dev/null +++ b/packages/core/src/client/build-compatibility.ts @@ -0,0 +1,77 @@ +declare const __AGENT_NATIVE_BUILD_ID__: string | undefined; +declare const __AGENT_NATIVE_CLIENT_COMPATIBILITY_VERSION__: string | undefined; + +const RELOAD_MARKER_KEY = "__agentNativeClientCompatibilityReload"; +export const BUILD_CACHE_BUSTER_PARAM = "__an_build"; + +type CompatibilityWindow = Pick & { + sessionStorage?: Storage; + __agentNativeClientCompatibilityReload?: string; +}; + +export function clientBuildId(): string { + if (typeof __AGENT_NATIVE_BUILD_ID__ === "string") { + return __AGENT_NATIVE_BUILD_ID__; + } + return ( + (globalThis as typeof globalThis & { __AGENT_NATIVE_BUILD_ID__?: string }) + .__AGENT_NATIVE_BUILD_ID__ ?? "" + ); +} + +export function clientCompatibilityVersion(): string { + if (typeof __AGENT_NATIVE_CLIENT_COMPATIBILITY_VERSION__ === "string") { + return __AGENT_NATIVE_CLIENT_COMPATIBILITY_VERSION__; + } + return ( + ( + globalThis as typeof globalThis & { + __AGENT_NATIVE_CLIENT_COMPATIBILITY_VERSION__?: string; + } + ).__AGENT_NATIVE_CLIENT_COMPATIBILITY_VERSION__ ?? "" + ); +} + +function readReloadMarker(win: CompatibilityWindow): string { + try { + return win.sessionStorage?.getItem(RELOAD_MARKER_KEY) ?? ""; + } catch { + return win.__agentNativeClientCompatibilityReload ?? ""; + } +} + +function writeReloadMarker(win: CompatibilityWindow, marker: string): void { + win.__agentNativeClientCompatibilityReload = marker; + try { + win.sessionStorage?.setItem(RELOAD_MARKER_KEY, marker); + } catch {} +} + +export function reloadForClientCompatibilityMismatch( + serverBuildId: string, + requiredCompatibility: string, + win: CompatibilityWindow | undefined = typeof window === "undefined" + ? undefined + : window, +): boolean { + if (!win?.location) return false; + const marker = `${requiredCompatibility}:${serverBuildId}`; + if (readReloadMarker(win) === marker) return false; + writeReloadMarker(win, marker); + const target = new URL(win.location.href); + target.searchParams.set(BUILD_CACHE_BUSTER_PARAM, serverBuildId || "latest"); + win.location.replace(target.href); + return true; +} + +export function stripBuildCompatibilityCacheBuster( + win: CompatibilityWindow | undefined = typeof window === "undefined" + ? undefined + : window, +): void { + if (!win?.location || !win.history) return; + const target = new URL(win.location.href); + if (!target.searchParams.has(BUILD_CACHE_BUSTER_PARAM)) return; + target.searchParams.delete(BUILD_CACHE_BUSTER_PARAM); + win.history.replaceState(win.history.state, "", target.href); +} diff --git a/packages/core/src/client/index.ts b/packages/core/src/client/index.ts index 1e736265d8..1815bfd9f9 100644 --- a/packages/core/src/client/index.ts +++ b/packages/core/src/client/index.ts @@ -3,6 +3,13 @@ import { installRouteChunkRecovery } from "./route-chunk-recovery.js"; installRouteChunkRecovery(); export { getBrowserTabId } from "./browser-tab-id.js"; +export { + BUILD_CACHE_BUSTER_PARAM, + clientBuildId, + clientCompatibilityVersion, + reloadForClientCompatibilityMismatch, + stripBuildCompatibilityCacheBuster, +} from "./build-compatibility.js"; export { addContextToAgentChat, diff --git a/packages/core/src/client/use-action.spec.ts b/packages/core/src/client/use-action.spec.ts index 074b14f7d3..364468f657 100644 --- a/packages/core/src/client/use-action.spec.ts +++ b/packages/core/src/client/use-action.spec.ts @@ -19,6 +19,17 @@ function jsonResponse(body: unknown, init: ResponseInit = {}): Response { afterEach(() => { vi.unstubAllGlobals(); + delete ( + globalThis as typeof globalThis & { + __AGENT_NATIVE_BUILD_ID__?: string; + __AGENT_NATIVE_CLIENT_COMPATIBILITY_VERSION__?: string; + } + ).__AGENT_NATIVE_BUILD_ID__; + delete ( + globalThis as typeof globalThis & { + __AGENT_NATIVE_CLIENT_COMPATIBILITY_VERSION__?: string; + } + ).__AGENT_NATIVE_CLIENT_COMPATIBILITY_VERSION__; }); describe("serializeActionQueryParams", () => { @@ -39,6 +50,48 @@ describe("serializeActionQueryParams", () => { }); describe("callAction", () => { + it("sends build compatibility and hard-refreshes once on a mismatch", async () => { + Object.assign(globalThis, { + __AGENT_NATIVE_BUILD_ID__: "client-build", + __AGENT_NATIVE_CLIENT_COMPATIBILITY_VERSION__: "spaces-v1", + }); + const replace = vi.fn(); + vi.stubGlobal("window", { + location: { href: "https://content.example/page/one", replace }, + history: { state: null, replaceState: vi.fn() }, + sessionStorage: { + getItem: vi.fn(() => null), + setItem: vi.fn(), + }, + }); + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse( + { code: "client_build_mismatch" }, + { + status: 409, + headers: { + "Content-Type": "application/json", + "X-Agent-Native-Client-Mismatch": "1", + "X-Agent-Native-Build-Id": "server-build", + "X-Agent-Native-Client-Compatibility": "spaces-v2", + }, + }, + ), + ); + vi.stubGlobal("fetch", fetchMock); + + await expect( + callAction("list-files", {}, { method: "GET" }), + ).rejects.toMatchObject({ status: 409, code: "client_build_mismatch" }); + expect(fetchMock.mock.calls[0]?.[1]?.headers).toMatchObject({ + "X-Agent-Native-Client-Compatibility": "spaces-v1", + "X-Agent-Native-Build-Id": "client-build", + }); + expect(replace).toHaveBeenCalledWith( + "https://content.example/page/one?__an_build=server-build", + ); + }); + it("calls mutating actions through the framework action transport", async () => { const fetchMock = vi .fn() diff --git a/packages/core/src/client/use-action.ts b/packages/core/src/client/use-action.ts index f920254948..b6fa043f8b 100644 --- a/packages/core/src/client/use-action.ts +++ b/packages/core/src/client/use-action.ts @@ -29,6 +29,11 @@ import type { import { agentNativePath } from "./api-path.js"; import { getBrowserTabId } from "./browser-tab-id.js"; +import { + clientBuildId, + clientCompatibilityVersion, + reloadForClientCompatibilityMismatch, +} from "./build-compatibility.js"; import { ensureEmbedAuthFetchInterceptor } from "./embed-auth.js"; const ACTION_PREFIX = agentNativePath("/_agent-native/actions"); @@ -276,6 +281,12 @@ async function actionFetch( } : {}), }; + const compatibilityVersion = clientCompatibilityVersion(); + if (compatibilityVersion) { + headers["X-Agent-Native-Client-Compatibility"] = compatibilityVersion; + } + const buildId = clientBuildId(); + if (buildId) headers["X-Agent-Native-Build-Id"] = buildId; const tz = resolveUserTimezone(); if (tz) headers["x-user-timezone"] = tz; const init: RequestInit = { @@ -343,6 +354,26 @@ async function actionFetch( throw new Error(`Action ${name} failed: ${cause}`); } + if ( + res.status === 409 && + res.headers.get("X-Agent-Native-Client-Mismatch") === "1" + ) { + const serverBuildId = + res.headers.get("X-Agent-Native-Build-Id") ?? "latest"; + const requiredCompatibility = + res.headers.get("X-Agent-Native-Client-Compatibility") ?? "unknown"; + reloadForClientCompatibilityMismatch( + serverBuildId, + requiredCompatibility, + ); + const error = new Error( + `Action ${name} requires a refreshed browser client`, + ); + (error as any).status = 409; + (error as any).code = "client_build_mismatch"; + throw error; + } + // 204 No Content — nothing to parse. if (res.status === 204) return null as T; diff --git a/packages/core/src/deploy/build.spec.ts b/packages/core/src/deploy/build.spec.ts index 4c1833e0dc..05a97c8442 100644 --- a/packages/core/src/deploy/build.spec.ts +++ b/packages/core/src/deploy/build.spec.ts @@ -779,6 +779,8 @@ export default { expect(response.status).toBe(204); const allowHeaders = response.headers.get("Access-Control-Allow-Headers"); expect(allowHeaders).toContain("X-Agent-Native-Frontend"); + expect(allowHeaders).toContain("X-Agent-Native-Client-Compatibility"); + expect(allowHeaders).toContain("X-Agent-Native-Build-Id"); expect(allowHeaders).toContain("X-User-Timezone"); }); diff --git a/packages/core/src/deploy/build.ts b/packages/core/src/deploy/build.ts index b0f80887dd..74219d4776 100644 --- a/packages/core/src/deploy/build.ts +++ b/packages/core/src/deploy/build.ts @@ -1188,7 +1188,7 @@ async function getHandler() { headers: { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS", - "Access-Control-Allow-Headers": "Content-Type,Authorization,X-Requested-With,X-Request-Source,X-Agent-Native-CSRF,X-User-Timezone,X-Agent-Native-Tool-Bridge,X-Agent-Native-Tool-Id,X-Agent-Native-Frontend,X-Agent-Native-Embed-Target", + "Access-Control-Allow-Headers": "Content-Type,Authorization,X-Requested-With,X-Request-Source,X-Agent-Native-CSRF,X-User-Timezone,X-Agent-Native-Tool-Bridge,X-Agent-Native-Tool-Id,X-Agent-Native-Frontend,X-Agent-Native-Client-Compatibility,X-Agent-Native-Build-Id,X-Agent-Native-Embed-Target", }, }); } diff --git a/packages/core/src/server/action-routes.spec.ts b/packages/core/src/server/action-routes.spec.ts index 04977c92dc..9b5c04b247 100644 --- a/packages/core/src/server/action-routes.spec.ts +++ b/packages/core/src/server/action-routes.spec.ts @@ -55,6 +55,8 @@ describe("mountActionRoutes", () => { delete process.env.AGENT_USER_EMAIL; delete process.env.AGENT_ORG_ID; delete process.env.AGENT_USER_TIMEZONE; + delete process.env.AGENT_NATIVE_BUILD_ID; + delete process.env.AGENT_NATIVE_CLIENT_COMPATIBILITY_VERSION; mockNotifyActionChange.mockReset(); mockResolveOrgIdForEmail.mockReset(); mockGetSession.mockReset(); @@ -64,6 +66,78 @@ describe("mountActionRoutes", () => { vi.restoreAllMocks(); }); + it("rejects cached frontend clients before an action can read or write", async () => { + process.env.AGENT_NATIVE_BUILD_ID = "server-build"; + process.env.AGENT_NATIVE_CLIENT_COMPATIBILITY_VERSION = "spaces-v1"; + const { mountActionRoutes } = await import("./action-routes.js"); + const mounted: Array<{ path: string; handler: any }> = []; + const run = vi.fn(async () => ({ ok: true })); + const nitroApp = { + use: vi.fn((path: string, handler: any) => + mounted.push({ path, handler }), + ), + }; + + mountActionRoutes(nitroApp, { test: { run } as any }); + const event = { + _method: "POST", + _headers: { "x-agent-native-frontend": "1" }, + req: { json: vi.fn(async () => ({})) }, + }; + + await expect(mounted[0]!.handler(event)).resolves.toMatchObject({ + code: "client_build_mismatch", + serverBuildId: "server-build", + requiredCompatibility: "spaces-v1", + }); + expect(event).toMatchObject({ + _status: 409, + _responseHeaders: { + "cache-control": "no-store", + "x-agent-native-client-mismatch": "1", + }, + }); + expect(event.req.json).not.toHaveBeenCalled(); + expect(run).not.toHaveBeenCalled(); + }); + + it("allows matching frontend clients and leaves non-frontend callers unaffected", async () => { + process.env.AGENT_NATIVE_CLIENT_COMPATIBILITY_VERSION = "spaces-v1"; + const { mountActionRoutes } = await import("./action-routes.js"); + const mounted: Array<{ path: string; handler: any }> = []; + const run = vi.fn(async () => ({ ok: true })); + const nitroApp = { + use: vi.fn((path: string, handler: any) => + mounted.push({ path, handler }), + ), + }; + + mountActionRoutes( + nitroApp, + { test: { run } as any }, + { + getOwnerFromEvent: async () => "owner@example.com", + }, + ); + const matchingFrontend = { + _method: "POST", + _headers: { + "x-agent-native-frontend": "1", + "x-agent-native-client-compatibility": "spaces-v1", + }, + req: { json: async () => ({}) }, + }; + const agentCaller = { + _method: "POST", + _headers: {}, + req: { json: async () => ({}) }, + }; + + await expect(mounted[0]!.handler(matchingFrontend)).resolves.toBeTruthy(); + await expect(mounted[0]!.handler(agentCaller)).resolves.toBeTruthy(); + expect(run).toHaveBeenCalledTimes(2); + }); + it("uses action error statusCode for HTTP responses", async () => { const { mountActionRoutes } = await import("./action-routes.js"); const mounted: Array<{ path: string; handler: any }> = []; diff --git a/packages/core/src/server/action-routes.ts b/packages/core/src/server/action-routes.ts index 63a65228a7..eed57dce66 100644 --- a/packages/core/src/server/action-routes.ts +++ b/packages/core/src/server/action-routes.ts @@ -28,6 +28,26 @@ import { getAllowedCorsOrigin as resolveAllowedCorsOrigin, readCorsAllowedOrigins, } from "./cors-origins.js"; + +declare const __AGENT_NATIVE_BUILD_ID__: string | undefined; +declare const __AGENT_NATIVE_CLIENT_COMPATIBILITY_VERSION__: string | undefined; + +function requiredClientCompatibilityVersion(): string { + const configured = + typeof __AGENT_NATIVE_CLIENT_COMPATIBILITY_VERSION__ === "string" + ? __AGENT_NATIVE_CLIENT_COMPATIBILITY_VERSION__ + : process.env.AGENT_NATIVE_CLIENT_COMPATIBILITY_VERSION; + return configured?.trim() ?? ""; +} + +function currentBuildId(): string { + const configured = + typeof __AGENT_NATIVE_BUILD_ID__ === "string" + ? __AGENT_NATIVE_BUILD_ID__ + : process.env.AGENT_NATIVE_BUILD_ID; + return configured?.trim() || "unknown"; +} + /** * Auto-mount actions as HTTP endpoints under /_agent-native/actions/:name. * @@ -161,8 +181,8 @@ function handleOptionsRequest(event: any): string { event, "Access-Control-Allow-Headers", cors.credentials - ? `Content-Type,Authorization,X-Requested-With,X-Request-Source,X-Agent-Native-CSRF,X-User-Timezone,X-Agent-Native-Tool-Bridge,X-Agent-Native-Tool-Id,X-Agent-Native-Frontend,${EMBED_TARGET_HEADER}` - : `${MCP_EMBED_CORS_ALLOW_HEADERS},X-Agent-Native-Tool-Bridge,X-Agent-Native-Tool-Id,X-Agent-Native-Frontend`, + ? `Content-Type,Authorization,X-Requested-With,X-Request-Source,X-Agent-Native-CSRF,X-User-Timezone,X-Agent-Native-Tool-Bridge,X-Agent-Native-Tool-Id,X-Agent-Native-Frontend,X-Agent-Native-Client-Compatibility,X-Agent-Native-Build-Id,${EMBED_TARGET_HEADER}` + : `${MCP_EMBED_CORS_ALLOW_HEADERS},X-Agent-Native-Tool-Bridge,X-Agent-Native-Tool-Id,X-Agent-Native-Frontend,X-Agent-Native-Client-Compatibility,X-Agent-Native-Build-Id`, ); } @@ -298,6 +318,31 @@ export function mountActionRoutes( return { error: `Method not allowed. Use ${method}.` }; } + const requiredCompatibility = requiredClientCompatibilityVersion(); + if (isFrontendActionRequest(event) && requiredCompatibility) { + const receivedCompatibility = getHeader( + event, + "x-agent-native-client-compatibility", + ); + if (receivedCompatibility !== requiredCompatibility) { + const serverBuildId = currentBuildId(); + setResponseStatus(event, 409); + setResponseHeader(event, "X-Agent-Native-Client-Mismatch", "1"); + setResponseHeader(event, "X-Agent-Native-Build-Id", serverBuildId); + setResponseHeader( + event, + "X-Agent-Native-Client-Compatibility", + requiredCompatibility, + ); + return { + error: "This browser tab must reload before it can use this app.", + code: "client_build_mismatch", + serverBuildId, + requiredCompatibility, + }; + } + } + // (audit H5) Per-action `toolCallable` opt-out for the tools-iframe // bridge. The bridge tags every outbound action call with // X-Agent-Native-Tool-Bridge: 1. When that header is present and the diff --git a/packages/core/src/shared/mcp-embed-headers.spec.ts b/packages/core/src/shared/mcp-embed-headers.spec.ts index ae284c8ced..45bf69221b 100644 --- a/packages/core/src/shared/mcp-embed-headers.spec.ts +++ b/packages/core/src/shared/mcp-embed-headers.spec.ts @@ -12,6 +12,10 @@ import { describe("MCP embed headers", () => { it("allows frontend action-client headers from embedded apps", () => { expect(MCP_EMBED_CORS_ALLOW_HEADERS).toContain("X-Agent-Native-Frontend"); + expect(MCP_EMBED_CORS_ALLOW_HEADERS).toContain( + "X-Agent-Native-Client-Compatibility", + ); + expect(MCP_EMBED_CORS_ALLOW_HEADERS).toContain("X-Agent-Native-Build-Id"); }); it("allows ChatGPT web-sandbox origins", () => { diff --git a/packages/core/src/shared/mcp-embed-headers.ts b/packages/core/src/shared/mcp-embed-headers.ts index 4c1057fa3c..743e1b25a6 100644 --- a/packages/core/src/shared/mcp-embed-headers.ts +++ b/packages/core/src/shared/mcp-embed-headers.ts @@ -1,5 +1,5 @@ export const MCP_EMBED_CORS_ALLOW_HEADERS = - "Content-Type,Authorization,X-Requested-With,X-Request-Source,X-Agent-Native-CSRF,X-Agent-Native-Frontend,X-User-Timezone,X-Agent-Native-Embed-Target,X-Agent-Native-Embed-Transplant"; + "Content-Type,Authorization,X-Requested-With,X-Request-Source,X-Agent-Native-CSRF,X-Agent-Native-Frontend,X-Agent-Native-Client-Compatibility,X-Agent-Native-Build-Id,X-User-Timezone,X-Agent-Native-Embed-Target,X-Agent-Native-Embed-Transplant"; export const EMBED_TRANSPLANT_HEADER = "x-agent-native-embed-transplant"; const CLAUDE_MCP_CONTENT_HOST_RE = /^[a-f0-9]{32}\.claudemcpcontent\.com$/i; diff --git a/packages/core/src/vite/client.spec.ts b/packages/core/src/vite/client.spec.ts index b31714bc5c..9949ac8754 100644 --- a/packages/core/src/vite/client.spec.ts +++ b/packages/core/src/vite/client.spec.ts @@ -272,6 +272,26 @@ describe("Vite optimized dependency recovery", () => { }); describe("route warmup config", () => { + it("compiles the app compatibility epoch and deploy build id into client and server bundles", () => { + const previousDeployId = process.env.DEPLOY_ID; + process.env.DEPLOY_ID = "deploy-123"; + try { + const config = defineConfig({ + clientCompatibilityVersion: " content-spaces-v1 ", + }); + + expect(config.define?.__AGENT_NATIVE_BUILD_ID__).toBe( + JSON.stringify("deploy-123"), + ); + expect(config.define?.__AGENT_NATIVE_CLIENT_COMPATIBILITY_VERSION__).toBe( + JSON.stringify("content-spaces-v1"), + ); + } finally { + if (previousDeployId === undefined) delete process.env.DEPLOY_ID; + else process.env.DEPLOY_ID = previousDeployId; + } + }); + it("enables safe React Router route warmup by default", () => { const config = defineConfig(); const routeWarmup = JSON.parse( diff --git a/packages/core/src/vite/client.ts b/packages/core/src/vite/client.ts index 03be4c62f5..d6b6afda15 100644 --- a/packages/core/src/vite/client.ts +++ b/packages/core/src/vite/client.ts @@ -1272,6 +1272,12 @@ export interface ClientConfigOptions { optimizeDeps?: NonNullable; /** Additional Vite define constants. */ define?: UserConfig["define"]; + /** + * Browser/server compatibility epoch for app changes that cannot safely run + * across a cached client and a newer action backend. Bump only for an + * incompatible protocol or data-model transition, not for every deploy. + */ + clientCompatibilityVersion?: string; /** * Framework route warmup behavior mounted by AgentSidebar. * @@ -2585,6 +2591,13 @@ function createAgentNativeConfig( userConfig: UserConfig = {}, ): UserConfig { const cwd = process.cwd(); + const buildId = + process.env.DEPLOY_ID?.trim() || + process.env.COMMIT_REF?.trim() || + process.env.VERCEL_GIT_COMMIT_SHA?.trim() || + process.env.CF_PAGES_COMMIT_SHA?.trim() || + process.env.AGENT_NATIVE_BUILD_SHA?.trim() || + "development"; // Workspace env fallback. If this app is inside a workspace, tell Vite to // also look for .env files at the workspace root. Per-app .env still wins @@ -2667,6 +2680,10 @@ function createAgentNativeConfig( define: { ...(userConfig.define ?? {}), ...(options.define ?? {}), + __AGENT_NATIVE_BUILD_ID__: JSON.stringify(buildId), + __AGENT_NATIVE_CLIENT_COMPATIBILITY_VERSION__: JSON.stringify( + options.clientCompatibilityVersion?.trim() || "", + ), __AGENT_NATIVE_BUILD_GA_MEASUREMENT_ID__: JSON.stringify( process.env.GA_MEASUREMENT_ID?.trim() || "", ), diff --git a/templates/content/actions/_content-files.ts b/templates/content/actions/_content-files.ts index 59decb0cd7..0ed41207b6 100644 --- a/templates/content/actions/_content-files.ts +++ b/templates/content/actions/_content-files.ts @@ -242,19 +242,8 @@ export async function reconcileContentFilesMemberships( const orgSpaceIds = new Map(); for (const membership of memberships) { const spaceId = organizationContentSpaceId(membership.orgId); - try { - await resolveContentSpaceAccess(spaceId, "editor"); - orgSpaceIds.set(membership.orgId, spaceId); - } catch (error) { - if ( - error instanceof Error && - error.message === - `Editor access is required for Content space "${spaceId}"` - ) { - continue; - } - throw error; - } + await resolveContentSpaceAccess(spaceId); + orgSpaceIds.set(membership.orgId, spaceId); } const accessibleSpaceIds = [personalSpaceId, ...orgSpaceIds.values()]; const now = new Date().toISOString(); diff --git a/templates/content/actions/_content-spaces.ts b/templates/content/actions/_content-spaces.ts index eba1e62def..d9c48be355 100644 --- a/templates/content/actions/_content-spaces.ts +++ b/templates/content/actions/_content-spaces.ts @@ -298,16 +298,7 @@ export async function provisionContentSpaces( ).databaseId, })), ]; - const provisionableOrganizationSpaceIds = new Set( - memberships - .filter( - (membership) => - membership.role === "owner" || membership.role === "admin", - ) - .map((membership) => organizationContentSpaceId(membership.orgId)), - ); for (const space of spaces.slice(1)) { - if (!provisionableOrganizationSpaceIds.has(space.id)) continue; const files = await ensureSystemDatabase({ db: tx, spaceId: space.id, @@ -451,7 +442,6 @@ export async function provisionContentSpaces( db, }); for (const membership of memberships) { - if (membership.role !== "owner" && membership.role !== "admin") continue; const spaceId = organizationContentSpaceId(membership.orgId); const [database] = await db .select({ diff --git a/templates/content/actions/_database-utils.ts b/templates/content/actions/_database-utils.ts index d16af8dc82..ae3df32274 100644 --- a/templates/content/actions/_database-utils.ts +++ b/templates/content/actions/_database-utils.ts @@ -1,4 +1,4 @@ -import { and, asc, eq, inArray, isNull, sql } from "drizzle-orm"; +import { and, asc, eq, inArray, isNull, or, sql } from "drizzle-orm"; import { getDb, schema } from "../server/db/index.js"; import { getDocumentContextPath } from "../server/lib/document-context.js"; @@ -277,7 +277,15 @@ export async function getContentDatabaseResponse( schema.documents.id, items.map((item) => item.documentId), ), - eq(schema.documents.ownerEmail, database.ownerEmail), + database.systemRole === "files" && database.orgId + ? and( + eq(schema.documents.orgId, database.orgId), + or( + eq(schema.documents.visibility, "org"), + eq(schema.documents.visibility, "public"), + ), + ) + : eq(schema.documents.ownerEmail, database.ownerEmail), ), ) : []; diff --git a/templates/content/actions/_property-utils.ts b/templates/content/actions/_property-utils.ts index 42a073940a..4134b943ae 100644 --- a/templates/content/actions/_property-utils.ts +++ b/templates/content/actions/_property-utils.ts @@ -159,6 +159,7 @@ export function serializeDatabase( id: database.id, documentId: database.documentId, title: database.title, + systemRole: database.systemRole, description, viewConfig: parseDatabaseViewConfig(database.viewConfigJson), createdAt: database.createdAt, diff --git a/templates/content/actions/content-files.db.test.ts b/templates/content/actions/content-files.db.test.ts index 929066bcbb..0a8d6c8d4f 100644 --- a/templates/content/actions/content-files.db.test.ts +++ b/templates/content/actions/content-files.db.test.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import { getDbExec } from "@agent-native/core/db"; import { runWithRequestContext } from "@agent-native/core/server"; -import { and, eq } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; const TEST_DB_PATH = join( @@ -22,6 +22,7 @@ let provisionContentSpaces: typeof import("./_content-spaces.js").provisionConte let personalContentSpaceId: typeof import("./_content-spaces.js").personalContentSpaceId; let organizationContentSpaceId: typeof import("./_content-spaces.js").organizationContentSpaceId; let reconcileContentFilesMemberships: typeof import("./_content-files.js").reconcileContentFilesMemberships; +let getContentDatabaseAction: typeof import("./get-content-database.js").default; beforeAll(async () => { process.env.DATABASE_URL = `file:${TEST_DB_PATH}`; @@ -34,6 +35,8 @@ beforeAll(async () => { organizationContentSpaceId, } = await import("./_content-spaces.js")); ({ reconcileContentFilesMemberships } = await import("./_content-files.js")); + getContentDatabaseAction = (await import("./get-content-database.js")) + .default; const plugin = (await import("../server/plugins/db.js")).default; await plugin(undefined as any); await getDbExec().execute(`CREATE TABLE IF NOT EXISTS organizations ( @@ -100,7 +103,7 @@ async function getFilesDatabase(spaceId: string) { } describe("Content Files membership reconciliation", () => { - it("does not let organization viewers backfill legacy organization pages", async () => { + it("lets an ordinary member backfill legacy organization pages without changing their content or ownership", async () => { const viewerOrgId = "files-viewer-org"; await getDbExec().execute({ sql: "INSERT INTO organizations (id, name, created_by, created_at) VALUES (?, ?, ?, ?)", @@ -120,21 +123,88 @@ describe("Content Files membership reconciliation", () => { await createLegacyDocument({ id: "viewer-legacy-org", orgId: viewerOrgId, - title: "Viewer cannot reconcile", + title: "Member can reconcile", }); + await createLegacyDocument({ + id: "owner-private-org", + orgId: viewerOrgId, + title: "Owner private page", + }); + await getDb() + .update(schema.documents) + .set({ content: "Keep this body exactly", icon: "📚" }) + .where(eq(schema.documents.id, "viewer-legacy-org")); + await getDb() + .update(schema.documents) + .set({ visibility: "private" }) + .where(eq(schema.documents.id, "owner-private-org")); + const [before] = await getDb() + .select() + .from(schema.documents) + .where(eq(schema.documents.id, "viewer-legacy-org")); await runWithRequestContext({ userEmail: VIEWER }, () => reconcileContentFilesMemberships(getDb(), VIEWER), ); const [legacyDocument] = await getDb() - .select({ spaceId: schema.documents.spaceId }) + .select() .from(schema.documents) .where(eq(schema.documents.id, "viewer-legacy-org")); - expect(legacyDocument?.spaceId).toBeNull(); + expect(legacyDocument).toMatchObject({ + id: before!.id, + ownerEmail: before!.ownerEmail, + orgId: before!.orgId, + title: before!.title, + content: before!.content, + icon: before!.icon, + visibility: before!.visibility, + spaceId: organizationContentSpaceId(viewerOrgId), + }); + const filesDatabase = await getFilesDatabase( + organizationContentSpaceId(viewerOrgId), + ); + await expect( + getDb() + .select() + .from(schema.contentDatabaseItems) + .where( + and( + eq(schema.contentDatabaseItems.databaseId, filesDatabase.id), + eq(schema.contentDatabaseItems.documentId, "viewer-legacy-org"), + ), + ), + ).resolves.toHaveLength(1); + const databaseResponse = await runWithRequestContext( + { userEmail: VIEWER, orgId: viewerOrgId }, + () => getContentDatabaseAction.run({ databaseId: filesDatabase.id }), + ); + expect(databaseResponse).toMatchObject({ + database: { id: filesDatabase.id, systemRole: "files" }, + items: expect.arrayContaining([ + expect.objectContaining({ + document: expect.objectContaining({ + id: "viewer-legacy-org", + title: "Member can reconcile", + }), + }), + ]), + }); + expect(databaseResponse.items).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + document: expect.objectContaining({ id: "owner-private-org" }), + }), + ]), + ); await getDb() .delete(schema.documents) - .where(eq(schema.documents.id, "viewer-legacy-org")); + .where( + inArray(schema.documents.id, [ + "viewer-legacy-org", + "owner-private-org", + ]), + ); }); it("assigns personal and organization legacy pages to their canonical Files databases", async () => { diff --git a/templates/content/actions/content-spaces.db.test.ts b/templates/content/actions/content-spaces.db.test.ts index ae8df47543..ef529ffd83 100644 --- a/templates/content/actions/content-spaces.db.test.ts +++ b/templates/content/actions/content-spaces.db.test.ts @@ -126,6 +126,15 @@ describe("Content space provisioning", () => { ); expect(filesSelfItems).toHaveLength(0); await runWithRequestContext({ userEmail: OWNER }, async () => { + await expect(listContentSpacesAction.run({})).resolves.toMatchObject({ + spaces: expect.arrayContaining([ + expect.objectContaining({ + id: first.personalSpaceId, + filesDatabaseId: files.id, + filesDocumentId: files.documentId, + }), + ]), + }); await expect( deleteContentDatabaseAction.run({ databaseId: files.id }), ).rejects.toThrow("System Content databases cannot be deleted"); @@ -270,7 +279,7 @@ describe("Content space provisioning", () => { expect(reference?.title).toBe("After rename"); }); - it("does not create or seed organization resources from a viewer session", async () => { + it("lets an ordinary member provision organization Files on first login", async () => { const orgId = "org-viewer-provisioning"; const spaceId = organizationContentSpaceId(orgId); await addOrganization(orgId, "Viewer Provisioning"); @@ -281,23 +290,16 @@ describe("Content space provisioning", () => { () => provisionContentSpaces(getDb(), MEMBER), ); expect(viewerResult.spaceIds).toContain(spaceId); - await expect( - getDb() - .select() - .from(schema.contentSpaces) - .where(eq(schema.contentSpaces.id, spaceId)), - ).resolves.toEqual([]); - await expect( - getDb() - .select() - .from(schema.contentDatabases) - .where(eq(schema.contentDatabases.spaceId, spaceId)), - ).resolves.toEqual([]); - - await addMember("owner-viewer-provisioning", orgId, OWNER, "owner"); - await runWithRequestContext({ userEmail: OWNER }, () => - provisionContentSpaces(getDb(), OWNER), - ); + const [organizationSpace] = await getDb() + .select() + .from(schema.contentSpaces) + .where(eq(schema.contentSpaces.id, spaceId)); + expect(organizationSpace).toMatchObject({ + id: spaceId, + orgId, + ownerEmail: OWNER, + kind: "organization", + }); const [filesDatabase] = await getDb() .select() .from(schema.contentDatabases) @@ -307,19 +309,12 @@ describe("Content space provisioning", () => { eq(schema.contentDatabases.systemRole, "files"), ), ); - await getDb() - .delete(schema.documentPropertyDefinitions) - .where( - eq(schema.documentPropertyDefinitions.databaseId, filesDatabase!.id), - ); - await getDb() - .update(schema.contentDatabases) - .set({ primaryBlocksPropertyId: null, blocksSeeded: 0 }) - .where(eq(schema.contentDatabases.id, filesDatabase!.id)); - - await runWithRequestContext({ userEmail: MEMBER }, () => - provisionContentSpaces(getDb(), MEMBER), - ); + expect(filesDatabase).toMatchObject({ + orgId, + ownerEmail: OWNER, + systemRole: "files", + blocksSeeded: 1, + }); await expect( getDb() .select() @@ -327,7 +322,7 @@ describe("Content space provisioning", () => { .where( eq(schema.documentPropertyDefinitions.databaseId, filesDatabase!.id), ), - ).resolves.toEqual([]); + ).resolves.toHaveLength(1); await runWithRequestContext({ userEmail: MEMBER }, async () => { await expect(resolveContentSpaceAccess(spaceId)).resolves.toMatchObject({ role: "viewer", diff --git a/templates/content/actions/list-content-spaces.ts b/templates/content/actions/list-content-spaces.ts index ea439cbb83..254560b454 100644 --- a/templates/content/actions/list-content-spaces.ts +++ b/templates/content/actions/list-content-spaces.ts @@ -1,6 +1,6 @@ import { defineAction } from "@agent-native/core"; import { getRequestUserEmail } from "@agent-native/core/server/request-context"; -import { and, eq, isNull } from "drizzle-orm"; +import { and, eq, inArray, isNull } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; @@ -65,11 +65,25 @@ export default defineAction({ isNull(schema.contentSpaces.archivedAt), ), ); + const filesDatabaseIds = rows.map((row) => row.space.filesDatabaseId); + const filesDatabases = filesDatabaseIds.length + ? await db + .select({ + id: schema.contentDatabases.id, + documentId: schema.contentDatabases.documentId, + }) + .from(schema.contentDatabases) + .where(inArray(schema.contentDatabases.id, filesDatabaseIds)) + : []; + const filesDocumentIdByDatabaseId = new Map( + filesDatabases.map((database) => [database.id, database.documentId]), + ); const spaces = [] as Array<{ id: string; name: string; kind: string; filesDatabaseId: string; + filesDocumentId: string; orgId: string | null; role: string; catalogItemId: string; @@ -87,11 +101,16 @@ export default defineAction({ ? "owner" : undefined; if (!role) continue; + const filesDocumentId = filesDocumentIdByDatabaseId.get( + row.space.filesDatabaseId, + ); + if (!filesDocumentId) continue; spaces.push({ id: row.space.id, name: row.space.name, kind: row.space.kind, filesDatabaseId: row.space.filesDatabaseId, + filesDocumentId, orgId: row.space.orgId, role, catalogItemId: row.mapping.databaseItemId, diff --git a/templates/content/app/components/editor/database/DatabaseView.tsx b/templates/content/app/components/editor/database/DatabaseView.tsx index 36f68b6ae3..4f37bdd7bb 100644 --- a/templates/content/app/components/editor/database/DatabaseView.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.tsx @@ -1,12 +1,14 @@ import { agentNativePath, getBrowserTabId, + setClientAppState, useBuilderConnectFlow, useBuilderStatus, useCodeMode, useSession, useT, } from "@agent-native/core/client"; +import { useOrg, useSwitchOrg } from "@agent-native/core/client/org"; import { AlertDialog, AlertDialogAction, @@ -146,6 +148,11 @@ import { import { Link, useLocation, useNavigate } from "react-router"; import { toast } from "sonner"; +import { + contentSpaceForCatalogItem, + SELECTED_CONTENT_SPACE_STORAGE_KEY, + selectContentSpace, +} from "@/components/sidebar/select-content-space"; import { isContentDatabaseUnavailable, useAddDatabaseItem, @@ -175,6 +182,7 @@ import { useUpdateContentDatabasePersonalView, useUpdateContentDatabaseView, } from "@/hooks/use-content-database"; +import { useContentSpaces } from "@/hooks/use-content-spaces"; import { useConfigureDocumentProperty, useSetDocumentProperty, @@ -188,6 +196,7 @@ import { useUpdatePreviewDocumentDraft, useUpdateDocument, } from "@/hooks/use-documents"; +import { useLocalStorage } from "@/hooks/use-local-storage"; import { messagesByLocale } from "@/i18n-data"; import { cn } from "@/lib/utils"; @@ -668,6 +677,13 @@ function DatabaseTable({ const t = useT(); const navigate = useNavigate(); const queryClient = useQueryClient(); + const contentSpacesQuery = useContentSpaces(); + const activeOrgQuery = useOrg(); + const switchOrg = useSwitchOrg(); + const [, setStoredSpaceId] = useLocalStorage( + SELECTED_CONTENT_SPACE_STORAGE_KEY, + null, + ); const [databaseItemLimit, setDatabaseItemLimit] = useState( CONTENT_DATABASE_PAGE_SIZE, ); @@ -1240,6 +1256,7 @@ function DatabaseTable({ ]); function previewItemPage(item: ContentDatabaseItem) { + if (openWorkspaceFiles(item)) return; seedDatabaseItemDocumentCaches(queryClient, item); prioritizeBuilderBodyHydrationForItem(item); if (activeView.openPagesIn === "full_page") { @@ -1279,11 +1296,48 @@ function DatabaseTable({ } function openItemPage(item: ContentDatabaseItem) { + if (openWorkspaceFiles(item)) return; seedDatabaseItemDocumentCaches(queryClient, item); prioritizeBuilderBodyHydrationForItem(item); navigate(`/page/${item.document.id}`); } + function openWorkspaceFiles(item: ContentDatabaseItem) { + if (data?.database.systemRole !== "workspaces") return false; + void (async () => { + const spacesResponse = + contentSpacesQuery.data ?? (await contentSpacesQuery.refetch()).data; + const space = contentSpaceForCatalogItem({ + databaseId, + catalogDatabaseId: spacesResponse?.catalogDatabaseId, + documentId: item.document.id, + spaces: spacesResponse?.spaces ?? [], + }); + if (!space) throw new Error("This workspace is no longer available"); + await selectContentSpace({ + space, + activeOrgId: activeOrgQuery.data?.orgId, + switchOrg: (orgId) => switchOrg.mutateAsync(orgId), + syncApplicationState: (selected) => + setClientAppState( + "content-space", + { + spaceId: selected.id, + name: selected.name, + kind: selected.kind, + filesDatabaseId: selected.filesDatabaseId, + }, + { requestSource: "content-workspaces-database" }, + ), + persistSelection: setStoredSpaceId, + openFiles: (documentId) => navigate(`/page/${documentId}`), + }); + })().catch((error) => { + toast.error(error instanceof Error ? error.message : String(error)); + }); + return true; + } + function prioritizeBuilderBodyHydrationForItem(item: ContentDatabaseItem) { const builderSource = databaseBuilderHydrationSourceForItem(item, sources); if (!builderSource) return; diff --git a/templates/content/app/components/sidebar/DocumentSidebar.tsx b/templates/content/app/components/sidebar/DocumentSidebar.tsx index 2c4a6fdd91..47a7c2e6a2 100644 --- a/templates/content/app/components/sidebar/DocumentSidebar.tsx +++ b/templates/content/app/components/sidebar/DocumentSidebar.tsx @@ -122,6 +122,7 @@ import { NotionButton } from "./NotionButton"; import { contentSpaceAvailability, contentSpaceForActiveOrg, + SELECTED_CONTENT_SPACE_STORAGE_KEY, selectContentSpace, } from "./select-content-space"; @@ -188,8 +189,6 @@ type CollapsedSectionsState = Record; const SIDEBAR_SECTION_COLLAPSE_STORAGE_KEY = "content-sidebar-collapsed-sections"; -const SELECTED_CONTENT_SPACE_STORAGE_KEY = "content-selected-space"; - const DEFAULT_COLLAPSED_SECTIONS: CollapsedSectionsState = { "local-files": false, "shared-copies": false, @@ -304,13 +303,26 @@ export function DocumentSidebar({ space, activeOrgId: activeOrg?.orgId, switchOrg: (orgId) => switchOrg.mutateAsync(orgId), + syncApplicationState: (selected) => + setClientAppState( + "content-space", + { + spaceId: selected.id, + name: selected.name, + kind: selected.kind, + filesDatabaseId: selected.filesDatabaseId, + }, + { requestSource: "content-sidebar" }, + ), persistSelection: setStoredSpaceId, + openFiles: (documentId) => + navigate(`/page/${documentId}`, { flushSync: true }), }); } catch (error) { toast.error(error instanceof Error ? error.message : String(error)); } }, - [activeOrg?.orgId, setStoredSpaceId, switchOrg, t], + [activeOrg?.orgId, navigate, setStoredSpaceId, switchOrg], ); useEffect(() => { if (!selectedSpace) return; diff --git a/templates/content/app/components/sidebar/select-content-space.test.ts b/templates/content/app/components/sidebar/select-content-space.test.ts index 455694b853..e00207ac09 100644 --- a/templates/content/app/components/sidebar/select-content-space.test.ts +++ b/templates/content/app/components/sidebar/select-content-space.test.ts @@ -5,6 +5,7 @@ import type { ContentSpaceSummary } from "@/hooks/use-content-spaces"; import { contentSpaceAvailability, contentSpaceForActiveOrg, + contentSpaceForCatalogItem, selectContentSpace, } from "./select-content-space"; @@ -16,6 +17,7 @@ function space( name: "Workspace", kind: "organization", filesDatabaseId: "database_1", + filesDocumentId: "files_document_1", orgId: "org_1", role: "owner", catalogItemId: "catalog_item_1", @@ -25,6 +27,52 @@ function space( } describe("selectContentSpace", () => { + it("supports Personal to organization to Personal with one complete flow per selection", async () => { + let activeOrgId: string | null = null; + let storedSpaceId: string | null = null; + const opened: string[] = []; + const states: string[] = []; + const switchOrg = vi.fn(async (orgId: string | null) => { + activeOrgId = orgId; + }); + const select = async (selected: ContentSpaceSummary) => { + await selectContentSpace({ + space: selected, + activeOrgId, + switchOrg, + syncApplicationState: async (next) => states.push(next.id), + persistSelection: (id) => { + storedSpaceId = id; + }, + openFiles: (id) => opened.push(id), + }); + }; + const personal = space({ + id: "personal", + kind: "personal", + orgId: null, + filesDocumentId: "personal-files", + }); + const builder = space({ + id: "builder", + orgId: "builder-org", + filesDocumentId: "builder-files", + }); + + await select(personal); + await select(builder); + await select(personal); + + expect(switchOrg.mock.calls).toEqual([["builder-org"], [null]]); + expect(states).toEqual(["personal", "builder", "personal"]); + expect(opened).toEqual([ + "personal-files", + "builder-files", + "personal-files", + ]); + expect(storedSpaceId).toBe("personal"); + }); + it("switches organization context before persisting another org workspace", async () => { const events: string[] = []; const switchOrg = vi.fn(async (orgId: string | null) => { @@ -33,26 +81,43 @@ describe("selectContentSpace", () => { const persistSelection = vi.fn((spaceId: string) => { events.push(`persist:${spaceId}`); }); + const syncApplicationState = vi.fn(async () => { + events.push("state:space_1"); + }); + const openFiles = vi.fn((documentId: string) => { + events.push(`open:${documentId}`); + }); await selectContentSpace({ space: space(), activeOrgId: "org_2", switchOrg, + syncApplicationState, persistSelection, + openFiles, }); - expect(events).toEqual(["switch:org_1", "persist:space_1"]); + expect(events).toEqual([ + "switch:org_1", + "state:space_1", + "persist:space_1", + "open:files_document_1", + ]); }); it("switches explicitly when the active organization is still loading", async () => { const switchOrg = vi.fn(async () => undefined); const persistSelection = vi.fn(); + const syncApplicationState = vi.fn(async () => undefined); + const openFiles = vi.fn(); await selectContentSpace({ space: space({ kind: "personal", orgId: null }), activeOrgId: undefined, switchOrg, + syncApplicationState, persistSelection, + openFiles, }); expect(switchOrg).toHaveBeenCalledWith(null); @@ -62,12 +127,16 @@ describe("selectContentSpace", () => { it("switches to personal context for a personal workspace", async () => { const switchOrg = vi.fn(async () => undefined); const persistSelection = vi.fn(); + const syncApplicationState = vi.fn(async () => undefined); + const openFiles = vi.fn(); await selectContentSpace({ space: space({ kind: "personal", orgId: null }), activeOrgId: "org_1", switchOrg, + syncApplicationState, persistSelection, + openFiles, }); expect(switchOrg).toHaveBeenCalledWith(null); @@ -77,32 +146,94 @@ describe("selectContentSpace", () => { it("does not persist a selection when organization switching fails", async () => { const error = new Error("Organization switch failed"); const persistSelection = vi.fn(); + const syncApplicationState = vi.fn(async () => undefined); + const openFiles = vi.fn(); await expect( selectContentSpace({ space: space(), activeOrgId: "org_2", switchOrg: async () => Promise.reject(error), + syncApplicationState, + persistSelection, + openFiles, + }), + ).rejects.toBe(error); + + expect(persistSelection).not.toHaveBeenCalled(); + expect(syncApplicationState).not.toHaveBeenCalled(); + expect(openFiles).not.toHaveBeenCalled(); + }); + + it("does not persist or navigate when application state cannot be updated", async () => { + const error = new Error("Application state failed"); + const persistSelection = vi.fn(); + const openFiles = vi.fn(); + + await expect( + selectContentSpace({ + space: space(), + activeOrgId: "org_1", + switchOrg: vi.fn(async () => undefined), + syncApplicationState: async () => Promise.reject(error), persistSelection, + openFiles, }), ).rejects.toBe(error); expect(persistSelection).not.toHaveBeenCalled(); + expect(openFiles).not.toHaveBeenCalled(); }); it("persists immediately when the organization context already matches", async () => { const switchOrg = vi.fn(async () => undefined); const persistSelection = vi.fn(); + const syncApplicationState = vi.fn(async () => undefined); + const openFiles = vi.fn(); await selectContentSpace({ space: space(), activeOrgId: "org_1", switchOrg, + syncApplicationState, persistSelection, + openFiles, }); expect(switchOrg).not.toHaveBeenCalled(); expect(persistSelection).toHaveBeenCalledWith("space_1"); + expect(syncApplicationState).toHaveBeenCalledWith( + expect.objectContaining({ id: "space_1" }), + ); + expect(openFiles).toHaveBeenCalledWith("files_document_1"); + }); +}); + +describe("contentSpaceForCatalogItem", () => { + it("maps a Workspaces database row to the Content space it selects", () => { + const builder = space({ + id: "builder", + catalogDocumentId: "builder-reference", + }); + expect( + contentSpaceForCatalogItem({ + databaseId: "workspaces", + catalogDatabaseId: "workspaces", + documentId: "builder-reference", + spaces: [builder], + }), + ).toBe(builder); + }); + + it("leaves ordinary database rows on the normal page-open path", () => { + expect( + contentSpaceForCatalogItem({ + databaseId: "projects", + catalogDatabaseId: "workspaces", + documentId: "builder-reference", + spaces: [space({ catalogDocumentId: "builder-reference" })], + }), + ).toBeNull(); }); }); diff --git a/templates/content/app/components/sidebar/select-content-space.ts b/templates/content/app/components/sidebar/select-content-space.ts index 1d8d27471e..3b55478f87 100644 --- a/templates/content/app/components/sidebar/select-content-space.ts +++ b/templates/content/app/components/sidebar/select-content-space.ts @@ -1,5 +1,7 @@ import type { ContentSpaceSummary } from "@/hooks/use-content-spaces"; +export const SELECTED_CONTENT_SPACE_STORAGE_KEY = "content-selected-space"; + export type ContentSpaceAvailability = "loading" | "ready" | "error"; export function contentSpaceAvailability(args: { @@ -51,14 +53,31 @@ export function contentSpaceForActiveOrg(args: { : (matching[0] ?? null); } +export function contentSpaceForCatalogItem(args: { + databaseId: string; + catalogDatabaseId: string | undefined; + documentId: string; + spaces: ContentSpaceSummary[]; +}) { + if (args.databaseId !== args.catalogDatabaseId) return null; + return ( + args.spaces.find((space) => space.catalogDocumentId === args.documentId) ?? + null + ); +} + export async function selectContentSpace(args: { space: ContentSpaceSummary; activeOrgId: string | null | undefined; switchOrg: (orgId: string | null) => Promise; + syncApplicationState: (space: ContentSpaceSummary) => Promise; persistSelection: (spaceId: string) => void; + openFiles: (documentId: string) => void; }) { if (args.activeOrgId !== args.space.orgId) { await args.switchOrg(args.space.orgId); } + await args.syncApplicationState(args.space); args.persistSelection(args.space.id); + args.openFiles(args.space.filesDocumentId); } diff --git a/templates/content/app/entry.client.tsx b/templates/content/app/entry.client.tsx index 30b909ada4..32f3b2fd67 100644 --- a/templates/content/app/entry.client.tsx +++ b/templates/content/app/entry.client.tsx @@ -1,3 +1,4 @@ +import { stripBuildCompatibilityCacheBuster } from "@agent-native/core/client"; import { appBasePath } from "@agent-native/core/client/api-path"; import { hydrateRoot } from "react-dom/client"; import { HydratedRouter } from "react-router/dom"; @@ -17,3 +18,4 @@ if (context) { } hydrateRoot(document, ); +stripBuildCompatibilityCacheBuster(); diff --git a/templates/content/app/hooks/use-content-spaces.ts b/templates/content/app/hooks/use-content-spaces.ts index ee0b52e69c..960f3b9f4d 100644 --- a/templates/content/app/hooks/use-content-spaces.ts +++ b/templates/content/app/hooks/use-content-spaces.ts @@ -6,6 +6,7 @@ export type ContentSpaceSummary = { name: string; kind: string; filesDatabaseId: string; + filesDocumentId: string; orgId: string | null; role: "owner" | "editor" | "viewer"; catalogItemId: string; diff --git a/templates/content/changelog/2026-07-17-workspace-switching-now-opens-the-selected-files-database-pr.md b/templates/content/changelog/2026-07-17-workspace-switching-now-opens-the-selected-files-database-pr.md new file mode 100644 index 0000000000..fa3be40aad --- /dev/null +++ b/templates/content/changelog/2026-07-17-workspace-switching-now-opens-the-selected-files-database-pr.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-07-17 +--- + +Workspace switching now opens the selected Files database, preserves the choice across reloads, and provisions organization Files for every member. diff --git a/templates/content/shared/api.ts b/templates/content/shared/api.ts index 8f5ece9d7b..f391ec2d76 100644 --- a/templates/content/shared/api.ts +++ b/templates/content/shared/api.ts @@ -227,6 +227,7 @@ export interface ContentDatabase { id: string; documentId: string; title: string; + systemRole?: string | null; description?: string; viewConfig: ContentDatabaseViewConfig; createdAt: string; diff --git a/templates/content/vite.config.ts b/templates/content/vite.config.ts index 0e07970aa3..412fec4904 100644 --- a/templates/content/vite.config.ts +++ b/templates/content/vite.config.ts @@ -429,6 +429,7 @@ export default defineConfig({ contentLocalComponentsPlugin(), ...reactRouterPlugins(), ...agentNativePlugins({ + clientCompatibilityVersion: "content-spaces-v1", fsAllow: [ ...(localWorkspaceRoot ? [localWorkspaceRoot] : []), ...dynamicLocalComponentDirs, From 5a3a9bb1c6ea0cfdd1c4ef743426b86507424811 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:19:24 -0400 Subject: [PATCH 13/76] fix(content): switch workspaces from Files rows --- .../editor/database/DatabaseView.tsx | 7 +++- .../sidebar/select-content-space.test.ts | 39 +++++++++++++++++++ .../sidebar/select-content-space.ts | 10 ++++- 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/templates/content/app/components/editor/database/DatabaseView.tsx b/templates/content/app/components/editor/database/DatabaseView.tsx index 4f37bdd7bb..6e847bf09b 100644 --- a/templates/content/app/components/editor/database/DatabaseView.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.tsx @@ -1303,7 +1303,12 @@ function DatabaseTable({ } function openWorkspaceFiles(item: ContentDatabaseItem) { - if (data?.database.systemRole !== "workspaces") return false; + if ( + data?.database.systemRole !== "workspaces" && + data?.database.systemRole !== "files" + ) { + return false; + } void (async () => { const spacesResponse = contentSpacesQuery.data ?? (await contentSpacesQuery.refetch()).data; diff --git a/templates/content/app/components/sidebar/select-content-space.test.ts b/templates/content/app/components/sidebar/select-content-space.test.ts index e00207ac09..2bcbb6fe2c 100644 --- a/templates/content/app/components/sidebar/select-content-space.test.ts +++ b/templates/content/app/components/sidebar/select-content-space.test.ts @@ -225,6 +225,45 @@ describe("contentSpaceForCatalogItem", () => { ).toBe(builder); }); + it("maps workspace references surfaced in the personal Files database", () => { + const personal = space({ + id: "personal", + kind: "personal", + filesDatabaseId: "personal-files", + catalogDocumentId: "personal-reference", + }); + const builder = space({ + id: "builder", + kind: "organization", + filesDatabaseId: "builder-files", + catalogDocumentId: "builder-reference", + }); + expect( + contentSpaceForCatalogItem({ + databaseId: "personal-files", + catalogDatabaseId: "workspaces", + documentId: "builder-reference", + spaces: [personal, builder], + }), + ).toBe(builder); + }); + + it("does not treat workspace references in another space's Files database as selectors", () => { + const personal = space({ + id: "personal", + kind: "personal", + filesDatabaseId: "personal-files", + }); + expect( + contentSpaceForCatalogItem({ + databaseId: "organization-files", + catalogDatabaseId: "workspaces", + documentId: "builder-reference", + spaces: [personal, space({ catalogDocumentId: "builder-reference" })], + }), + ).toBeNull(); + }); + it("leaves ordinary database rows on the normal page-open path", () => { expect( contentSpaceForCatalogItem({ diff --git a/templates/content/app/components/sidebar/select-content-space.ts b/templates/content/app/components/sidebar/select-content-space.ts index 3b55478f87..a05f439e16 100644 --- a/templates/content/app/components/sidebar/select-content-space.ts +++ b/templates/content/app/components/sidebar/select-content-space.ts @@ -59,7 +59,15 @@ export function contentSpaceForCatalogItem(args: { documentId: string; spaces: ContentSpaceSummary[]; }) { - if (args.databaseId !== args.catalogDatabaseId) return null; + const personalFilesDatabaseId = args.spaces.find( + (space) => space.kind === "personal", + )?.filesDatabaseId; + if ( + args.databaseId !== args.catalogDatabaseId && + args.databaseId !== personalFilesDatabaseId + ) { + return null; + } return ( args.spaces.find((space) => space.catalogDocumentId === args.documentId) ?? null From 40fbfe4f56a28a13dbeff6e23f479e631eac7ee7 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:23:45 -0400 Subject: [PATCH 14/76] fix(content): scope organization Files pagination --- .../core/src/server/action-routes.spec.ts | 7 ++- packages/core/src/server/action-routes.ts | 5 +++ templates/content/actions/_database-utils.ts | 22 +++++++++- .../content/actions/content-files.db.test.ts | 43 ++++++++++++++++++- 4 files changed, 73 insertions(+), 4 deletions(-) diff --git a/packages/core/src/server/action-routes.spec.ts b/packages/core/src/server/action-routes.spec.ts index c26ccde7f1..1544224545 100644 --- a/packages/core/src/server/action-routes.spec.ts +++ b/packages/core/src/server/action-routes.spec.ts @@ -81,7 +81,10 @@ describe("mountActionRoutes", () => { mountActionRoutes(nitroApp, { test: { run } as any }); const event = { _method: "POST", - _headers: { "x-agent-native-frontend": "1" }, + _headers: { + origin: "https://embedded.example.com", + "x-agent-native-frontend": "1", + }, req: { json: vi.fn(async () => ({})) }, }; @@ -94,6 +97,8 @@ describe("mountActionRoutes", () => { _status: 409, _responseHeaders: { "cache-control": "no-store", + "access-control-expose-headers": + "X-Agent-Native-Client-Mismatch,X-Agent-Native-Build-Id,X-Agent-Native-Client-Compatibility", "x-agent-native-client-mismatch": "1", }, }); diff --git a/packages/core/src/server/action-routes.ts b/packages/core/src/server/action-routes.ts index eed57dce66..bead9bb0a9 100644 --- a/packages/core/src/server/action-routes.ts +++ b/packages/core/src/server/action-routes.ts @@ -311,6 +311,11 @@ export function mountActionRoutes( } setResponseHeader(event, "Cache-Control", "no-store"); + setResponseHeader( + event, + "Access-Control-Expose-Headers", + "X-Agent-Native-Client-Mismatch,X-Agent-Native-Build-Id,X-Agent-Native-Client-Compatibility", + ); // Allow the declared method if (effectiveMethod !== method) { diff --git a/templates/content/actions/_database-utils.ts b/templates/content/actions/_database-utils.ts index ae3df32274..95a4aec76f 100644 --- a/templates/content/actions/_database-utils.ts +++ b/templates/content/actions/_database-utils.ts @@ -250,15 +250,29 @@ export async function getContentDatabaseResponse( // shared one a viewer is opening) must not mutate schema. const { limit, offset } = normalizeContentDatabasePageOptions(options); + const organizationFilesItemFilter = + database.systemRole === "files" && database.orgId + ? sql`exists ( + select 1 from ${schema.documents} + where ${schema.documents.id} = ${schema.contentDatabaseItems.documentId} + and ${schema.documents.orgId} = ${database.orgId} + and ${schema.documents.visibility} in ('org', 'public') + and (${schema.documents.hideFromSearch} = 0 or ${schema.documents.hideFromSearch} is null) + )` + : undefined; + const visibleItemFilter = and( + eq(schema.contentDatabaseItems.databaseId, databaseId), + organizationFilesItemFilter, + ); const [itemCount] = await db .select({ count: sql`COUNT(*)` }) .from(schema.contentDatabaseItems) - .where(eq(schema.contentDatabaseItems.databaseId, databaseId)); + .where(visibleItemFilter); let itemsQuery = db .select() .from(schema.contentDatabaseItems) - .where(eq(schema.contentDatabaseItems.databaseId, databaseId)) + .where(visibleItemFilter) .orderBy(asc(schema.contentDatabaseItems.position)) .$dynamic(); if (limit !== null) { @@ -284,6 +298,10 @@ export async function getContentDatabaseResponse( eq(schema.documents.visibility, "org"), eq(schema.documents.visibility, "public"), ), + or( + eq(schema.documents.hideFromSearch, 0), + isNull(schema.documents.hideFromSearch), + ), ) : eq(schema.documents.ownerEmail, database.ownerEmail), ), diff --git a/templates/content/actions/content-files.db.test.ts b/templates/content/actions/content-files.db.test.ts index 0a8d6c8d4f..3993440f75 100644 --- a/templates/content/actions/content-files.db.test.ts +++ b/templates/content/actions/content-files.db.test.ts @@ -130,6 +130,11 @@ describe("Content Files membership reconciliation", () => { orgId: viewerOrgId, title: "Owner private page", }); + await createLegacyDocument({ + id: "hidden-org-page", + orgId: viewerOrgId, + title: "Hidden organization page", + }); await getDb() .update(schema.documents) .set({ content: "Keep this body exactly", icon: "📚" }) @@ -138,6 +143,10 @@ describe("Content Files membership reconciliation", () => { .update(schema.documents) .set({ visibility: "private" }) .where(eq(schema.documents.id, "owner-private-org")); + await getDb() + .update(schema.documents) + .set({ hideFromSearch: 1 }) + .where(eq(schema.documents.id, "hidden-org-page")); const [before] = await getDb() .select() .from(schema.documents) @@ -164,6 +173,21 @@ describe("Content Files membership reconciliation", () => { const filesDatabase = await getFilesDatabase( organizationContentSpaceId(viewerOrgId), ); + for (const [documentId, position] of [ + ["owner-private-org", 0], + ["hidden-org-page", 1], + ["viewer-legacy-org", 2], + ] as const) { + await getDb() + .update(schema.contentDatabaseItems) + .set({ position }) + .where( + and( + eq(schema.contentDatabaseItems.databaseId, filesDatabase.id), + eq(schema.contentDatabaseItems.documentId, documentId), + ), + ); + } await expect( getDb() .select() @@ -177,10 +201,19 @@ describe("Content Files membership reconciliation", () => { ).resolves.toHaveLength(1); const databaseResponse = await runWithRequestContext( { userEmail: VIEWER, orgId: viewerOrgId }, - () => getContentDatabaseAction.run({ databaseId: filesDatabase.id }), + () => + getContentDatabaseAction.run({ + databaseId: filesDatabase.id, + limit: 1, + }), ); expect(databaseResponse).toMatchObject({ database: { id: filesDatabase.id, systemRole: "files" }, + pagination: { + totalItems: 1, + returnedItems: 1, + hasMore: false, + }, items: expect.arrayContaining([ expect.objectContaining({ document: expect.objectContaining({ @@ -197,12 +230,20 @@ describe("Content Files membership reconciliation", () => { }), ]), ); + expect(databaseResponse.items).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + document: expect.objectContaining({ id: "hidden-org-page" }), + }), + ]), + ); await getDb() .delete(schema.documents) .where( inArray(schema.documents.id, [ "viewer-legacy-org", "owner-private-org", + "hidden-org-page", ]), ); }); From 37c3edd9efdce7e0feac22321339e9532e862f32 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:31:14 -0400 Subject: [PATCH 15/76] fix(content): preserve private workspace boundaries --- templates/content/actions/_content-files.ts | 44 +++++- .../content/actions/_database-utils.test.ts | 30 ++++ templates/content/actions/_database-utils.ts | 38 ++++- .../content/actions/content-files.db.test.ts | 147 ++++++++++++++++++ .../editor/database/DatabaseView.tsx | 18 +-- 5 files changed, 261 insertions(+), 16 deletions(-) diff --git a/templates/content/actions/_content-files.ts b/templates/content/actions/_content-files.ts index 0ed41207b6..820319de85 100644 --- a/templates/content/actions/_content-files.ts +++ b/templates/content/actions/_content-files.ts @@ -239,13 +239,23 @@ export async function reconcileContentFilesMemberships( const email = normalizeContentSpaceEmail(userEmail); const memberships = await listContentOrganizationMemberships(email); const personalSpaceId = personalContentSpaceId(email); - const orgSpaceIds = new Map(); + const orgSpaces = new Map< + string, + { spaceId: string; canReconcilePrivateDocuments: boolean } + >(); for (const membership of memberships) { const spaceId = organizationContentSpaceId(membership.orgId); await resolveContentSpaceAccess(spaceId); - orgSpaceIds.set(membership.orgId, spaceId); + orgSpaces.set(membership.orgId, { + spaceId, + canReconcilePrivateDocuments: + membership.role === "owner" || membership.role === "admin", + }); } - const accessibleSpaceIds = [personalSpaceId, ...orgSpaceIds.values()]; + const accessibleSpaceIds = [ + personalSpaceId, + ...[...orgSpaces.values()].map(({ spaceId }) => spaceId), + ]; const now = new Date().toISOString(); const result: ContentFilesReconciliation = { assignedSpaces: 0, @@ -277,7 +287,7 @@ export async function reconcileContentFilesMemberships( ); result.assignedSpaces += personalLegacyDocuments.length; } - for (const [orgId, spaceId] of orgSpaceIds) { + for (const [orgId, orgSpace] of orgSpaces) { const legacyDocuments = await tx .select({ id: schema.documents.id }) .from(schema.documents) @@ -285,12 +295,19 @@ export async function reconcileContentFilesMemberships( and( eq(schema.documents.orgId, orgId), isNull(schema.documents.spaceId), + orgSpace.canReconcilePrivateDocuments + ? undefined + : or( + eq(schema.documents.ownerEmail, email), + eq(schema.documents.visibility, "org"), + eq(schema.documents.visibility, "public"), + ), ), ); if (!legacyDocuments.length) continue; await tx .update(schema.documents) - .set({ spaceId, updatedAt: now }) + .set({ spaceId: orgSpace.spaceId, updatedAt: now }) .where( inArray( schema.documents.id, @@ -300,10 +317,25 @@ export async function reconcileContentFilesMemberships( result.assignedSpaces += legacyDocuments.length; } - const documents = await tx + const accessibleDocuments = await tx .select() .from(schema.documents) .where(inArray(schema.documents.spaceId, accessibleSpaceIds)); + const orgSpaceById = new Map( + [...orgSpaces.values()].map((space) => [space.spaceId, space]), + ); + const documents = accessibleDocuments.filter((document: any) => { + if (document.spaceId === personalSpaceId) { + return normalizeContentSpaceEmail(document.ownerEmail) === email; + } + const orgSpace = orgSpaceById.get(document.spaceId); + return ( + orgSpace?.canReconcilePrivateDocuments === true || + normalizeContentSpaceEmail(document.ownerEmail) === email || + document.visibility === "org" || + document.visibility === "public" + ); + }); const filesDatabases = await tx .select() .from(schema.contentDatabases) diff --git a/templates/content/actions/_database-utils.test.ts b/templates/content/actions/_database-utils.test.ts index c9e8b0bab9..845ff89125 100644 --- a/templates/content/actions/_database-utils.test.ts +++ b/templates/content/actions/_database-utils.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { contentDatabaseListDocumentSelection, + filterContentDatabaseSourceForVisibleDocuments, filterContentDatabaseSourceRowsForPage, filterDatabaseContainedDocuments, } from "./_database-utils"; @@ -17,6 +18,35 @@ describe("content database list projections", () => { }); }); +describe("filterContentDatabaseSourceForVisibleDocuments", () => { + it("removes private source rows and their related change sets", () => { + const visibleRow = { documentId: "visible", sourceValues: { safe: true } }; + const privateRow = { + documentId: "private", + sourceValues: { secret: "must not cross the organization boundary" }, + }; + const source = { + id: "source", + rows: [visibleRow, privateRow], + changeSets: [ + { documentId: "visible", summary: "Visible change" }, + { documentId: "private", summary: "Private change" }, + ], + }; + + expect( + filterContentDatabaseSourceForVisibleDocuments( + source, + new Set(["visible"]), + ), + ).toEqual({ + id: "source", + rows: [visibleRow], + changeSets: [source.changeSets[0]], + }); + }); +}); + function doc(id: string, parentId: string | null = null) { return { id, parentId }; } diff --git a/templates/content/actions/_database-utils.ts b/templates/content/actions/_database-utils.ts index 95a4aec76f..6fd350a004 100644 --- a/templates/content/actions/_database-utils.ts +++ b/templates/content/actions/_database-utils.ts @@ -195,6 +195,24 @@ export function filterContentDatabaseSourceRowsForPage< ); } +export function filterContentDatabaseSourceForVisibleDocuments< + TSource extends { + rows: Array<{ documentId: string }>; + changeSets: Array<{ documentId: string | null }>; + }, +>(source: TSource, visibleDocumentIds: ReadonlySet): TSource { + return { + ...source, + rows: source.rows.filter( + (row) => !row.documentId || visibleDocumentIds.has(row.documentId), + ), + changeSets: source.changeSets.filter( + (changeSet) => + !changeSet.documentId || visibleDocumentIds.has(changeSet.documentId), + ), + }; +} + function serializeDocument( doc: DocumentListRow, membership?: DatabaseMembershipRow, @@ -355,7 +373,25 @@ export async function getContentDatabaseResponse( }); } - const sources = await getAllContentDatabaseSourceSnapshots(database); + const sourceSnapshots = await getAllContentDatabaseSourceSnapshots(database); + const organizationVisibleDocumentIds = organizationFilesItemFilter + ? new Set( + ( + await db + .select({ documentId: schema.contentDatabaseItems.documentId }) + .from(schema.contentDatabaseItems) + .where(visibleItemFilter) + ).map((item) => item.documentId), + ) + : null; + const sources = organizationVisibleDocumentIds + ? sourceSnapshots.map((source) => + filterContentDatabaseSourceForVisibleDocuments( + source, + organizationVisibleDocumentIds, + ), + ) + : sourceSnapshots; const serializedDocumentIds = new Set( serializedItems.map((item) => item.document.id), ); diff --git a/templates/content/actions/content-files.db.test.ts b/templates/content/actions/content-files.db.test.ts index 3993440f75..3edb154199 100644 --- a/templates/content/actions/content-files.db.test.ts +++ b/templates/content/actions/content-files.db.test.ts @@ -53,6 +53,10 @@ beforeAll(async () => { sql: "INSERT INTO org_members (id, org_id, email, role, joined_at) VALUES (?, ?, ?, ?, ?)", args: ["files-owner-membership", ORG_ID, OWNER, "owner", Date.now()], }); + await getDbExec().execute({ + sql: "INSERT INTO org_members (id, org_id, email, role, joined_at) VALUES (?, ?, ?, ?, ?)", + args: ["files-viewer-membership", ORG_ID, VIEWER, "member", Date.now()], + }); await runWithRequestContext({ userEmail: OWNER }, () => provisionContentSpaces(getDb(), OWNER), ); @@ -151,6 +155,10 @@ describe("Content Files membership reconciliation", () => { .select() .from(schema.documents) .where(eq(schema.documents.id, "viewer-legacy-org")); + const [privateBefore] = await getDb() + .select() + .from(schema.documents) + .where(eq(schema.documents.id, "owner-private-org")); await runWithRequestContext({ userEmail: VIEWER }, () => reconcileContentFilesMemberships(getDb(), VIEWER), @@ -170,6 +178,11 @@ describe("Content Files membership reconciliation", () => { visibility: before!.visibility, spaceId: organizationContentSpaceId(viewerOrgId), }); + const [privateAfter] = await getDb() + .select() + .from(schema.documents) + .where(eq(schema.documents.id, "owner-private-org")); + expect(privateAfter).toEqual(privateBefore); const filesDatabase = await getFilesDatabase( organizationContentSpaceId(viewerOrgId), ); @@ -306,6 +319,140 @@ describe("Content Files membership reconciliation", () => { ).toHaveLength(1); }); + it("does not expose private source rows or change sets through organization Files", async () => { + await createLegacyDocument({ + id: "source-visible-org", + orgId: ORG_ID, + title: "Visible source row", + }); + await createLegacyDocument({ + id: "source-private-org", + orgId: ORG_ID, + title: "Private source row", + }); + await getDb() + .update(schema.documents) + .set({ visibility: "private" }) + .where(eq(schema.documents.id, "source-private-org")); + await runWithRequestContext({ userEmail: OWNER, orgId: ORG_ID }, () => + reconcileContentFilesMemberships(getDb(), OWNER), + ); + const filesDatabase = await getFilesDatabase( + organizationContentSpaceId(ORG_ID), + ); + const items = await getDb() + .select() + .from(schema.contentDatabaseItems) + .where( + and( + eq(schema.contentDatabaseItems.databaseId, filesDatabase.id), + inArray(schema.contentDatabaseItems.documentId, [ + "source-visible-org", + "source-private-org", + ]), + ), + ); + const itemByDocumentId = new Map( + items.map((item: any) => [item.documentId, item]), + ); + const now = new Date().toISOString(); + await getDb().insert(schema.contentDatabaseSources).values({ + id: "private-boundary-source", + ownerEmail: OWNER, + orgId: ORG_ID, + databaseId: filesDatabase.id, + sourceType: "local-folder", + sourceName: "Private boundary source", + sourceTable: "private-boundary", + createdAt: now, + updatedAt: now, + }); + for (const documentId of ["source-visible-org", "source-private-org"]) { + const item = itemByDocumentId.get(documentId); + if (!item) throw new Error(`Missing Files item for ${documentId}`); + await getDb() + .insert(schema.contentDatabaseSourceRows) + .values({ + id: `${documentId}-source-row`, + ownerEmail: OWNER, + sourceId: "private-boundary-source", + databaseItemId: item.id, + documentId, + sourceRowId: documentId, + sourceQualifiedId: `source:${documentId}`, + sourceDisplayKey: documentId, + sourceValuesJson: JSON.stringify({ secret: documentId }), + createdAt: now, + updatedAt: now, + }); + await getDb() + .insert(schema.contentDatabaseSourceChangeSets) + .values({ + id: `${documentId}-change-set`, + ownerEmail: OWNER, + sourceId: "private-boundary-source", + databaseItemId: item.id, + documentId, + summary: `Change for ${documentId}`, + createdAt: now, + updatedAt: now, + }); + } + + const response = await runWithRequestContext( + { userEmail: VIEWER, orgId: ORG_ID }, + () => getContentDatabaseAction.run({ databaseId: filesDatabase.id }), + ); + expect(response.sources[0]?.rows.map((row) => row.documentId)).toContain( + "source-visible-org", + ); + expect( + response.sources[0]?.rows.map((row) => row.documentId), + ).not.toContain("source-private-org"); + expect( + response.sources[0]?.changeSets.map((changeSet) => changeSet.documentId), + ).toContain("source-visible-org"); + expect( + response.sources[0]?.changeSets.map((changeSet) => changeSet.documentId), + ).not.toContain("source-private-org"); + + await getDb() + .delete(schema.contentDatabaseSourceChangeSets) + .where( + eq( + schema.contentDatabaseSourceChangeSets.sourceId, + "private-boundary-source", + ), + ); + await getDb() + .delete(schema.contentDatabaseSourceRows) + .where( + eq( + schema.contentDatabaseSourceRows.sourceId, + "private-boundary-source", + ), + ); + await getDb() + .delete(schema.contentDatabaseSources) + .where(eq(schema.contentDatabaseSources.id, "private-boundary-source")); + await getDb() + .delete(schema.contentDatabaseItems) + .where( + inArray(schema.contentDatabaseItems.documentId, [ + "source-visible-org", + "source-private-org", + ]), + ); + await getDb() + .delete(schema.documents) + .where( + inArray(schema.documents.id, [ + "source-visible-org", + "source-private-org", + ]), + ); + }); + it("is idempotent and never adds a Files database backing document to a Files database", async () => { const personalFiles = await getFilesDatabase(personalContentSpaceId(OWNER)); const second = await runWithRequestContext({ userEmail: OWNER }, () => diff --git a/templates/content/app/components/editor/database/DatabaseView.tsx b/templates/content/app/components/editor/database/DatabaseView.tsx index 6e847bf09b..078b093718 100644 --- a/templates/content/app/components/editor/database/DatabaseView.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.tsx @@ -1309,16 +1309,16 @@ function DatabaseTable({ ) { return false; } + const spacesResponse = contentSpacesQuery.data; + if (!spacesResponse) return false; + const space = contentSpaceForCatalogItem({ + databaseId, + catalogDatabaseId: spacesResponse.catalogDatabaseId, + documentId: item.document.id, + spaces: spacesResponse.spaces, + }); + if (!space) return false; void (async () => { - const spacesResponse = - contentSpacesQuery.data ?? (await contentSpacesQuery.refetch()).data; - const space = contentSpaceForCatalogItem({ - databaseId, - catalogDatabaseId: spacesResponse?.catalogDatabaseId, - documentId: item.document.id, - spaces: spacesResponse?.spaces ?? [], - }); - if (!space) throw new Error("This workspace is no longer available"); await selectContentSpace({ space, activeOrgId: activeOrgQuery.data?.orgId, From 1b8c2d33f2f820da10ebddf2f254193983e70883 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:55:57 -0400 Subject: [PATCH 16/76] fix(content): restore ordinary-member workspace flows --- .../content/actions/_content-space-access.ts | 47 +++++++++- templates/content/actions/_database-utils.ts | 88 +++++++++++++++++-- .../content/actions/blocks-seeding.db.test.ts | 51 ++++++++++- .../content/actions/content-files.db.test.ts | 25 ++++++ .../actions/create-content-database.ts | 50 +++++++++-- templates/content/actions/create-document.ts | 5 +- .../actions/space-aware-writers.db.test.ts | 10 +-- .../content/app/components/EmptyState.tsx | 61 +------------ .../editor/database/DatabaseView.tsx | 53 ++++++----- .../sidebar/select-content-space.test.ts | 87 ++++++++++++++++++ .../sidebar/select-content-space.ts | 20 +++++ .../content/app/hooks/use-create-page.ts | 33 +++++++ ...ow-opens-the-selected-files-database-pr.md | 2 +- 13 files changed, 425 insertions(+), 107 deletions(-) diff --git a/templates/content/actions/_content-space-access.ts b/templates/content/actions/_content-space-access.ts index 0b113fcb65..39163fe0a5 100644 --- a/templates/content/actions/_content-space-access.ts +++ b/templates/content/actions/_content-space-access.ts @@ -1,6 +1,6 @@ import { getDbExec } from "@agent-native/core/db"; import { getRequestUserEmail } from "@agent-native/core/server/request-context"; -import { eq } from "drizzle-orm"; +import { and, eq, sql } from "drizzle-orm"; import { getDb, schema } from "../server/db/index.js"; @@ -21,7 +21,36 @@ export function normalizeContentSpaceEmail(email: string): string { export async function getContentOrganizationMembership( orgId: string, userEmail: string, + options: { db?: any } = {}, ): Promise<{ role: string; name: string; createdBy: string } | null> { + if (options.db) { + // The canonical tables carry the active database dialect. Loading them only + // for transaction-scoped checks avoids initializing auth timers elsewhere. + const { organizations, orgMembers } = + await import("@agent-native/core/org"); + const [row] = await options.db + .select({ + role: orgMembers.role, + name: organizations.name, + createdBy: organizations.createdBy, + }) + .from(orgMembers) + .innerJoin(organizations, eq(organizations.id, orgMembers.orgId)) + .where( + and( + eq(orgMembers.orgId, orgId), + sql`LOWER(${orgMembers.email}) = ${normalizeContentSpaceEmail(userEmail)}`, + ), + ) + .limit(1); + return row + ? { + role: String(row.role ?? "member").toLowerCase(), + name: row.name, + createdBy: row.createdBy, + } + : null; + } const result = await getDbExec().execute({ sql: `SELECT m.role AS role, o.name AS name, o.created_by AS "createdBy" FROM org_members m @@ -89,12 +118,13 @@ export async function listContentOrganizationMemberships(userEmail: string) { export async function resolveContentSpaceAccess( spaceId: string, - requiredRole: "viewer" | "editor" = "viewer", + requiredRole: "viewer" | "contributor" | "editor" = "viewer", + options: { db?: any } = {}, ): Promise { const userEmail = getRequestUserEmail(); if (!userEmail) throw new Error("no authenticated user"); const normalizedUserEmail = normalizeContentSpaceEmail(userEmail); - const [space] = await getDb() + const [space] = await (options.db ?? getDb()) .select() .from(schema.contentSpaces) .where(eq(schema.contentSpaces.id, spaceId)); @@ -115,6 +145,7 @@ export async function resolveContentSpaceAccess( const membership = await getContentOrganizationMembership( space.orgId, normalizedUserEmail, + options, ); if (!membership) throw new Error(`Not authorized for Content space "${spaceId}"`); @@ -124,6 +155,16 @@ export async function resolveContentSpaceAccess( : membership.role === "admin" ? "editor" : "viewer"; + if ( + requiredRole === "contributor" && + membership.role !== "owner" && + membership.role !== "admin" && + membership.role !== "member" + ) { + throw new Error( + `Contributor access is required for Content space "${spaceId}"`, + ); + } if (requiredRole === "editor" && role === "viewer") { throw new Error(`Editor access is required for Content space "${spaceId}"`); } diff --git a/templates/content/actions/_database-utils.ts b/templates/content/actions/_database-utils.ts index 6fd350a004..c6c4b4f29b 100644 --- a/templates/content/actions/_database-utils.ts +++ b/templates/content/actions/_database-utils.ts @@ -1,3 +1,8 @@ +import { + getRequestOrgId, + getRequestUserEmail, +} from "@agent-native/core/server/request-context"; +import { ROLE_RANK, type ShareRole } from "@agent-native/core/sharing"; import { and, asc, eq, inArray, isNull, or, sql } from "drizzle-orm"; import { getDb, schema } from "../server/db/index.js"; @@ -29,6 +34,15 @@ function canManageRole(role: string) { return role === "owner" || role === "admin"; } +function canEditRole(role: string) { + return role === "owner" || role === "admin" || role === "editor"; +} + +function strongerRole(current: ShareRole | null, next: ShareRole): ShareRole { + if (!current || ROLE_RANK[next] > ROLE_RANK[current]) return next; + return current; +} + type DatabaseMembershipRow = { item: typeof schema.contentDatabaseItems.$inferSelect; database: typeof schema.contentDatabases.$inferSelect; @@ -216,7 +230,12 @@ export function filterContentDatabaseSourceForVisibleDocuments< function serializeDocument( doc: DocumentListRow, membership?: DatabaseMembershipRow, + shareRole?: ShareRole, ) { + const isOwner = + doc.ownerEmail.trim().toLowerCase() === + getRequestUserEmail()?.trim().toLowerCase(); + const accessRole = isOwner ? ("owner" as const) : (shareRole ?? "viewer"); return { id: doc.id, parentId: doc.parentId, @@ -230,9 +249,9 @@ function serializeDocument( isFavorite: parseDocumentFavorite(doc.isFavorite), hideFromSearch: parseDocumentHideFromSearch(doc.hideFromSearch), visibility: doc.visibility, - accessRole: "owner" as const, - canEdit: true, - canManage: canManageRole("owner"), + accessRole, + canEdit: canEditRole(accessRole), + canManage: canManageRole(accessRole), databaseMembership: membership ? serializeDatabaseMembership(membership) : undefined, @@ -326,6 +345,55 @@ export async function getContentDatabaseResponse( ) : []; const documentById = new Map(documents.map((doc) => [doc.id, doc])); + const shareRoleByDocumentId = new Map(); + if (documents.length > 0) { + const principalClauses: NonNullable>[] = []; + const userEmail = getRequestUserEmail(); + const orgId = getRequestOrgId(); + if (userEmail) { + principalClauses.push( + and( + eq(schema.documentShares.principalType, "user"), + eq(schema.documentShares.principalId, userEmail), + )!, + ); + } + if (orgId) { + principalClauses.push( + and( + eq(schema.documentShares.principalType, "org"), + eq(schema.documentShares.principalId, orgId), + )!, + ); + } + const shareRows = + principalClauses.length > 0 + ? await db + .select({ + resourceId: schema.documentShares.resourceId, + role: schema.documentShares.role, + }) + .from(schema.documentShares) + .where( + and( + inArray( + schema.documentShares.resourceId, + documents.map((document) => document.id), + ), + or(...principalClauses), + ), + ) + : []; + for (const row of shareRows) { + shareRoleByDocumentId.set( + row.resourceId, + strongerRole( + shareRoleByDocumentId.get(row.resourceId) ?? null, + row.role, + ), + ); + } + } const propertiesByDocumentId = await listPropertiesForDatabaseDocuments( databaseId, // Property serialization uses metadata only; this list projection carries @@ -360,11 +428,15 @@ export async function getContentDatabaseResponse( serializedItems.push({ id: item.id, databaseId: item.databaseId, - document: serializeDocument(document, { - item, - database, - bodyHydrationQueueId: bodyHydrationQueued ? item.id : null, - }), + document: serializeDocument( + document, + { + item, + database, + bodyHydrationQueueId: bodyHydrationQueued ? item.id : null, + }, + shareRoleByDocumentId.get(document.id), + ), position: item.position, bodyHydration: serializeBodyHydration(item, { queued: bodyHydrationQueued, diff --git a/templates/content/actions/blocks-seeding.db.test.ts b/templates/content/actions/blocks-seeding.db.test.ts index 7d9c4f4c42..484fc36fba 100644 --- a/templates/content/actions/blocks-seeding.db.test.ts +++ b/templates/content/actions/blocks-seeding.db.test.ts @@ -9,7 +9,7 @@ import { join } from "node:path"; import { runWithRequestContext } from "@agent-native/core/server"; import { and, eq } from "drizzle-orm"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { countWords, @@ -33,6 +33,7 @@ let databaseUtils: typeof import("./_database-utils.js"); let createInlineContentDatabaseAction: typeof import("./create-inline-content-database.js").default; let updateDocumentAction: typeof import("./update-document.js").default; let createContentDatabaseAction: typeof import("./create-content-database.js").default; +let createContentDatabaseModule: typeof import("./create-content-database.js"); let getContentDatabaseAction: typeof import("./get-content-database.js").default; let getDocumentAction: typeof import("./get-document.js").default; let configureDocumentPropertyAction: typeof import("./configure-document-property.js").default; @@ -51,8 +52,8 @@ beforeAll(async () => { await import("./create-inline-content-database.js") ).default; updateDocumentAction = (await import("./update-document.js")).default; - createContentDatabaseAction = (await import("./create-content-database.js")) - .default; + createContentDatabaseModule = await import("./create-content-database.js"); + createContentDatabaseAction = createContentDatabaseModule.default; getContentDatabaseAction = (await import("./get-content-database.js")) .default; getDocumentAction = (await import("./get-document.js")).default; @@ -110,6 +111,50 @@ async function blocksDefinitions(databaseId: string) { } describe("seedDefaultBlocksField — single-primary invariant (findings 1, 2)", () => { + it("revalidates space access through the database transaction", async () => { + let transactionDb: any; + let resolvedSpaceAccess: any; + const resolveInsideTransaction = vi.fn( + async (_spaceId: string, _role: string, options?: { db?: any }) => { + expect(options?.db).toBe(transactionDb); + return resolvedSpaceAccess; + }, + ); + + const databaseId = await runWithRequestContext( + { userEmail: OWNER }, + async () => { + const db = getDb(); + const args = { title: "Hosted transaction boundary" }; + const spaceId = + await createContentDatabaseModule.resolveContentDatabaseSpace( + args, + db, + ); + resolvedSpaceAccess = await ( + await import("./_content-space-access.js") + ).resolveContentSpaceAccess(spaceId, "editor"); + let createdId: string | null = null; + await db.transaction(async (tx: any) => { + transactionDb = tx; + createdId = + await createContentDatabaseModule.createContentDatabaseRecord( + args, + { + db: tx, + spaceId, + resolveSpaceAccess: resolveInsideTransaction, + }, + ); + }); + return createdId; + }, + ); + + expect(databaseId).toEqual(expect.any(String)); + expect(resolveInsideTransaction).toHaveBeenCalledOnce(); + }); + it("round-trips owned descriptions and returns one live root-to-database row context path", async () => { const suffix = `${Date.now()}_${Math.random().toString(36).slice(2, 7)}`; const rootId = `root_${suffix}`; diff --git a/templates/content/actions/content-files.db.test.ts b/templates/content/actions/content-files.db.test.ts index 3edb154199..0f4a8dbf10 100644 --- a/templates/content/actions/content-files.db.test.ts +++ b/templates/content/actions/content-files.db.test.ts @@ -23,6 +23,7 @@ let personalContentSpaceId: typeof import("./_content-spaces.js").personalConten let organizationContentSpaceId: typeof import("./_content-spaces.js").organizationContentSpaceId; let reconcileContentFilesMemberships: typeof import("./_content-files.js").reconcileContentFilesMemberships; let getContentDatabaseAction: typeof import("./get-content-database.js").default; +let getDocumentAction: typeof import("./get-document.js").default; beforeAll(async () => { process.env.DATABASE_URL = `file:${TEST_DB_PATH}`; @@ -37,6 +38,7 @@ beforeAll(async () => { ({ reconcileContentFilesMemberships } = await import("./_content-files.js")); getContentDatabaseAction = (await import("./get-content-database.js")) .default; + getDocumentAction = (await import("./get-document.js")).default; const plugin = (await import("../server/plugins/db.js")).default; await plugin(undefined as any); await getDbExec().execute(`CREATE TABLE IF NOT EXISTS organizations ( @@ -151,6 +153,15 @@ describe("Content Files membership reconciliation", () => { .update(schema.documents) .set({ hideFromSearch: 1 }) .where(eq(schema.documents.id, "hidden-org-page")); + await getDb().insert(schema.documentShares).values({ + id: "viewer-visible-editor-share", + resourceId: "viewer-legacy-org", + principalType: "user", + principalId: VIEWER, + role: "editor", + createdBy: OWNER, + createdAt: new Date().toISOString(), + }); const [before] = await getDb() .select() .from(schema.documents) @@ -232,6 +243,9 @@ describe("Content Files membership reconciliation", () => { document: expect.objectContaining({ id: "viewer-legacy-org", title: "Member can reconcile", + accessRole: "editor", + canEdit: true, + canManage: false, }), }), ]), @@ -250,6 +264,17 @@ describe("Content Files membership reconciliation", () => { }), ]), ); + const openedDocument = await runWithRequestContext( + { userEmail: VIEWER, orgId: viewerOrgId }, + () => getDocumentAction.run({ id: "viewer-legacy-org" }), + ); + expect(openedDocument).toMatchObject({ + id: "viewer-legacy-org", + title: "Member can reconcile", + content: "Keep this body exactly", + accessRole: "editor", + canEdit: true, + }); await getDb() .delete(schema.documents) .where( diff --git a/templates/content/actions/create-content-database.ts b/templates/content/actions/create-content-database.ts index 69ac589edd..c77aac1f54 100644 --- a/templates/content/actions/create-content-database.ts +++ b/templates/content/actions/create-content-database.ts @@ -5,7 +5,13 @@ import { getRequestOrgId, getRequestUserEmail, } from "@agent-native/core/server/request-context"; -import { assertAccess, type ShareRole } from "@agent-native/core/sharing"; +import { + accessFilter, + assertAccess, + currentAccess, + ForbiddenError, + type ShareRole, +} from "@agent-native/core/sharing"; import { and, eq, sql } from "drizzle-orm"; import { z } from "zod"; @@ -154,13 +160,38 @@ export async function resolveContentDatabaseSpace( if (!userEmail) throw new Error("no authenticated user"); const provisioned = await provisionContentSpaces(db, userEmail); const spaceId = args.spaceId ?? provisioned.personalSpaceId; - await resolveContentSpaceAccess(spaceId, "editor"); + await resolveContentSpaceAccess(spaceId, "contributor"); return spaceId; } +async function assertDocumentEditorAccess(db: any, documentId: string) { + const [document] = await db + .select() + .from(schema.documents) + .where( + and( + eq(schema.documents.id, documentId), + accessFilter( + schema.documents, + schema.documentShares, + currentAccess(), + "editor", + ), + ), + ); + if (!document) { + throw new ForbiddenError(`No editor access to document ${documentId}`); + } + return document; +} + export async function createContentDatabaseRecord( args: CreateDatabaseRequest, - options: { db?: any; spaceId?: string } = {}, + options: { + db?: any; + spaceId?: string; + resolveSpaceAccess?: typeof resolveContentSpaceAccess; + } = {}, ): Promise { const db = options.db ?? getDb(); const now = new Date().toISOString(); @@ -178,8 +209,7 @@ export async function createContentDatabaseRecord( }> = []; if (documentId) { - const access = await assertAccess("document", documentId, "editor"); - const document = access.resource; + const document = await assertDocumentEditorAccess(db, documentId); ownerEmail = document.ownerEmail as string; orgId = (document.orgId as string | null) ?? null; spaceId = (document.spaceId as string | null) ?? spaceId; @@ -230,8 +260,7 @@ export async function createContentDatabaseRecord( let hideFromSearch = 0; if (parentId) { - const parentAccess = await assertAccess("document", parentId, "editor"); - const parent = parentAccess.resource; + const parent = await assertDocumentEditorAccess(db, parentId); ownerEmail = parent.ownerEmail as string; orgId = (parent.orgId as string | null) ?? null; spaceId = (parent.spaceId as string | null) ?? spaceId; @@ -257,7 +286,12 @@ export async function createContentDatabaseRecord( "A top-level database requires a resolved Content space", ); } - const spaceAccess = await resolveContentSpaceAccess(spaceId, "editor"); + const spaceAccess = await ( + options.resolveSpaceAccess ?? resolveContentSpaceAccess + )(spaceId, "contributor", { db }); + if (spaceAccess.space.id !== spaceId) { + throw new Error("Resolved Content space does not match the request"); + } ownerEmail = getRequestUserEmail() ?? ownerEmail; orgId = spaceAccess.space.orgId; visibility = orgId ? "org" : "private"; diff --git a/templates/content/actions/create-document.ts b/templates/content/actions/create-document.ts index 921ca53ba1..beb1571ec8 100644 --- a/templates/content/actions/create-document.ts +++ b/templates/content/actions/create-document.ts @@ -211,7 +211,10 @@ export default defineAction({ } else { const provisioned = await provisionContentSpaces(db, currentUserEmail); spaceId = args.spaceId ?? provisioned.personalSpaceId; - const spaceAccess = await resolveContentSpaceAccess(spaceId, "editor"); + const spaceAccess = await resolveContentSpaceAccess( + spaceId, + "contributor", + ); ownerEmail = currentUserEmail; orgId = spaceAccess.space.orgId; visibility = orgId ? "org" : "private"; diff --git a/templates/content/actions/space-aware-writers.db.test.ts b/templates/content/actions/space-aware-writers.db.test.ts index 4cde92681f..fa8cc27868 100644 --- a/templates/content/actions/space-aware-writers.db.test.ts +++ b/templates/content/actions/space-aware-writers.db.test.ts @@ -128,10 +128,10 @@ describe("space-aware document writers", () => { ).rejects.toThrow("parent Content space"); }); - it("requires organization editor access for root page and database creation", async () => { + it("lets ordinary organization members create root pages and databases while guests remain read-only", async () => { const orgId = "org-shared-writers"; - await addOrganizationMember({ orgId, email: MEMBER, role: "admin" }); - await addOrganizationMember({ orgId, email: VIEWER }); + await addOrganizationMember({ orgId, email: MEMBER }); + await addOrganizationMember({ orgId, email: VIEWER, role: "guest" }); const spaceId = organizationContentSpaceId(orgId); const created = await runWithRequestContext( { userEmail: MEMBER, orgId }, @@ -171,12 +171,12 @@ describe("space-aware document writers", () => { runWithRequestContext({ userEmail: VIEWER, orgId }, () => createDocument.run({ title: "Viewer page", spaceId }), ), - ).rejects.toThrow("Editor access is required"); + ).rejects.toThrow("Contributor access is required"); await expect( runWithRequestContext({ userEmail: VIEWER, orgId }, () => createContentDatabase.run({ title: "Viewer database", spaceId }), ), - ).rejects.toThrow("Editor access is required"); + ).rejects.toThrow("Contributor access is required"); await expect( runWithRequestContext({ userEmail: OUTSIDER }, () => createDocument.run({ title: "No entry", spaceId }), diff --git a/templates/content/app/components/EmptyState.tsx b/templates/content/app/components/EmptyState.tsx index 0cb75e89b4..6b35a71932 100644 --- a/templates/content/app/components/EmptyState.tsx +++ b/templates/content/app/components/EmptyState.tsx @@ -1,68 +1,15 @@ import { useT } from "@agent-native/core/client"; -import type { Document } from "@shared/api"; import { IconFileText, IconPlus } from "@tabler/icons-react"; -import { useQueryClient } from "@tanstack/react-query"; -import { useNavigate } from "react-router"; -import { toast } from "sonner"; import { Button } from "@/components/ui/button"; -import { useCreateDocument } from "@/hooks/use-documents"; - -function nanoid(size = 12): string { - const chars = - "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; - const bytes = crypto.getRandomValues(new Uint8Array(size)); - return Array.from(bytes, (b) => chars[b % chars.length]).join(""); -} +import { useCreatePage } from "@/hooks/use-create-page"; export function EmptyState() { - const navigate = useNavigate(); - const queryClient = useQueryClient(); - const createDocument = useCreateDocument(); + const createPage = useCreatePage(); const t = useT(); - const handleCreate = async () => { - const id = nanoid(); - const now = new Date().toISOString(); - const tempDoc: Document = { - id, - parentId: null, - title: "", - content: "", - icon: null, - position: 9999, - isFavorite: false, - hideFromSearch: false, - visibility: "private", - createdAt: now, - updatedAt: now, - }; - - // Optimistically inject into cache and navigate immediately - queryClient.setQueryData( - ["action", "list-documents", undefined], - (old: any) => { - const docs: Document[] = - old?.documents ?? (Array.isArray(old) ? old : []); - return { documents: [...docs, tempDoc] }; - }, - ); - queryClient.setQueryData(["action", "get-document", { id }], tempDoc); - navigate(`/page/${id}`, { flushSync: true }); - - try { - await createDocument.mutateAsync({ id, title: "" }); - } catch (err) { - queryClient.invalidateQueries({ queryKey: ["action", "list-documents"] }); - queryClient.removeQueries({ - queryKey: ["action", "get-document", { id }], - }); - navigate("/"); - toast.error(t("empty.createFailed"), { - description: - err instanceof Error ? err.message : t("empty.genericError"), - }); - } + const handleCreate = () => { + void createPage().catch(() => undefined); }; return ( diff --git a/templates/content/app/components/editor/database/DatabaseView.tsx b/templates/content/app/components/editor/database/DatabaseView.tsx index 078b093718..e66d703013 100644 --- a/templates/content/app/components/editor/database/DatabaseView.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.tsx @@ -150,6 +150,7 @@ import { toast } from "sonner"; import { contentSpaceForCatalogItem, + createContentSpaceSelectionQueue, SELECTED_CONTENT_SPACE_STORAGE_KEY, selectContentSpace, } from "@/components/sidebar/select-content-space"; @@ -839,6 +840,11 @@ function DatabaseTable({ const builderContinuationWatchdogRef = useRef>(new Map()); const refreshSourceInFlightRef = useRef(null); const hydrationSourceInFlightRef = useRef(null); + const workspaceSelectionQueueRef = useRef(createContentSpaceSelectionQueue()); + const activeOrgIdRef = useRef(activeOrgQuery.data?.orgId); + useEffect(() => { + activeOrgIdRef.current = activeOrgQuery.data?.orgId; + }, [activeOrgQuery.data?.orgId]); const builderReviewGenerationRef = useRef(0); const builderReviewSessionRef = useRef(null); const [ @@ -1318,28 +1324,33 @@ function DatabaseTable({ spaces: spacesResponse.spaces, }); if (!space) return false; - void (async () => { - await selectContentSpace({ - space, - activeOrgId: activeOrgQuery.data?.orgId, - switchOrg: (orgId) => switchOrg.mutateAsync(orgId), - syncApplicationState: (selected) => - setClientAppState( - "content-space", - { - spaceId: selected.id, - name: selected.name, - kind: selected.kind, - filesDatabaseId: selected.filesDatabaseId, - }, - { requestSource: "content-workspaces-database" }, - ), - persistSelection: setStoredSpaceId, - openFiles: (documentId) => navigate(`/page/${documentId}`), + void workspaceSelectionQueueRef + .current(async () => { + await selectContentSpace({ + space, + activeOrgId: activeOrgIdRef.current, + switchOrg: async (orgId) => { + await switchOrg.mutateAsync(orgId); + activeOrgIdRef.current = orgId; + }, + syncApplicationState: (selected) => + setClientAppState( + "content-space", + { + spaceId: selected.id, + name: selected.name, + kind: selected.kind, + filesDatabaseId: selected.filesDatabaseId, + }, + { requestSource: "content-workspaces-database" }, + ), + persistSelection: setStoredSpaceId, + openFiles: (documentId) => navigate(`/page/${documentId}`), + }); + }) + .catch((error) => { + toast.error(error instanceof Error ? error.message : String(error)); }); - })().catch((error) => { - toast.error(error instanceof Error ? error.message : String(error)); - }); return true; } diff --git a/templates/content/app/components/sidebar/select-content-space.test.ts b/templates/content/app/components/sidebar/select-content-space.test.ts index 2bcbb6fe2c..b32c966406 100644 --- a/templates/content/app/components/sidebar/select-content-space.test.ts +++ b/templates/content/app/components/sidebar/select-content-space.test.ts @@ -6,6 +6,8 @@ import { contentSpaceAvailability, contentSpaceForActiveOrg, contentSpaceForCatalogItem, + contentSpaceIdForCreate, + createContentSpaceSelectionQueue, selectContentSpace, } from "./select-content-space"; @@ -27,6 +29,71 @@ function space( } describe("selectContentSpace", () => { + it("serializes rapid workspace selections", async () => { + const enqueue = createContentSpaceSelectionQueue(); + const events: string[] = []; + let activeOrgId: string | null = null; + let releaseFirst!: () => void; + let markFirstStarted!: () => void; + const firstPending = new Promise((resolve) => { + releaseFirst = resolve; + }); + const firstStarted = new Promise((resolve) => { + markFirstStarted = resolve; + }); + + const personal = space({ + id: "personal", + kind: "personal", + orgId: null, + filesDocumentId: "personal-files", + }); + const builder = space({ + id: "builder", + orgId: "builder-org", + filesDocumentId: "builder-files", + }); + const select = (selected: ContentSpaceSummary, wait = false) => + enqueue(() => + selectContentSpace({ + space: selected, + activeOrgId, + switchOrg: async (orgId) => { + events.push(`switch:${orgId}`); + if (wait) { + markFirstStarted(); + await firstPending; + } + activeOrgId = orgId; + }, + syncApplicationState: async (next) => { + events.push(`state:${next.id}`); + }, + persistSelection: (id) => events.push(`persist:${id}`), + openFiles: (id) => events.push(`open:${id}`), + }), + ); + + const first = select(builder, true); + const second = select(personal); + + await firstStarted; + expect(events).toEqual(["switch:builder-org"]); + releaseFirst(); + await Promise.all([first, second]); + expect(events).toEqual([ + "switch:builder-org", + "state:builder", + "persist:builder", + "open:builder-files", + "switch:null", + "state:personal", + "persist:personal", + "open:personal-files", + ]); + expect(activeOrgId).toBeNull(); + }); + it("supports Personal to organization to Personal with one complete flow per selection", async () => { let activeOrgId: string | null = null; let storedSpaceId: string | null = null; @@ -276,6 +343,26 @@ describe("contentSpaceForCatalogItem", () => { }); }); +describe("contentSpaceIdForCreate", () => { + it("uses the selected workspace for a root page", () => { + expect( + contentSpaceIdForCreate({ selectedSpace: space(), parentId: undefined }), + ).toBe("space_1"); + }); + + it("fails closed while root-page workspace selection is unresolved", () => { + expect(() => + contentSpaceIdForCreate({ selectedSpace: null, parentId: undefined }), + ).toThrow("Files are still loading"); + }); + + it("lets nested pages inherit their parent workspace", () => { + expect( + contentSpaceIdForCreate({ selectedSpace: null, parentId: "parent" }), + ).toBeUndefined(); + }); +}); + describe("contentSpaceForActiveOrg", () => { it("keeps the stored workspace when its organization is active", () => { const selected = space({ id: "space_2", orgId: "org_1" }); diff --git a/templates/content/app/components/sidebar/select-content-space.ts b/templates/content/app/components/sidebar/select-content-space.ts index a05f439e16..23eb07b6c8 100644 --- a/templates/content/app/components/sidebar/select-content-space.ts +++ b/templates/content/app/components/sidebar/select-content-space.ts @@ -74,6 +74,17 @@ export function contentSpaceForCatalogItem(args: { ); } +export function contentSpaceIdForCreate(args: { + parentId?: string; + selectedSpace: ContentSpaceSummary | null; +}) { + if (args.parentId) return undefined; + if (!args.selectedSpace) { + throw new Error("Files are still loading. Try creating the page again."); + } + return args.selectedSpace.id; +} + export async function selectContentSpace(args: { space: ContentSpaceSummary; activeOrgId: string | null | undefined; @@ -89,3 +100,12 @@ export async function selectContentSpace(args: { args.persistSelection(args.space.id); args.openFiles(args.space.filesDocumentId); } + +export function createContentSpaceSelectionQueue() { + let pending = Promise.resolve(); + return (selection: () => Promise) => { + const next = pending.catch(() => undefined).then(selection); + pending = next; + return next; + }; +} diff --git a/templates/content/app/hooks/use-create-page.ts b/templates/content/app/hooks/use-create-page.ts index 47b765ce96..9e0e85e522 100644 --- a/templates/content/app/hooks/use-create-page.ts +++ b/templates/content/app/hooks/use-create-page.ts @@ -1,10 +1,18 @@ +import { useOrg } from "@agent-native/core/client/org"; import type { Document } from "@shared/api"; import { useQueryClient } from "@tanstack/react-query"; import { useCallback } from "react"; import { useNavigate } from "react-router"; import { toast } from "sonner"; +import { + contentSpaceForActiveOrg, + contentSpaceIdForCreate, + SELECTED_CONTENT_SPACE_STORAGE_KEY, +} from "@/components/sidebar/select-content-space"; +import { useContentSpaces } from "@/hooks/use-content-spaces"; import { useCreateDocument } from "@/hooks/use-documents"; +import { useLocalStorage } from "@/hooks/use-local-storage"; const LIST_DOCUMENTS_QUERY_KEY = [ "action", @@ -27,12 +35,35 @@ export function useCreatePage(opts?: { const navigate = useNavigate(); const queryClient = useQueryClient(); const createDocument = useCreateDocument(); + const contentSpacesQuery = useContentSpaces(); + const activeOrgQuery = useOrg(); + const [storedSpaceId] = useLocalStorage( + SELECTED_CONTENT_SPACE_STORAGE_KEY, + null, + ); + const selectedSpace = contentSpaceForActiveOrg({ + spaces: contentSpacesQuery.data?.spaces ?? [], + storedSpaceId, + activeOrgId: activeOrgQuery.data?.orgId, + }); const onAfterNavigate = opts?.onAfterNavigate; const shouldNavigate = opts?.navigate ?? true; const shouldAwaitPersist = opts?.awaitPersist ?? true; return useCallback( async (parentId?: string) => { + let spaceId: string | undefined; + try { + spaceId = contentSpaceIdForCreate({ + parentId, + selectedSpace, + }); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Files are still loading", + ); + throw error; + } const id = nanoid(); const now = new Date().toISOString(); const tempDoc: Document = { @@ -66,6 +97,7 @@ export function useCreatePage(opts?: { id, title: "", parentId: parentId ?? undefined, + spaceId, }); // Replace optimistic doc with real server doc + clear any 404 error // state from the in-flight fetch that ran before create completed. @@ -109,6 +141,7 @@ export function useCreatePage(opts?: { navigate, onAfterNavigate, queryClient, + selectedSpace, shouldAwaitPersist, shouldNavigate, ], diff --git a/templates/content/changelog/2026-07-17-workspace-switching-now-opens-the-selected-files-database-pr.md b/templates/content/changelog/2026-07-17-workspace-switching-now-opens-the-selected-files-database-pr.md index fa3be40aad..a5d4e5f330 100644 --- a/templates/content/changelog/2026-07-17-workspace-switching-now-opens-the-selected-files-database-pr.md +++ b/templates/content/changelog/2026-07-17-workspace-switching-now-opens-the-selected-files-database-pr.md @@ -3,4 +3,4 @@ type: fixed date: 2026-07-17 --- -Workspace switching now opens the selected Files database, preserves the choice across reloads, and provisions organization Files for every member. +Workspace switching now opens the selected Files database, preserves the choice across reloads, provisions organization Files for every member, and creates new pages in the selected workspace. From 96939d6cedbcfa8c68559b98ffab1c5877078c49 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:06:03 -0400 Subject: [PATCH 17/76] fix(content): isolate workspace database caches --- .../app/hooks/use-content-database.test.ts | 37 +++++++++++++++++++ .../content/app/hooks/use-content-database.ts | 36 ++++++++++++++++-- 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/templates/content/app/hooks/use-content-database.test.ts b/templates/content/app/hooks/use-content-database.test.ts index 774036c1db..c93d045463 100644 --- a/templates/content/app/hooks/use-content-database.test.ts +++ b/templates/content/app/hooks/use-content-database.test.ts @@ -14,6 +14,7 @@ import { contentDatabaseQueryKey, invalidateBuilderBodyHydrationQueries, invalidateContentDatabaseSourceRefreshQueries, + preserveScopedDatabasePlaceholder, readCachedContentDatabaseResponse, removeDocumentPropertyFromDatabaseResponse, writeContentDatabaseResponseToCache, @@ -21,6 +22,42 @@ import { const createdAt = "2026-06-15T12:00:00.000Z"; +describe("preserveScopedDatabasePlaceholder", () => { + const previous = { database: "organization-files" }; + + it("preserves data while only pagination changes within one database", () => { + expect( + preserveScopedDatabasePlaceholder( + previous, + { + queryKey: [ + "action", + "get-content-database", + { documentId: "organization-files", limit: 50 }, + ], + }, + { documentId: "organization-files" }, + ), + ).toBe(previous); + }); + + it("never carries database rows across workspace database ids", () => { + expect( + preserveScopedDatabasePlaceholder( + previous, + { + queryKey: [ + "action", + "get-content-database", + { databaseId: "organization-files" }, + ], + }, + { databaseId: "personal-files" }, + ), + ).toBeUndefined(); + }); +}); + function databaseResponse(): ContentDatabaseResponse { return { database: { diff --git a/templates/content/app/hooks/use-content-database.ts b/templates/content/app/hooks/use-content-database.ts index f260cc2b72..b5cb8b507c 100644 --- a/templates/content/app/hooks/use-content-database.ts +++ b/templates/content/app/hooks/use-content-database.ts @@ -56,6 +56,27 @@ export function contentDatabaseByIdQueryKey(databaseId: string) { return ["action", "get-content-database", { databaseId }] as const; } +export function preserveScopedDatabasePlaceholder( + previous: T | undefined, + previousQuery: Pick | undefined, + scope: { documentId?: string; databaseId?: string }, +): T | undefined { + const previousParams = previousQuery?.queryKey[2]; + if (!previousParams || typeof previousParams !== "object") return undefined; + + const params = previousParams as { + documentId?: unknown; + databaseId?: unknown; + }; + if (scope.documentId !== undefined) { + return params.documentId === scope.documentId ? previous : undefined; + } + if (scope.databaseId !== undefined) { + return params.databaseId === scope.databaseId ? previous : undefined; + } + return undefined; +} + function isContentDatabaseQueryForDocument( queryKey: readonly unknown[], documentId: string, @@ -446,7 +467,10 @@ export function useContentDatabase(documentId: string | null, limit?: number) { { enabled: !!documentId, retry: false, - placeholderData: (previous) => previous, + placeholderData: (previous, previousQuery) => + preserveScopedDatabasePlaceholder(previous, previousQuery, { + documentId: documentId ?? undefined, + }), initialData: () => documentId ? readCachedContentDatabaseResponse(queryClient, documentId) @@ -465,7 +489,10 @@ export function useContentDatabaseById(databaseId: string | null) { { enabled: !!databaseId, retry: false, - placeholderData: (previous) => previous, + placeholderData: (previous, previousQuery) => + preserveScopedDatabasePlaceholder(previous, previousQuery, { + databaseId: databaseId ?? undefined, + }), }, ); } @@ -716,7 +743,10 @@ export function useContentDatabasePersonalView(databaseId: string | null) { { enabled: !!databaseId, retry: false, - placeholderData: (previous) => previous, + placeholderData: (previous, previousQuery) => + preserveScopedDatabasePlaceholder(previous, previousQuery, { + databaseId: databaseId ?? undefined, + }), }, ); } From 75ffe59e1942d340202e0ec0bc61d8e4e7299449 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:23:08 -0400 Subject: [PATCH 18/76] fix(core): honor explicit personal context --- .changeset/honor-personal-org-context.md | 5 ++ packages/core/src/org/context.spec.ts | 53 +++++++++++++-- packages/core/src/org/context.ts | 86 +++++++++++++++--------- 3 files changed, 110 insertions(+), 34 deletions(-) create mode 100644 .changeset/honor-personal-org-context.md diff --git a/.changeset/honor-personal-org-context.md b/.changeset/honor-personal-org-context.md new file mode 100644 index 0000000000..11ad248c37 --- /dev/null +++ b/.changeset/honor-personal-org-context.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Honor an explicit Personal context for users who also belong to organizations. diff --git a/packages/core/src/org/context.spec.ts b/packages/core/src/org/context.spec.ts index eefd82cca0..37fd56316a 100644 --- a/packages/core/src/org/context.spec.ts +++ b/packages/core/src/org/context.spec.ts @@ -25,6 +25,7 @@ vi.mock("../settings/store.js", () => ({ import { getOrgContext, resolveOrgIdForEmail, + resolveOrgIdForEmailViaEvent, createOrganization, getOrgDomain, getOrgA2ASecret, @@ -176,15 +177,43 @@ describe("getOrgContext", () => { expect(ctx.orgId).toBe("first"); }); - it("does NOT consult active-org-id when in exactly one org", async () => { + it("falls back to the only membership when no active-org preference exists", async () => { mockGetSession.mockResolvedValue({ email: "a@b.com" }); queueSelect([{ orgId: "only", role: "owner", orgName: "Only Co" }]); const ctx = await getOrgContext(EVENT); expect(ctx.orgId).toBe("only"); - expect(mockGetUserSetting).not.toHaveBeenCalled(); + expect(mockGetUserSetting).toHaveBeenCalledWith("a@b.com", "active-org-id"); expect(mockExecute).toHaveBeenCalledTimes(1); }); + it("honors an explicit Personal choice for a user in exactly one org", async () => { + mockGetSession.mockResolvedValue({ + email: "a@b.com", + orgId: "only", + orgRole: "owner", + }); + queueSelect([{ orgId: "only", role: "owner", orgName: "Only Co" }]); + mockGetUserSetting.mockResolvedValue({ orgId: null }); + + expect(await getOrgContext(EVENT)).toEqual({ + email: "a@b.com", + orgId: null, + orgName: null, + role: null, + }); + }); + + it("honors an explicit Personal choice for a user in multiple orgs", async () => { + mockGetSession.mockResolvedValue({ email: "a@b.com", orgId: "first" }); + queueSelect([ + { orgId: "first", role: "owner", orgName: "First Co" }, + { orgId: "second", role: "member", orgName: "Second Co" }, + ]); + mockGetUserSetting.mockResolvedValue({ orgId: null }); + + expect((await getOrgContext(EVENT)).orgId).toBeNull(); + }); + it("does not run domain auto-join for a single non-personal org", async () => { mockGetSession.mockResolvedValue({ email: "member@builder.io" }); queueSelect([{ orgId: "builder", role: "member", orgName: "Builder.io" }]); @@ -600,10 +629,26 @@ describe("resolveOrgIdForEmail", () => { expect(await resolveOrgIdForEmail("nobody@b.com")).toBeNull(); }); - it("returns the single membership without consulting active-org-id", async () => { + it("returns the single membership when no active-org preference exists", async () => { queueSelect([{ org_id: "only" }]); expect(await resolveOrgIdForEmail("a@b.com")).toBe("only"); - expect(mockGetUserSetting).not.toHaveBeenCalled(); + expect(mockGetUserSetting).toHaveBeenCalledWith("a@b.com", "active-org-id"); + }); + + it("returns null for an explicit Personal choice with one membership", async () => { + queueSelect([{ org_id: "only" }]); + mockGetUserSetting.mockResolvedValue({ orgId: null }); + + expect(await resolveOrgIdForEmail("a@b.com")).toBeNull(); + }); + + it("keeps an explicit Personal choice during HTTP session org backfill", async () => { + queueSelect([{ orgId: "only", role: "owner", orgName: "Only Co" }]); + mockGetUserSetting.mockResolvedValue({ orgId: null }); + + expect( + await resolveOrgIdForEmailViaEvent(makeEvent(), "a@b.com"), + ).toBeNull(); }); it("prefers active-org-id when it is one of multiple memberships", async () => { diff --git a/packages/core/src/org/context.ts b/packages/core/src/org/context.ts index 240b946e93..deb569282e 100644 --- a/packages/core/src/org/context.ts +++ b/packages/core/src/org/context.ts @@ -41,7 +41,8 @@ const nanoid = (): string => /** * Resolve the current user's organization context from their session. * - * - For users in multiple orgs, honors their `active-org-id` user setting. + * - Honors the user's `active-org-id` setting, including an explicit Personal + * context represented by `{ orgId: null }`. * - Falls back to the user's first membership. * - When the authenticated user has zero memberships, provisions a default org * named after the user ({name}'s workspace, falling back to the email @@ -63,6 +64,34 @@ export async function getOrgContext(event: H3Event): Promise { type MembershipRow = { orgId: string; role: OrgRole; orgName: string }; const MEMBERSHIPS_CACHE_KEY = "__anOrgMembershipsCache"; +const ACTIVE_ORG_SETTING_CACHE_KEY = "__anActiveOrgSettingCache"; + +type ActiveOrgSetting = { orgId: string | null } | null; + +function loadActiveOrgSettingForEvent( + event: H3Event, + email: string, +): Promise { + const ctx = event.context as Record; + const cache = ((ctx[ACTIVE_ORG_SETTING_CACHE_KEY] as + | Map> + | undefined) ?? + (ctx[ACTIVE_ORG_SETTING_CACHE_KEY] = new Map< + string, + Promise + >())) as Map>; + const normalizedEmail = email.toLowerCase(); + let promise = cache.get(normalizedEmail); + if (!promise) { + promise = getUserSetting(email, "active-org-id").then((value) => { + if (!value || !("orgId" in value)) return null; + if (value.orgId === null) return { orgId: null }; + return typeof value.orgId === "string" ? { orgId: value.orgId } : null; + }); + cache.set(normalizedEmail, promise); + } + return promise; +} /** * Per-request memoization of the org_members lookup, keyed by email on @@ -128,32 +157,29 @@ async function resolveOrgContextUncached(event: H3Event): Promise { return { email, orgId: null, orgName: null, role: null }; } - if (memberships.length > 1) { - const activeOrgSetting = (await getUserSetting(email, "active-org-id")) as { - orgId: string; - } | null; - if (activeOrgSetting?.orgId) { - const active = memberships.find( - (m) => m.orgId === activeOrgSetting.orgId, - ); - if (active) { - return { - email, - orgId: active.orgId, - orgName: active.orgName, - role: active.role, - }; - } + const shouldTryDomainAutoJoin = + memberships.length === 0 || + (memberships.length === 1 && + isLikelyPersonalWorkspace(memberships[0], email, session)); + const activeOrgSetting = await loadActiveOrgSettingForEvent(event, email); + if (activeOrgSetting?.orgId === null) { + return { email, orgId: null, orgName: null, role: null }; + } + if (activeOrgSetting?.orgId) { + const active = memberships.find((m) => m.orgId === activeOrgSetting.orgId); + if (active && !shouldTryDomainAutoJoin) { + return { + email, + orgId: active.orgId, + orgName: active.orgName, + role: active.role, + }; } } const sessionMembership = sessionOrgId ? memberships.find((m) => m.orgId === sessionOrgId) : null; - const shouldTryDomainAutoJoin = - memberships.length === 0 || - (memberships.length === 1 && - isLikelyPersonalWorkspace(memberships[0], email, session)); if (shouldTryDomainAutoJoin) { const joined = await autoJoinDomainMatchingOrgs(email, { @@ -248,7 +274,8 @@ async function loadMemberships( /** * Resolve the active org ID for a given email — for non-HTTP contexts like * the integration webhook handler where we have an email but no event/session. - * Picks the user's active-org-id setting if set, otherwise the first membership. + * Picks the user's active-org-id setting if set, including explicit Personal, + * otherwise the first membership. * Returns null if the user has no memberships. */ export async function resolveOrgIdForEmail( @@ -263,10 +290,11 @@ export async function resolveOrgIdForEmail( }); if (rows.length === 0) return null; const ids = rows.map((r: any) => String(r.org_id)); - if (ids.length === 1) return ids[0]; - const activeOrgSetting = (await getUserSetting(email, "active-org-id")) as { - orgId: string; - } | null; + const activeOrgSetting = (await getUserSetting( + email, + "active-org-id", + )) as ActiveOrgSetting; + if (activeOrgSetting?.orgId === null) return null; if (activeOrgSetting?.orgId && ids.includes(activeOrgSetting.orgId)) { return activeOrgSetting.orgId; } @@ -291,10 +319,8 @@ export async function resolveOrgIdForEmailViaEvent( if (!exec) return null; const memberships = await loadMembershipsForEvent(event, exec, email); if (!memberships || memberships.length === 0) return null; - if (memberships.length === 1) return memberships[0].orgId; - const activeOrgSetting = (await getUserSetting(email, "active-org-id")) as { - orgId: string; - } | null; + const activeOrgSetting = await loadActiveOrgSettingForEvent(event, email); + if (activeOrgSetting?.orgId === null) return null; if ( activeOrgSetting?.orgId && memberships.some((m) => m.orgId === activeOrgSetting.orgId) From e94493c01bf8f57a98100af4ff5ec0cf8912aab5 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:25:01 -0400 Subject: [PATCH 19/76] test(core): cover personal context cache --- packages/core/src/org/context.spec.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/core/src/org/context.spec.ts b/packages/core/src/org/context.spec.ts index 37fd56316a..bc66730c0d 100644 --- a/packages/core/src/org/context.spec.ts +++ b/packages/core/src/org/context.spec.ts @@ -214,6 +214,17 @@ describe("getOrgContext", () => { expect((await getOrgContext(EVENT)).orgId).toBeNull(); }); + it("shares the Personal preference read with HTTP session org backfill", async () => { + mockGetSession.mockResolvedValue({ email: "a@b.com" }); + queueSelect([{ orgId: "only", role: "owner", orgName: "Only Co" }]); + mockGetUserSetting.mockResolvedValue({ orgId: null }); + + expect(await resolveOrgIdForEmailViaEvent(EVENT, "a@b.com")).toBeNull(); + expect((await getOrgContext(EVENT)).orgId).toBeNull(); + expect(mockExecute).toHaveBeenCalledTimes(1); + expect(mockGetUserSetting).toHaveBeenCalledTimes(1); + }); + it("does not run domain auto-join for a single non-personal org", async () => { mockGetSession.mockResolvedValue({ email: "member@builder.io" }); queueSelect([{ orgId: "builder", role: "member", orgName: "Builder.io" }]); From aaedde3f417a1043f6f2909c7cf81a53d1acc5fa Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:34:40 -0400 Subject: [PATCH 20/76] fix(content): switch workspaces from sidebar rows --- .../editor/database/sidebar.test.tsx | 58 ++++++++++++++++- .../components/editor/database/sidebar.tsx | 15 ++++- .../components/sidebar/DocumentSidebar.tsx | 63 +++++++++++++------ 3 files changed, 114 insertions(+), 22 deletions(-) diff --git a/templates/content/app/components/editor/database/sidebar.test.tsx b/templates/content/app/components/editor/database/sidebar.test.tsx index 670f7ba235..be4881e290 100644 --- a/templates/content/app/components/editor/database/sidebar.test.tsx +++ b/templates/content/app/components/editor/database/sidebar.test.tsx @@ -1,7 +1,11 @@ +// @vitest-environment happy-dom + import type { ContentDatabaseItem } from "@shared/api"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; import { renderToStaticMarkup } from "react-dom/server"; import { MemoryRouter } from "react-router"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { DatabaseSidebarView, databaseSidebarRows } from "./sidebar"; import type { DatabaseBoardGroup } from "./types"; @@ -69,4 +73,56 @@ describe("DatabaseSidebarView", () => { expect(markup).toContain('href="/page/page"'); expect(markup).toContain("Project"); }); + + it("lets the Files sidebar intercept a workspace reference row", async () => { + const onOpenItem = vi.fn(() => true); + const container = document.createElement("div"); + const root = createRoot(container); + + await act(async () => { + root.render( + + {}} + onPreview={() => {}} + onOpenItem={onOpenItem} + /> + , + ); + }); + + const click = new MouseEvent("click", { + bubbles: true, + cancelable: true, + button: 0, + }); + await act(async () => { + container.querySelector("a")?.dispatchEvent(click); + }); + + expect(onOpenItem).toHaveBeenCalledWith( + expect.objectContaining({ id: "item-workspace" }), + ); + expect(click.defaultPrevented).toBe(true); + + await act(async () => root.unmount()); + }); }); diff --git a/templates/content/app/components/editor/database/sidebar.tsx b/templates/content/app/components/editor/database/sidebar.tsx index d561b76572..a5a1190f34 100644 --- a/templates/content/app/components/editor/database/sidebar.tsx +++ b/templates/content/app/components/editor/database/sidebar.tsx @@ -70,11 +70,13 @@ export function ContentFilesSidebarView({ isLoading, labels, onSelectView, + onOpenItem, }: { data: ContentDatabaseResponse | undefined; overrides: ContentDatabasePersonalViewOverrides | null | undefined; isLoading: boolean; onSelectView?: (viewId: string) => void; + onOpenItem?: (item: ContentDatabaseItem) => boolean; labels: Omit< Parameters[0], | "groups" @@ -151,6 +153,7 @@ export function ContentFilesSidebarView({ openPagesIn="full_page" onClearResultConstraints={() => {}} onPreview={() => {}} + onOpenItem={onOpenItem} /> ); @@ -164,6 +167,7 @@ export function DatabaseSidebarView({ openPagesIn, onClearResultConstraints, onPreview, + onOpenItem, loadingLabel, noMatchesLabel, clearLabel, @@ -177,6 +181,7 @@ export function DatabaseSidebarView({ openPagesIn: ContentDatabaseOpenPagesIn; onClearResultConstraints: () => void; onPreview: (item: ContentDatabaseItem) => void; + onOpenItem?: (item: ContentDatabaseItem) => boolean; loadingLabel: string; noMatchesLabel: string; clearLabel: string; @@ -254,6 +259,7 @@ export function DatabaseSidebarView({ item={item} openPagesIn={openPagesIn} onPreview={onPreview} + onOpenItem={onOpenItem} untitledLabel={untitledLabel} /> ))} @@ -267,6 +273,7 @@ export function DatabaseSidebarView({ item={item} openPagesIn={openPagesIn} onPreview={onPreview} + onOpenItem={onOpenItem} untitledLabel={untitledLabel} /> ))} @@ -279,16 +286,17 @@ function DatabaseSidebarRow({ item, openPagesIn, onPreview, + onOpenItem, untitledLabel, }: { item: ContentDatabaseItem; openPagesIn: ContentDatabaseOpenPagesIn; onPreview: (item: ContentDatabaseItem) => void; + onOpenItem?: (item: ContentDatabaseItem) => boolean; untitledLabel: string; }) { function handleClick(event: MouseEvent) { if ( - openPagesIn !== "preview" || event.defaultPrevented || event.button !== 0 || event.metaKey || @@ -298,6 +306,11 @@ function DatabaseSidebarRow({ ) { return; } + if (onOpenItem?.(item)) { + event.preventDefault(); + return; + } + if (openPagesIn !== "preview") return; event.preventDefault(); onPreview(item); } diff --git a/templates/content/app/components/sidebar/DocumentSidebar.tsx b/templates/content/app/components/sidebar/DocumentSidebar.tsx index 47a7c2e6a2..3fc7bb9a1f 100644 --- a/templates/content/app/components/sidebar/DocumentSidebar.tsx +++ b/templates/content/app/components/sidebar/DocumentSidebar.tsx @@ -122,6 +122,8 @@ import { NotionButton } from "./NotionButton"; import { contentSpaceAvailability, contentSpaceForActiveOrg, + contentSpaceForCatalogItem, + createContentSpaceSelectionQueue, SELECTED_CONTENT_SPACE_STORAGE_KEY, selectContentSpace, } from "./select-content-space"; @@ -241,6 +243,11 @@ export function DocumentSidebar({ const ensureContentSpaces = useEnsureContentSpaces(); const activeOrgQuery = useOrg(); const activeOrg = activeOrgQuery.data; + const activeOrgIdRef = useRef(activeOrg?.orgId); + const workspaceSelectionQueueRef = useRef(createContentSpaceSelectionQueue()); + useEffect(() => { + activeOrgIdRef.current = activeOrg?.orgId; + }, [activeOrg?.orgId]); const switchOrg = useSwitchOrg(); const contentSpaces = contentSpacesQuery.data?.spaces ?? []; const spaceProvisionAttemptedRef = useRef(false); @@ -299,30 +306,35 @@ export function DocumentSidebar({ const handleSelectContentSpace = useCallback( async (space: (typeof contentSpaces)[number]) => { try { - await selectContentSpace({ - space, - activeOrgId: activeOrg?.orgId, - switchOrg: (orgId) => switchOrg.mutateAsync(orgId), - syncApplicationState: (selected) => - setClientAppState( - "content-space", - { - spaceId: selected.id, - name: selected.name, - kind: selected.kind, - filesDatabaseId: selected.filesDatabaseId, - }, - { requestSource: "content-sidebar" }, - ), - persistSelection: setStoredSpaceId, - openFiles: (documentId) => - navigate(`/page/${documentId}`, { flushSync: true }), - }); + await workspaceSelectionQueueRef.current(() => + selectContentSpace({ + space, + activeOrgId: activeOrgIdRef.current, + switchOrg: async (orgId) => { + await switchOrg.mutateAsync(orgId); + activeOrgIdRef.current = orgId; + }, + syncApplicationState: (selected) => + setClientAppState( + "content-space", + { + spaceId: selected.id, + name: selected.name, + kind: selected.kind, + filesDatabaseId: selected.filesDatabaseId, + }, + { requestSource: "content-sidebar" }, + ), + persistSelection: setStoredSpaceId, + openFiles: (documentId) => + navigate(`/page/${documentId}`, { flushSync: true }), + }), + ); } catch (error) { toast.error(error instanceof Error ? error.message : String(error)); } }, - [activeOrg?.orgId, navigate, setStoredSpaceId, switchOrg], + [navigate, setStoredSpaceId, switchOrg], ); useEffect(() => { if (!selectedSpace) return; @@ -1140,6 +1152,17 @@ export function DocumentSidebar({ data={filesDatabase.data} overrides={filesPersonalView.data?.overrides} isLoading={filesDatabase.isLoading || filesPersonalView.isLoading} + onOpenItem={(item) => { + const space = contentSpaceForCatalogItem({ + databaseId: selectedSpace.filesDatabaseId, + catalogDatabaseId: contentSpacesQuery.data?.catalogDatabaseId, + documentId: item.document.id, + spaces: contentSpaces, + }); + if (!space) return false; + void handleSelectContentSpace(space); + return true; + }} onSelectView={(viewId) => { if (!selectedSpace) return; const current = filesPersonalView.data?.overrides; From 631c7e5da14689f1f12eee05c144ce38f0bbd09f Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:20:14 -0400 Subject: [PATCH 21/76] fix(content): nest Files beneath workspaces --- templates/content/actions/_content-files.ts | 45 +++++- templates/content/actions/_content-spaces.ts | 28 ---- .../content/actions/content-files.db.test.ts | 66 ++++++++ .../content/actions/content-spaces.db.test.ts | 5 + .../editor/database/DatabaseView.tsx | 5 +- .../sidebar/DocumentSidebar.layout.test.ts | 12 ++ .../components/sidebar/DocumentSidebar.tsx | 149 +++++++++--------- .../sidebar/select-content-space.test.ts | 4 +- .../sidebar/select-content-space.ts | 8 +- ...-above-their-own-files-list-without-dup.md | 6 + 10 files changed, 210 insertions(+), 118 deletions(-) create mode 100644 templates/content/changelog/2026-07-17-workspaces-now-expand-above-their-own-files-list-without-dup.md diff --git a/templates/content/actions/_content-files.ts b/templates/content/actions/_content-files.ts index 820319de85..a82e0fbb42 100644 --- a/templates/content/actions/_content-files.ts +++ b/templates/content/actions/_content-files.ts @@ -1,7 +1,7 @@ import { createHash } from "node:crypto"; import { accessFilter } from "@agent-native/core/sharing"; -import { and, eq, inArray, isNull, or, sql } from "drizzle-orm"; +import { and, eq, inArray, isNotNull, isNull, or, sql } from "drizzle-orm"; import { schema } from "../server/db/index.js"; import { @@ -62,9 +62,44 @@ async function reconcileDocuments(args: { args.filesDatabases.map((database) => [database.spaceId, database]), ); const filesDatabaseIds = args.filesDatabases.map((database) => database.id); - const filesBackingDocumentIds = new Set( - args.filesDatabases.map((database) => database.documentId), - ); + const documentIds = args.documents.map((document) => document.id); + const [systemDatabaseDocuments, workspaceReferenceDocuments] = + documentIds.length > 0 + ? await Promise.all([ + args.db + .select({ id: schema.contentDatabases.documentId }) + .from(schema.contentDatabases) + .where( + and( + inArray(schema.contentDatabases.documentId, documentIds), + isNotNull(schema.contentDatabases.systemRole), + ), + ), + args.db + .select({ id: schema.contentSpaceCatalogItems.documentId }) + .from(schema.contentSpaceCatalogItems) + .innerJoin( + schema.contentDatabases, + eq( + schema.contentDatabases.id, + schema.contentSpaceCatalogItems.catalogDatabaseId, + ), + ) + .where( + and( + inArray( + schema.contentSpaceCatalogItems.documentId, + documentIds, + ), + eq(schema.contentDatabases.systemRole, "workspaces"), + ), + ), + ]) + : [[], []]; + const excludedDocumentIds = new Set([ + ...systemDatabaseDocuments.map((document: any) => document.id), + ...workspaceReferenceDocuments.map((document: any) => document.id), + ]); const existingItems = filesDatabaseIds.length ? await args.db .select() @@ -93,7 +128,7 @@ async function reconcileDocuments(args: { const inserts: Array = []; for (const document of args.documents) { const existing = itemsByDocument.get(document.id) ?? []; - if (filesBackingDocumentIds.has(document.id)) { + if (excludedDocumentIds.has(document.id)) { for (const item of existing) deleteIds.add(item.id); continue; } diff --git a/templates/content/actions/_content-spaces.ts b/templates/content/actions/_content-spaces.ts index d9c48be355..a5aa372949 100644 --- a/templates/content/actions/_content-spaces.ts +++ b/templates/content/actions/_content-spaces.ts @@ -267,15 +267,6 @@ export async function provisionContentSpaces( now, created: result.created, }); - await ensureDatabaseItem({ - db: tx, - databaseId: personalFiles.id, - documentId: catalog.documentId, - ownerEmail: email, - orgId: null, - position: 0, - now, - }); const spaces = [ { @@ -386,15 +377,6 @@ export async function provisionContentSpaces( position: index, now, }); - await ensureDatabaseItem({ - db: tx, - databaseId: personalFiles.id, - documentId: referenceDocumentId, - ownerEmail: email, - orgId: null, - position: index + 1, - now, - }); const [existingCatalogItem] = await tx .select({ id: schema.contentSpaceCatalogItems.id }) .from(schema.contentSpaceCatalogItems) @@ -488,7 +470,6 @@ export async function provisionSourceBackedContentSpace( const now = new Date().toISOString(); const spaceId = sourceBackedContentSpaceId(email, connectionId); const personalSpaceId = personalContentSpaceId(email); - const personalFilesIds = systemIdsForContentSpace(personalSpaceId, "files"); const catalogIds = systemIdsForContentSpace(personalSpaceId, "workspaces"); const created: ProvisionedContentSpaces["created"] = { spaces: 0, @@ -580,15 +561,6 @@ export async function provisionSourceBackedContentSpace( position: (maxCatalogPosition?.max ?? -1) + 1, now, }); - await ensureDatabaseItem({ - db: tx, - databaseId: personalFilesIds.databaseId, - documentId: referenceDocumentId, - ownerEmail: email, - orgId: null, - position: (maxCatalogPosition?.max ?? -1) + 1, - now, - }); const [mapping] = await tx .select({ id: schema.contentSpaceCatalogItems.id }) .from(schema.contentSpaceCatalogItems) diff --git a/templates/content/actions/content-files.db.test.ts b/templates/content/actions/content-files.db.test.ts index 0f4a8dbf10..5c95764ce1 100644 --- a/templates/content/actions/content-files.db.test.ts +++ b/templates/content/actions/content-files.db.test.ts @@ -109,6 +109,72 @@ async function getFilesDatabase(spaceId: string) { } describe("Content Files membership reconciliation", () => { + it("removes system databases and workspace references from Personal Files", async () => { + const personalSpaceId = personalContentSpaceId(OWNER); + const personalFiles = await getFilesDatabase(personalSpaceId); + const [workspacesDatabase] = await getDb() + .select() + .from(schema.contentDatabases) + .where( + and( + eq(schema.contentDatabases.spaceId, personalSpaceId), + eq(schema.contentDatabases.systemRole, "workspaces"), + ), + ); + const [workspaceReference] = await getDb() + .select() + .from(schema.contentSpaceCatalogItems) + .where( + eq( + schema.contentSpaceCatalogItems.catalogDatabaseId, + workspacesDatabase.id, + ), + ); + const now = new Date().toISOString(); + await getDb() + .insert(schema.contentDatabaseItems) + .values([ + { + id: "legacy-workspaces-files-item", + ownerEmail: OWNER, + orgId: null, + databaseId: personalFiles.id, + documentId: workspacesDatabase.documentId, + position: 0, + createdAt: now, + updatedAt: now, + }, + { + id: "legacy-workspace-reference-files-item", + ownerEmail: OWNER, + orgId: null, + databaseId: personalFiles.id, + documentId: workspaceReference.documentId, + position: 1, + createdAt: now, + updatedAt: now, + }, + ]); + + await runWithRequestContext({ userEmail: OWNER }, () => + reconcileContentFilesMemberships(getDb(), OWNER), + ); + + const staleItems = await getDb() + .select() + .from(schema.contentDatabaseItems) + .where( + and( + eq(schema.contentDatabaseItems.databaseId, personalFiles.id), + inArray(schema.contentDatabaseItems.documentId, [ + workspacesDatabase.documentId, + workspaceReference.documentId, + ]), + ), + ); + expect(staleItems).toHaveLength(0); + }); + it("lets an ordinary member backfill legacy organization pages without changing their content or ownership", async () => { const viewerOrgId = "files-viewer-org"; await getDbExec().execute({ diff --git a/templates/content/actions/content-spaces.db.test.ts b/templates/content/actions/content-spaces.db.test.ts index ef529ffd83..a8ca54573e 100644 --- a/templates/content/actions/content-spaces.db.test.ts +++ b/templates/content/actions/content-spaces.db.test.ts @@ -125,6 +125,11 @@ describe("Content space provisioning", () => { ), ); expect(filesSelfItems).toHaveLength(0); + const filesItems = await getDb() + .select() + .from(schema.contentDatabaseItems) + .where(eq(schema.contentDatabaseItems.databaseId, files.id)); + expect(filesItems).toHaveLength(0); await runWithRequestContext({ userEmail: OWNER }, async () => { await expect(listContentSpacesAction.run({})).resolves.toMatchObject({ spaces: expect.arrayContaining([ diff --git a/templates/content/app/components/editor/database/DatabaseView.tsx b/templates/content/app/components/editor/database/DatabaseView.tsx index e66d703013..3a9a454644 100644 --- a/templates/content/app/components/editor/database/DatabaseView.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.tsx @@ -1309,10 +1309,7 @@ function DatabaseTable({ } function openWorkspaceFiles(item: ContentDatabaseItem) { - if ( - data?.database.systemRole !== "workspaces" && - data?.database.systemRole !== "files" - ) { + if (data?.database.systemRole !== "workspaces") { return false; } const spacesResponse = contentSpacesQuery.data; diff --git a/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts b/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts index e75a95a505..aa9ac95d0f 100644 --- a/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts +++ b/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts @@ -175,6 +175,18 @@ describe("document sidebar layout", () => { expect(messages).toContain('files: "Files"'); }); + it("nests the selected Files list beneath workspace disclosure rows", () => { + const sidebar = readSidebarSource("./DocumentSidebar.tsx"); + + expect(sidebar).toContain("aria-expanded={expanded}"); + expect(sidebar).toContain("collapsedWorkspaceId !== space.id"); + expect(sidebar).toContain("{expanded && renderSelectedWorkspaceFiles()}"); + expect(sidebar).toContain('t("sidebar.workspaces")'); + expect(sidebar).toContain('t("sidebar.files")'); + expect(sidebar).not.toContain("onOpenItem={(item) =>"); + expect(sidebar).not.toContain(""); + }); + it("keeps the trashed inline database lifecycle visible in the sidebar", () => { const sidebar = readSidebarSource("./DocumentSidebar.tsx"); const messages = readSidebarSource("../../i18n-data.ts"); diff --git a/templates/content/app/components/sidebar/DocumentSidebar.tsx b/templates/content/app/components/sidebar/DocumentSidebar.tsx index 3fc7bb9a1f..87e405a324 100644 --- a/templates/content/app/components/sidebar/DocumentSidebar.tsx +++ b/templates/content/app/components/sidebar/DocumentSidebar.tsx @@ -11,11 +11,7 @@ import { ExtensionSlot, ExtensionsSidebarSection, } from "@agent-native/core/client/extensions"; -import { - OrgSwitcher, - useOrg, - useSwitchOrg, -} from "@agent-native/core/client/org"; +import { useOrg, useSwitchOrg } from "@agent-native/core/client/org"; import { closestCenter, DndContext, @@ -45,6 +41,7 @@ import { IconLayoutSidebarLeftCollapse, IconLayoutSidebarLeftExpand, IconFolderOpen, + IconChevronDown, IconChevronRight, IconDots, IconTrash, @@ -122,7 +119,6 @@ import { NotionButton } from "./NotionButton"; import { contentSpaceAvailability, contentSpaceForActiveOrg, - contentSpaceForCatalogItem, createContentSpaceSelectionQueue, SELECTED_CONTENT_SPACE_STORAGE_KEY, selectContentSpace, @@ -274,6 +270,9 @@ export function DocumentSidebar({ storedSpaceId, activeOrgId: activeOrg?.orgId, }); + const [collapsedWorkspaceId, setCollapsedWorkspaceId] = useState< + string | null + >(null); const contentSpaceState = contentSpaceAvailability({ hasSelectedSpace: Boolean(selectedSpace), contentSpacesLoading: contentSpacesQuery.isLoading, @@ -1115,76 +1114,84 @@ export function DocumentSidebar({ ); }; - const renderWorkspaceNavigation = () => ( -
-
- {t("sidebar.workspaces")} + const renderSelectedWorkspaceFiles = () => ( +
+
+ {t("sidebar.files")}
- {contentSpaces.map((space) => ( - - ))} + { + if (!selectedSpace) return; + const current = filesPersonalView.data?.overrides; + updateFilesPersonalView.mutate({ + databaseId: selectedSpace.filesDatabaseId, + overrides: { + version: current?.version ?? 1, + activeViewId: viewId, + views: current?.views ?? [], + }, + }); + }} + labels={{ + loadingLabel: t("sidebar.loadingFiles"), + noMatchesLabel: t("database.noRowsMatchThisView"), + clearLabel: t("database.clearSearchAndFilters"), + navigationLabel: t("sidebar.files"), + untitledLabel: t("sidebar.untitled"), + }} + /> + {renderNewButton()}
); - const renderFilesDatabase = () => ( -
-
- {t("sidebar.files")} + const renderWorkspaceNavigation = () => ( +
+
+ {t("sidebar.workspaces")}
{contentSpaceState === "ready" && selectedSpace ? ( - <> - { - const space = contentSpaceForCatalogItem({ - databaseId: selectedSpace.filesDatabaseId, - catalogDatabaseId: contentSpacesQuery.data?.catalogDatabaseId, - documentId: item.document.id, - spaces: contentSpaces, - }); - if (!space) return false; - void handleSelectContentSpace(space); - return true; - }} - onSelectView={(viewId) => { - if (!selectedSpace) return; - const current = filesPersonalView.data?.overrides; - updateFilesPersonalView.mutate({ - databaseId: selectedSpace.filesDatabaseId, - overrides: { - version: current?.version ?? 1, - activeViewId: viewId, - views: current?.views ?? [], - }, - }); - }} - labels={{ - loadingLabel: t("sidebar.loadingFiles"), - noMatchesLabel: t("database.noRowsMatchThisView"), - clearLabel: t("database.clearSearchAndFilters"), - navigationLabel: t("sidebar.files"), - untitledLabel: t("sidebar.untitled"), - }} - /> - {renderNewButton()} - +
+ {contentSpaces.map((space) => { + const selected = selectedSpace.id === space.id; + const expanded = selected && collapsedWorkspaceId !== space.id; + return ( +
+ + {expanded && renderSelectedWorkspaceFiles()} +
+ ); + })} +
) : contentSpaceState === "loading" ? (
{t("sidebar.loadingFiles")} @@ -1526,7 +1533,6 @@ export function DocumentSidebar({ )} {renderWorkspaceNavigation()} - {renderFilesDatabase()} {renderTrashSection()} )} @@ -1558,7 +1564,6 @@ export function DocumentSidebar({ {/* Footer */}
- {isCodeMode ? : null}
diff --git a/templates/content/app/components/sidebar/select-content-space.test.ts b/templates/content/app/components/sidebar/select-content-space.test.ts index b32c966406..cc32570191 100644 --- a/templates/content/app/components/sidebar/select-content-space.test.ts +++ b/templates/content/app/components/sidebar/select-content-space.test.ts @@ -292,7 +292,7 @@ describe("contentSpaceForCatalogItem", () => { ).toBe(builder); }); - it("maps workspace references surfaced in the personal Files database", () => { + it("does not treat workspace catalog references as Files rows", () => { const personal = space({ id: "personal", kind: "personal", @@ -312,7 +312,7 @@ describe("contentSpaceForCatalogItem", () => { documentId: "builder-reference", spaces: [personal, builder], }), - ).toBe(builder); + ).toBeNull(); }); it("does not treat workspace references in another space's Files database as selectors", () => { diff --git a/templates/content/app/components/sidebar/select-content-space.ts b/templates/content/app/components/sidebar/select-content-space.ts index 23eb07b6c8..ed17736b1f 100644 --- a/templates/content/app/components/sidebar/select-content-space.ts +++ b/templates/content/app/components/sidebar/select-content-space.ts @@ -59,13 +59,7 @@ export function contentSpaceForCatalogItem(args: { documentId: string; spaces: ContentSpaceSummary[]; }) { - const personalFilesDatabaseId = args.spaces.find( - (space) => space.kind === "personal", - )?.filesDatabaseId; - if ( - args.databaseId !== args.catalogDatabaseId && - args.databaseId !== personalFilesDatabaseId - ) { + if (args.databaseId !== args.catalogDatabaseId) { return null; } return ( diff --git a/templates/content/changelog/2026-07-17-workspaces-now-expand-above-their-own-files-list-without-dup.md b/templates/content/changelog/2026-07-17-workspaces-now-expand-above-their-own-files-list-without-dup.md new file mode 100644 index 0000000000..a30ae39994 --- /dev/null +++ b/templates/content/changelog/2026-07-17-workspaces-now-expand-above-their-own-files-list-without-dup.md @@ -0,0 +1,6 @@ +--- +type: improved +date: 2026-07-17 +--- + +Workspaces now expand above their own Files list, without duplicate workspace rows or organization controls. From 03ee6676b63b733a2a7655d160d68b9d711bceca Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:34:25 -0400 Subject: [PATCH 22/76] fix(content): allow multiple workspace trees --- .../_content-database-personal-view.ts | 8 +- .../content/actions/content-files.db.test.ts | 29 +++ .../content/actions/get-content-database.ts | 13 +- .../sidebar/DocumentSidebar.layout.test.ts | 12 +- .../components/sidebar/DocumentSidebar.tsx | 209 ++++++++++++------ .../sidebar/select-content-space.test.ts | 23 ++ .../sidebar/select-content-space.ts | 18 ++ 7 files changed, 233 insertions(+), 79 deletions(-) diff --git a/templates/content/actions/_content-database-personal-view.ts b/templates/content/actions/_content-database-personal-view.ts index 14afb63ebf..91b1a02883 100644 --- a/templates/content/actions/_content-database-personal-view.ts +++ b/templates/content/actions/_content-database-personal-view.ts @@ -4,6 +4,7 @@ import { and, eq, isNull } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; +import { resolveContentSpaceAccess } from "./_content-space-access.js"; export const PERSONAL_DATABASE_VIEW_OVERRIDES_VERSION = 1; @@ -64,7 +65,12 @@ export async function assertContentDatabaseViewerAccess(databaseId: string) { ); if (!database) throw new Error(`Database "${databaseId}" not found`); - await assertAccess("document", database.documentId, "viewer"); + try { + await assertAccess("document", database.documentId, "viewer"); + } catch (error) { + if (database.systemRole !== "files" || !database.spaceId) throw error; + await resolveContentSpaceAccess(database.spaceId); + } } export async function readPersonalDatabaseViewOverrides( diff --git a/templates/content/actions/content-files.db.test.ts b/templates/content/actions/content-files.db.test.ts index 5c95764ce1..1d2cc7aee4 100644 --- a/templates/content/actions/content-files.db.test.ts +++ b/templates/content/actions/content-files.db.test.ts @@ -23,6 +23,7 @@ let personalContentSpaceId: typeof import("./_content-spaces.js").personalConten let organizationContentSpaceId: typeof import("./_content-spaces.js").organizationContentSpaceId; let reconcileContentFilesMemberships: typeof import("./_content-files.js").reconcileContentFilesMemberships; let getContentDatabaseAction: typeof import("./get-content-database.js").default; +let getContentDatabasePersonalViewAction: typeof import("./get-content-database-personal-view.js").default; let getDocumentAction: typeof import("./get-document.js").default; beforeAll(async () => { @@ -38,6 +39,9 @@ beforeAll(async () => { ({ reconcileContentFilesMemberships } = await import("./_content-files.js")); getContentDatabaseAction = (await import("./get-content-database.js")) .default; + getContentDatabasePersonalViewAction = ( + await import("./get-content-database-personal-view.js") + ).default; getDocumentAction = (await import("./get-document.js")).default; const plugin = (await import("../server/plugins/db.js")).default; await plugin(undefined as any); @@ -330,6 +334,31 @@ describe("Content Files membership reconciliation", () => { }), ]), ); + const crossWorkspaceResponse = await runWithRequestContext( + { userEmail: VIEWER, orgId: ORG_ID }, + () => + getContentDatabaseAction.run({ + databaseId: filesDatabase.id, + }), + ); + expect(crossWorkspaceResponse.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + document: expect.objectContaining({ id: "viewer-legacy-org" }), + }), + ]), + ); + await expect( + runWithRequestContext({ userEmail: VIEWER, orgId: ORG_ID }, () => + getContentDatabasePersonalViewAction.run( + { databaseId: filesDatabase.id }, + { userEmail: VIEWER } as any, + ), + ), + ).resolves.toMatchObject({ + databaseId: filesDatabase.id, + overrides: null, + }); const openedDocument = await runWithRequestContext( { userEmail: VIEWER, orgId: viewerOrgId }, () => getDocumentAction.run({ id: "viewer-legacy-org" }), diff --git a/templates/content/actions/get-content-database.ts b/templates/content/actions/get-content-database.ts index 01fe3d4a4f..3f183d7a71 100644 --- a/templates/content/actions/get-content-database.ts +++ b/templates/content/actions/get-content-database.ts @@ -8,6 +8,7 @@ import type { ContentDatabaseResponse, ContentDatabaseUnavailableResponse, } from "../shared/api.js"; +import { resolveContentSpaceAccess } from "./_content-space-access.js"; import { CONTENT_DATABASE_MAX_READ_LIMIT, getContentDatabaseResponse, @@ -65,8 +66,16 @@ export default defineAction({ }; } - const access = await resolveAccess("document", database.documentId); - if (!access) throw new Error(`Database "${resolvedDatabaseId}" not found`); + let canRead = Boolean(await resolveAccess("document", database.documentId)); + if (!canRead && database.systemRole === "files" && database.spaceId) { + try { + await resolveContentSpaceAccess(database.spaceId); + canRead = true; + } catch { + canRead = false; + } + } + if (!canRead) throw new Error(`Database "${resolvedDatabaseId}" not found`); if (database.deletedAt) { return { diff --git a/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts b/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts index aa9ac95d0f..84062f4f21 100644 --- a/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts +++ b/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts @@ -175,15 +175,19 @@ describe("document sidebar layout", () => { expect(messages).toContain('files: "Files"'); }); - it("nests the selected Files list beneath workspace disclosure rows", () => { + it("keeps independently expanded Files lists beneath their workspaces", () => { const sidebar = readSidebarSource("./DocumentSidebar.tsx"); expect(sidebar).toContain("aria-expanded={expanded}"); - expect(sidebar).toContain("collapsedWorkspaceId !== space.id"); - expect(sidebar).toContain("{expanded && renderSelectedWorkspaceFiles()}"); + expect(sidebar).toContain("content-sidebar-expanded-workspaces"); + expect(sidebar).toContain("toggleExpandedWorkspaceIds(current, space.id)"); + expect(sidebar).toContain("ensureWorkspaceExpanded(current, space.id)"); + expect(sidebar).toContain("{expanded && ("); + expect(sidebar).toContain(""); expect(sidebar).toContain('t("sidebar.workspaces")'); expect(sidebar).toContain('t("sidebar.files")'); - expect(sidebar).not.toContain("onOpenItem={(item) =>"); expect(sidebar).not.toContain(""); }); diff --git a/templates/content/app/components/sidebar/DocumentSidebar.tsx b/templates/content/app/components/sidebar/DocumentSidebar.tsx index 87e405a324..c238bd5789 100644 --- a/templates/content/app/components/sidebar/DocumentSidebar.tsx +++ b/templates/content/app/components/sidebar/DocumentSidebar.tsx @@ -27,7 +27,11 @@ import { sortableKeyboardCoordinates, verticalListSortingStrategy, } from "@dnd-kit/sortable"; -import type { Document, DocumentTreeNode } from "@shared/api"; +import type { + ContentDatabaseItem, + Document, + DocumentTreeNode, +} from "@shared/api"; import { IconDatabase, IconBrain, @@ -97,6 +101,7 @@ import { import { useContentSpaces, useEnsureContentSpaces, + type ContentSpaceSummary, } from "@/hooks/use-content-spaces"; import { useDocuments, @@ -120,8 +125,10 @@ import { contentSpaceAvailability, contentSpaceForActiveOrg, createContentSpaceSelectionQueue, + ensureWorkspaceExpanded, SELECTED_CONTENT_SPACE_STORAGE_KEY, selectContentSpace, + toggleExpandedWorkspaceIds, } from "./select-content-space"; function nanoid(size = 12): string { @@ -187,6 +194,7 @@ type CollapsedSectionsState = Record; const SIDEBAR_SECTION_COLLAPSE_STORAGE_KEY = "content-sidebar-collapsed-sections"; +const EXPANDED_WORKSPACES_STORAGE_KEY = "content-sidebar-expanded-workspaces"; const DEFAULT_COLLAPSED_SECTIONS: CollapsedSectionsState = { "local-files": false, "shared-copies": false, @@ -212,6 +220,76 @@ interface RemoveLocalFileSourceResult { deleted: number; } +function WorkspaceFilesSection({ + space, + selected, + footer, + onActivate, +}: { + space: ContentSpaceSummary; + selected: boolean; + footer?: ReactNode; + onActivate: (space: ContentSpaceSummary, documentId?: string) => void; +}) { + const t = useT(); + const filesDatabase = useContentDatabaseById(space.filesDatabaseId); + const filesPersonalView = useContentDatabasePersonalView( + space.filesDatabaseId, + ); + const updateFilesPersonalView = useUpdateContentDatabasePersonalView( + space.filesDatabaseId, + ); + const failed = filesDatabase.isError || filesPersonalView.isError; + + return ( +
+
+ {t("sidebar.files")} +
+ {failed ? ( + { + void filesDatabase.refetch(); + void filesPersonalView.refetch(); + }} + retrying={filesDatabase.isFetching || filesPersonalView.isFetching} + /> + ) : ( + { + const current = filesPersonalView.data?.overrides; + updateFilesPersonalView.mutate({ + databaseId: space.filesDatabaseId, + overrides: { + version: current?.version ?? 1, + activeViewId: viewId, + views: current?.views ?? [], + }, + }); + }} + onOpenItem={(item: ContentDatabaseItem) => { + if (selected) return false; + onActivate(space, item.document.id); + return true; + }} + labels={{ + loadingLabel: t("sidebar.loadingFiles"), + noMatchesLabel: t("database.noRowsMatchThisView"), + clearLabel: t("database.clearSearchAndFilters"), + navigationLabel: `${space.name} ${t("sidebar.files")}`, + untitledLabel: t("sidebar.untitled"), + }} + /> + )} + {selected ? footer : null} +
+ ); +} + export function DocumentSidebar({ activeDocumentId, collapsed, @@ -270,9 +348,9 @@ export function DocumentSidebar({ storedSpaceId, activeOrgId: activeOrg?.orgId, }); - const [collapsedWorkspaceId, setCollapsedWorkspaceId] = useState< - string | null - >(null); + const [expandedWorkspaceIds, setExpandedWorkspaceIds] = useLocalStorage< + string[] + >(EXPANDED_WORKSPACES_STORAGE_KEY, []); const contentSpaceState = contentSpaceAvailability({ hasSelectedSpace: Boolean(selectedSpace), contentSpacesLoading: contentSpacesQuery.isLoading, @@ -302,8 +380,20 @@ export function DocumentSidebar({ setStoredSpaceId(selectedSpace.id); } }, [selectedSpace, setStoredSpaceId, storedSpaceId]); + useEffect(() => { + if (!selectedSpace) return; + setExpandedWorkspaceIds((current) => + ensureWorkspaceExpanded(current, selectedSpace.id), + ); + }, [selectedSpace, setExpandedWorkspaceIds]); const handleSelectContentSpace = useCallback( - async (space: (typeof contentSpaces)[number]) => { + async ( + space: (typeof contentSpaces)[number], + targetDocumentId?: string, + ) => { + setExpandedWorkspaceIds((current) => + ensureWorkspaceExpanded(current, space.id), + ); try { await workspaceSelectionQueueRef.current(() => selectContentSpace({ @@ -326,14 +416,16 @@ export function DocumentSidebar({ ), persistSelection: setStoredSpaceId, openFiles: (documentId) => - navigate(`/page/${documentId}`, { flushSync: true }), + navigate(`/page/${targetDocumentId ?? documentId}`, { + flushSync: true, + }), }), ); } catch (error) { toast.error(error instanceof Error ? error.message : String(error)); } }, - [navigate, setStoredSpaceId, switchOrg], + [navigate, setExpandedWorkspaceIds, setStoredSpaceId, switchOrg], ); useEffect(() => { if (!selectedSpace) return; @@ -350,15 +442,6 @@ export function DocumentSidebar({ // Space selection remains usable when best-effort agent context sync fails. }); }, [selectedSpace]); - const filesDatabase = useContentDatabaseById( - selectedSpace?.filesDatabaseId ?? null, - ); - const filesPersonalView = useContentDatabasePersonalView( - selectedSpace?.filesDatabaseId ?? null, - ); - const updateFilesPersonalView = useUpdateContentDatabasePersonalView( - selectedSpace?.filesDatabaseId ?? null, - ); const removeLocalFileSource = useActionMutation< RemoveLocalFileSourceResult, { sourceRootPath?: string | null } @@ -1114,39 +1197,6 @@ export function DocumentSidebar({ ); }; - const renderSelectedWorkspaceFiles = () => ( -
-
- {t("sidebar.files")} -
- { - if (!selectedSpace) return; - const current = filesPersonalView.data?.overrides; - updateFilesPersonalView.mutate({ - databaseId: selectedSpace.filesDatabaseId, - overrides: { - version: current?.version ?? 1, - activeViewId: viewId, - views: current?.views ?? [], - }, - }); - }} - labels={{ - loadingLabel: t("sidebar.loadingFiles"), - noMatchesLabel: t("database.noRowsMatchThisView"), - clearLabel: t("database.clearSearchAndFilters"), - navigationLabel: t("sidebar.files"), - untitledLabel: t("sidebar.untitled"), - }} - /> - {renderNewButton()} -
- ); - const renderWorkspaceNavigation = () => (
@@ -1156,38 +1206,53 @@ export function DocumentSidebar({
{contentSpaces.map((space) => { const selected = selectedSpace.id === space.id; - const expanded = selected && collapsedWorkspaceId !== space.id; + const expanded = expandedWorkspaceIds.includes(space.id); return (
- + - {expanded && renderSelectedWorkspaceFiles()} + +
+ {expanded && ( + + void handleSelectContentSpace(nextSpace, documentId) + } + /> + )}
); })} diff --git a/templates/content/app/components/sidebar/select-content-space.test.ts b/templates/content/app/components/sidebar/select-content-space.test.ts index cc32570191..f21c24d6d9 100644 --- a/templates/content/app/components/sidebar/select-content-space.test.ts +++ b/templates/content/app/components/sidebar/select-content-space.test.ts @@ -8,7 +8,9 @@ import { contentSpaceForCatalogItem, contentSpaceIdForCreate, createContentSpaceSelectionQueue, + ensureWorkspaceExpanded, selectContentSpace, + toggleExpandedWorkspaceIds, } from "./select-content-space"; function space( @@ -343,6 +345,27 @@ describe("contentSpaceForCatalogItem", () => { }); }); +describe("workspace expansion", () => { + it("opens and closes workspaces independently", () => { + expect(toggleExpandedWorkspaceIds(["personal"], "organization")).toEqual([ + "personal", + "organization", + ]); + expect( + toggleExpandedWorkspaceIds(["personal", "organization"], "organization"), + ).toEqual(["personal"]); + }); + + it("keeps the selected workspace open without closing its siblings", () => { + expect(ensureWorkspaceExpanded(["personal"], "organization")).toEqual([ + "personal", + "organization", + ]); + const expanded = ["personal", "organization"]; + expect(ensureWorkspaceExpanded(expanded, "organization")).toBe(expanded); + }); +}); + describe("contentSpaceIdForCreate", () => { it("uses the selected workspace for a root page", () => { expect( diff --git a/templates/content/app/components/sidebar/select-content-space.ts b/templates/content/app/components/sidebar/select-content-space.ts index ed17736b1f..ab5a54d715 100644 --- a/templates/content/app/components/sidebar/select-content-space.ts +++ b/templates/content/app/components/sidebar/select-content-space.ts @@ -68,6 +68,24 @@ export function contentSpaceForCatalogItem(args: { ); } +export function toggleExpandedWorkspaceIds( + expandedIds: string[], + workspaceId: string, +) { + return expandedIds.includes(workspaceId) + ? expandedIds.filter((id) => id !== workspaceId) + : [...expandedIds, workspaceId]; +} + +export function ensureWorkspaceExpanded( + expandedIds: string[], + workspaceId: string, +) { + return expandedIds.includes(workspaceId) + ? expandedIds + : [...expandedIds, workspaceId]; +} + export function contentSpaceIdForCreate(args: { parentId?: string; selectedSpace: ContentSpaceSummary | null; From ff77bd609a7dd18df6d06fb3898041322096c458 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:40:38 -0400 Subject: [PATCH 23/76] refine(content): simplify workspace sidebar labels --- .../sidebar/DocumentSidebar.layout.test.ts | 9 +++++++-- .../components/sidebar/DocumentSidebar.tsx | 20 +++++++------------ ...-above-their-own-files-list-without-dup.md | 2 +- 3 files changed, 15 insertions(+), 16 deletions(-) diff --git a/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts b/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts index 84062f4f21..eea557e218 100644 --- a/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts +++ b/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts @@ -186,8 +186,13 @@ describe("document sidebar layout", () => { expect(sidebar).toContain(""); - expect(sidebar).toContain('t("sidebar.workspaces")'); - expect(sidebar).toContain('t("sidebar.files")'); + expect(sidebar).not.toContain('t("sidebar.workspaces")'); + expect(sidebar).not.toContain( + '
', + ); + expect(sidebar).toContain( + "text-[10px] font-semibold uppercase tracking-wider", + ); expect(sidebar).not.toContain(""); }); diff --git a/templates/content/app/components/sidebar/DocumentSidebar.tsx b/templates/content/app/components/sidebar/DocumentSidebar.tsx index c238bd5789..37362a57eb 100644 --- a/templates/content/app/components/sidebar/DocumentSidebar.tsx +++ b/templates/content/app/components/sidebar/DocumentSidebar.tsx @@ -242,10 +242,7 @@ function WorkspaceFilesSection({ const failed = filesDatabase.isError || filesPersonalView.isError; return ( -
-
- {t("sidebar.files")} -
+
{failed ? ( (
-
- {t("sidebar.workspaces")} -
{contentSpaceState === "ready" && selectedSpace ? ( -
+
{contentSpaces.map((space) => { const selected = selectedSpace.id === space.id; const expanded = expandedWorkspaceIds.includes(space.id); @@ -1211,17 +1205,17 @@ export function DocumentSidebar({
+ + + + onCreatePage(space)}> + + {t("sidebar.page")} + + onCreateDatabase(space)}> + + {t("sidebar.database")} + + + + + ); +} + export function DocumentSidebar({ activeDocumentId, collapsed, @@ -418,8 +456,10 @@ export function DocumentSidebar({ }), }), ); + return true; } catch (error) { toast.error(error instanceof Error ? error.message : String(error)); + return false; } }, [navigate, setExpandedWorkspaceIds, setStoredSpaceId, switchOrg], @@ -565,13 +605,13 @@ export function DocumentSidebar({ ); const handleCreatePage = useCallback( - async (parentId?: string) => { + async (parentId?: string, rootSpaceId = selectedSpace?.id) => { if (localFileMode) { try { const created = await createDocument.mutateAsync({ title: "", parentId: parentId ?? undefined, - spaceId: selectedSpace?.id, + spaceId: parentId ? undefined : rootSpaceId, }); queryClient.setQueryData( ["action", "get-document", { id: created.id }], @@ -626,7 +666,7 @@ export function DocumentSidebar({ id, title: "", parentId: parentId ?? undefined, - spaceId: selectedSpace?.id, + spaceId: parentId ? undefined : rootSpaceId, }); const nextId = created?.id || id; if (nextId !== id) { @@ -674,11 +714,11 @@ export function DocumentSidebar({ ); const handleCreateDatabase = useCallback( - async (parentId?: string | null) => { + async (parentId?: string | null, rootSpaceId = selectedSpace?.id) => { try { const result = await createDatabase.mutateAsync({ parentId: parentId ?? null, - spaceId: parentId ? undefined : selectedSpace?.id, + spaceId: parentId ? undefined : rootSpaceId, title: t("editor.untitledDatabase"), }); navigateToDocument(result.database.documentId); @@ -693,6 +733,32 @@ export function DocumentSidebar({ [createDatabase, navigateToDocument, onNavigate, selectedSpace?.id, t], ); + const handleCreatePageInSpace = useCallback( + async (space: ContentSpaceSummary) => { + if ( + selectedSpace?.id !== space.id && + !(await handleSelectContentSpace(space)) + ) { + return; + } + await handleCreatePage(undefined, space.id); + }, + [handleCreatePage, handleSelectContentSpace, selectedSpace?.id], + ); + + const handleCreateDatabaseInSpace = useCallback( + async (space: ContentSpaceSummary) => { + if ( + selectedSpace?.id !== space.id && + !(await handleSelectContentSpace(space)) + ) { + return; + } + await handleCreateDatabase(undefined, space.id); + }, + [handleCreateDatabase, handleSelectContentSpace, selectedSpace?.id], + ); + const handleDelete = useCallback( async (id: string) => { const deletedDocument = documents.find((doc) => doc.id === id) ?? null; @@ -950,14 +1016,14 @@ export function DocumentSidebar({ ); - const renderNewButton = () => - selectedSpace ? ( + const renderNewButton = (space = selectedSpace) => + space ? (
@@ -1205,7 +1271,7 @@ export function DocumentSidebar({
+ + void handleCreatePageInSpace(targetSpace) + } + onCreateDatabase={(targetSpace) => + void handleCreateDatabaseInSpace(targetSpace) + } + />
{expanded && ( void handleSelectContentSpace(nextSpace, documentId) } diff --git a/templates/content/changelog/2026-07-17-workspaces-now-expand-above-their-own-files-list-without-dup.md b/templates/content/changelog/2026-07-17-workspaces-now-expand-above-their-own-files-list-without-dup.md index 15985da7d5..8ebef7b0f4 100644 --- a/templates/content/changelog/2026-07-17-workspaces-now-expand-above-their-own-files-list-without-dup.md +++ b/templates/content/changelog/2026-07-17-workspaces-now-expand-above-their-own-files-list-without-dup.md @@ -3,4 +3,4 @@ type: improved date: 2026-07-17 --- -Workspaces now stay independently expanded as compact sidebar headers above their pages, without duplicate labels or organization controls. +Workspaces now stay independently expanded as compact sidebar headers above their pages, with a creation menu on every workspace and no duplicate labels or organization controls. From 649a255be25df9ac94abd24e4e8c0eeedc2cb9d3 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 17 Jul 2026 20:18:48 -0400 Subject: [PATCH 25/76] refine(content): open workspace pages optimistically --- .../editor/DocumentEditor.layout.test.ts | 16 ++ .../app/components/editor/DocumentEditor.tsx | 69 ++++++++- .../sidebar/DocumentSidebar.layout.test.ts | 14 +- .../components/sidebar/DocumentSidebar.tsx | 139 +++++------------- ...-above-their-own-files-list-without-dup.md | 2 +- 5 files changed, 125 insertions(+), 115 deletions(-) diff --git a/templates/content/app/components/editor/DocumentEditor.layout.test.ts b/templates/content/app/components/editor/DocumentEditor.layout.test.ts index 0134118534..b2b22f2bd8 100644 --- a/templates/content/app/components/editor/DocumentEditor.layout.test.ts +++ b/templates/content/app/components/editor/DocumentEditor.layout.test.ts @@ -16,6 +16,22 @@ describe("document editor layout", () => { expect(documentEditorTitleRegionClassName(false)).toContain("pb-8"); }); + it("offers page or database after an optimistic blank page opens", () => { + const source = readFileSync( + new URL("./DocumentEditor.tsx", import.meta.url), + { encoding: "utf8" }, + ); + + expect(source).toContain("const showNewDocumentTypeChooser ="); + expect(source).toContain("isEffectivelyEmptyDocumentContent(localContent)"); + expect(source).toContain("const handleChoosePage = useCallback"); + expect(source).toContain( + "await createDatabase.mutateAsync({ documentId })", + ); + expect(source).toContain('{t("sidebar.page")}'); + expect(source).toContain('{t("sidebar.database")}'); + }); + it("gives database pages a wider database surface", () => { expect(documentEditorTitleRegionClassName(true)).toContain("max-w-none"); expect(documentEditorTitleRegionClassName(true)).toContain("pt-14"); diff --git a/templates/content/app/components/editor/DocumentEditor.tsx b/templates/content/app/components/editor/DocumentEditor.tsx index 00ece8c099..5f69e1ee42 100644 --- a/templates/content/app/components/editor/DocumentEditor.tsx +++ b/templates/content/app/components/editor/DocumentEditor.tsx @@ -12,7 +12,12 @@ import { type CollabUser, } from "@agent-native/core/client"; import type { Document, DocumentSyncStatus } from "@shared/api"; -import { IconArrowLeft, IconDatabase, IconLoader2 } from "@tabler/icons-react"; +import { + IconArrowLeft, + IconDatabase, + IconFileText, + IconLoader2, +} from "@tabler/icons-react"; import { IconLock } from "@tabler/icons-react"; import { useQueryClient } from "@tanstack/react-query"; import { @@ -34,7 +39,10 @@ import { import { Button } from "@/components/ui/button"; import { Sheet, SheetContent } from "@/components/ui/sheet"; import { useComments } from "@/hooks/use-comments"; -import { useProcessBuilderBodyHydration } from "@/hooks/use-content-database"; +import { + useCreateContentDatabase, + useProcessBuilderBodyHydration, +} from "@/hooks/use-content-database"; import { isDocumentUpdateConflict, useDocument, @@ -309,6 +317,7 @@ function DatabaseMembershipBreadcrumb({ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) { const t = useT(); const updateDocument = useUpdateDocument(); + const createDatabase = useCreateContentDatabase(documentId); const queryClient = useQueryClient(); const processBuilderBodies = useProcessBuilderBodyHydration( document.databaseMembership?.databaseDocumentId ?? documentId, @@ -341,6 +350,7 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) { const pushDocumentToNotion = usePushDocumentToNotion(documentId); const [localTitle, setLocalTitle] = useState(""); const [localContent, setLocalContent] = useState(""); + const [newDocumentTypeChosen, setNewDocumentTypeChosen] = useState(false); const [localContentUpdatedAt, setLocalContentUpdatedAt] = useState< string | null >(document.updatedAt ?? null); @@ -513,6 +523,7 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) { if (prevDocIdRef.current !== documentId) { prevDocIdRef.current = documentId; isInitializedRef.current = false; + setNewDocumentTypeChosen(false); if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current); saveTimeoutRef.current = null; @@ -1273,6 +1284,29 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) { ); const defaultIconKind = documentEditorDefaultIconKind(document); const isDatabasePage = Boolean(document.database); + const showNewDocumentTypeChooser = + canEdit && + !isLocalFileDocument && + !isDatabasePage && + !newDocumentTypeChosen && + !localTitle.trim() && + !document.description?.trim() && + isEffectivelyEmptyDocumentContent(localContent); + const handleChoosePage = useCallback(() => { + setNewDocumentTypeChosen(true); + requestAnimationFrame(() => titleInputRef.current?.focus()); + }, []); + const handleChooseDatabase = useCallback(async () => { + try { + await createDatabase.mutateAsync({ documentId }); + setNewDocumentTypeChosen(true); + } catch (error) { + toast.error(t("sidebar.failedCreateDatabase"), { + description: + error instanceof Error ? error.message : t("empty.genericError"), + }); + } + }, [createDatabase, documentId, t]); const defaultIcon = defaultIconKind === "database" && !isDatabasePage ? (
{expanded && ( Date: Fri, 17 Jul 2026 20:40:07 -0400 Subject: [PATCH 26/76] refine(content): clarify optimistic page creation --- .../editor/DocumentEditor.layout.test.ts | 3 + .../app/components/editor/DocumentEditor.tsx | 69 ++++++++++--------- .../editor/database/sidebar.test.tsx | 3 + .../components/editor/database/sidebar.tsx | 14 +++- .../sidebar/DocumentSidebar.layout.test.ts | 6 +- .../components/sidebar/DocumentSidebar.tsx | 38 +++++++++- .../app/hooks/use-content-database.test.ts | 46 +++++++++++++ .../content/app/hooks/use-content-database.ts | 51 ++++++++++++++ ...-above-their-own-files-list-without-dup.md | 2 +- 9 files changed, 196 insertions(+), 36 deletions(-) diff --git a/templates/content/app/components/editor/DocumentEditor.layout.test.ts b/templates/content/app/components/editor/DocumentEditor.layout.test.ts index b2b22f2bd8..2583f45ab9 100644 --- a/templates/content/app/components/editor/DocumentEditor.layout.test.ts +++ b/templates/content/app/components/editor/DocumentEditor.layout.test.ts @@ -30,6 +30,9 @@ describe("document editor layout", () => { ); expect(source).toContain('{t("sidebar.page")}'); expect(source).toContain('{t("sidebar.database")}'); + expect(source.indexOf("if (showNewDocumentTypeChooser)")).toBeLessThan( + source.indexOf("const primaryEditor ="), + ); }); it("gives database pages a wider database surface", () => { diff --git a/templates/content/app/components/editor/DocumentEditor.tsx b/templates/content/app/components/editor/DocumentEditor.tsx index 5f69e1ee42..213a54bc5a 100644 --- a/templates/content/app/components/editor/DocumentEditor.tsx +++ b/templates/content/app/components/editor/DocumentEditor.tsx @@ -1479,37 +1479,6 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) { } /> ) : null} - {showNewDocumentTypeChooser ? ( -
- - -
- ) : null} {document.databaseMembership && !isLocalFileDocument ? ( + + +
+ ); + } + // The primary "Content" Blocks field IS the document body, // with the full collaborative editor. It renders chromeless // when it's the only Blocks field, or inside a diff --git a/templates/content/app/components/editor/database/sidebar.test.tsx b/templates/content/app/components/editor/database/sidebar.test.tsx index be4881e290..4c19f6903c 100644 --- a/templates/content/app/components/editor/database/sidebar.test.tsx +++ b/templates/content/app/components/editor/database/sidebar.test.tsx @@ -66,12 +66,15 @@ describe("DatabaseSidebarView", () => { untitledLabel="Untitled" onClearResultConstraints={() => {}} onPreview={() => {}} + activeDocumentId="page" /> , ); expect(markup).toContain('href="/page/page"'); expect(markup).toContain("Project"); + expect(markup).toContain('aria-current="page"'); + expect(markup).toContain("font-semibold"); }); it("lets the Files sidebar intercept a workspace reference row", async () => { diff --git a/templates/content/app/components/editor/database/sidebar.tsx b/templates/content/app/components/editor/database/sidebar.tsx index a5a1190f34..93fbd810b3 100644 --- a/templates/content/app/components/editor/database/sidebar.tsx +++ b/templates/content/app/components/editor/database/sidebar.tsx @@ -68,6 +68,7 @@ export function ContentFilesSidebarView({ data, overrides, isLoading, + activeDocumentId, labels, onSelectView, onOpenItem, @@ -75,6 +76,7 @@ export function ContentFilesSidebarView({ data: ContentDatabaseResponse | undefined; overrides: ContentDatabasePersonalViewOverrides | null | undefined; isLoading: boolean; + activeDocumentId?: string | null; onSelectView?: (viewId: string) => void; onOpenItem?: (item: ContentDatabaseItem) => boolean; labels: Omit< @@ -154,6 +156,7 @@ export function ContentFilesSidebarView({ onClearResultConstraints={() => {}} onPreview={() => {}} onOpenItem={onOpenItem} + activeDocumentId={activeDocumentId} />
); @@ -168,6 +171,7 @@ export function DatabaseSidebarView({ onClearResultConstraints, onPreview, onOpenItem, + activeDocumentId, loadingLabel, noMatchesLabel, clearLabel, @@ -182,6 +186,7 @@ export function DatabaseSidebarView({ onClearResultConstraints: () => void; onPreview: (item: ContentDatabaseItem) => void; onOpenItem?: (item: ContentDatabaseItem) => boolean; + activeDocumentId?: string | null; loadingLabel: string; noMatchesLabel: string; clearLabel: string; @@ -260,6 +265,7 @@ export function DatabaseSidebarView({ openPagesIn={openPagesIn} onPreview={onPreview} onOpenItem={onOpenItem} + active={item.document.id === activeDocumentId} untitledLabel={untitledLabel} /> ))} @@ -274,6 +280,7 @@ export function DatabaseSidebarView({ openPagesIn={openPagesIn} onPreview={onPreview} onOpenItem={onOpenItem} + active={item.document.id === activeDocumentId} untitledLabel={untitledLabel} /> ))} @@ -287,12 +294,14 @@ function DatabaseSidebarRow({ openPagesIn, onPreview, onOpenItem, + active, untitledLabel, }: { item: ContentDatabaseItem; openPagesIn: ContentDatabaseOpenPagesIn; onPreview: (item: ContentDatabaseItem) => void; onOpenItem?: (item: ContentDatabaseItem) => boolean; + active: boolean; untitledLabel: string; }) { function handleClick(event: MouseEvent) { @@ -323,6 +332,7 @@ function DatabaseSidebarRow({ item.document.icon ? "pl-1" : "pl-1.5", )} onClick={handleClick} + aria-current={active ? "page" : undefined} > {item.document.icon ? (
+ + + + + {t("sidebar.deletePageQuestion")} + + + {t("sidebar.deletePageDescription", { + title: documentTitle || t("sidebar.untitled"), + })} + + + + {t("comments.cancel")} + void onDelete?.()} + > + {t("database.delete")} + + + + ); } diff --git a/templates/content/app/components/editor/database/sidebar.test.tsx b/templates/content/app/components/editor/database/sidebar.test.tsx index 4c19f6903c..1175f6870d 100644 --- a/templates/content/app/components/editor/database/sidebar.test.tsx +++ b/templates/content/app/components/editor/database/sidebar.test.tsx @@ -7,6 +7,8 @@ import { renderToStaticMarkup } from "react-dom/server"; import { MemoryRouter } from "react-router"; import { describe, expect, it, vi } from "vitest"; +import { TooltipProvider } from "@/components/ui/tooltip"; + import { DatabaseSidebarView, databaseSidebarRows } from "./sidebar"; import type { DatabaseBoardGroup } from "./types"; @@ -45,29 +47,31 @@ describe("DatabaseSidebarView", () => { it("renders compact router links for an ungrouped saved view", () => { const markup = renderToStaticMarkup( - {}} - onPreview={() => {}} - activeDocumentId="page" - /> + + {}} + onPreview={() => {}} + activeDocumentId="page" + /> + , ); @@ -128,4 +132,52 @@ describe("DatabaseSidebarView", () => { await act(async () => root.unmount()); }); + + it("restores contextual more and add-child controls for Files rows", () => { + const markup = renderToStaticMarkup( + + + {}} + onPreview={() => {}} + onCreateChildPage={() => {}} + onCreateChildDatabase={() => {}} + onDeleteItem={() => {}} + onToggleFavorite={() => {}} + /> + + , + ); + + expect(markup).toContain('aria-label="More actions for Project"'); + expect(markup).toContain('aria-label="Add child to'); + expect(markup).toContain("group-hover:opacity-100"); + }); }); diff --git a/templates/content/app/components/editor/database/sidebar.tsx b/templates/content/app/components/editor/database/sidebar.tsx index 93fbd810b3..3f8780a714 100644 --- a/templates/content/app/components/editor/database/sidebar.tsx +++ b/templates/content/app/components/editor/database/sidebar.tsx @@ -1,3 +1,4 @@ +import { useT } from "@agent-native/core/client"; import { Button } from "@agent-native/toolkit/ui/button"; import { Collapsible, @@ -15,12 +16,39 @@ import type { import { IconChevronDown, IconChevronRight, + IconDatabase, + IconDots, IconFileText, IconLoader2, + IconPlus, + IconStar, + IconTrash, } from "@tabler/icons-react"; import { useEffect, useState, type MouseEvent } from "react"; import { Link } from "react-router"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import { applyDatabaseView } from "./filter-sort"; @@ -72,6 +100,10 @@ export function ContentFilesSidebarView({ labels, onSelectView, onOpenItem, + onCreateChildPage, + onCreateChildDatabase, + onDeleteItem, + onToggleFavorite, }: { data: ContentDatabaseResponse | undefined; overrides: ContentDatabasePersonalViewOverrides | null | undefined; @@ -79,6 +111,10 @@ export function ContentFilesSidebarView({ activeDocumentId?: string | null; onSelectView?: (viewId: string) => void; onOpenItem?: (item: ContentDatabaseItem) => boolean; + onCreateChildPage?: (item: ContentDatabaseItem) => void; + onCreateChildDatabase?: (item: ContentDatabaseItem) => void; + onDeleteItem?: (item: ContentDatabaseItem) => void; + onToggleFavorite?: (item: ContentDatabaseItem) => void; labels: Omit< Parameters[0], | "groups" @@ -157,6 +193,10 @@ export function ContentFilesSidebarView({ onPreview={() => {}} onOpenItem={onOpenItem} activeDocumentId={activeDocumentId} + onCreateChildPage={onCreateChildPage} + onCreateChildDatabase={onCreateChildDatabase} + onDeleteItem={onDeleteItem} + onToggleFavorite={onToggleFavorite} />
); @@ -172,6 +212,10 @@ export function DatabaseSidebarView({ onPreview, onOpenItem, activeDocumentId, + onCreateChildPage, + onCreateChildDatabase, + onDeleteItem, + onToggleFavorite, loadingLabel, noMatchesLabel, clearLabel, @@ -187,6 +231,10 @@ export function DatabaseSidebarView({ onPreview: (item: ContentDatabaseItem) => void; onOpenItem?: (item: ContentDatabaseItem) => boolean; activeDocumentId?: string | null; + onCreateChildPage?: (item: ContentDatabaseItem) => void; + onCreateChildDatabase?: (item: ContentDatabaseItem) => void; + onDeleteItem?: (item: ContentDatabaseItem) => void; + onToggleFavorite?: (item: ContentDatabaseItem) => void; loadingLabel: string; noMatchesLabel: string; clearLabel: string; @@ -266,6 +314,10 @@ export function DatabaseSidebarView({ onPreview={onPreview} onOpenItem={onOpenItem} active={item.document.id === activeDocumentId} + onCreateChildPage={onCreateChildPage} + onCreateChildDatabase={onCreateChildDatabase} + onDeleteItem={onDeleteItem} + onToggleFavorite={onToggleFavorite} untitledLabel={untitledLabel} /> ))} @@ -281,6 +333,10 @@ export function DatabaseSidebarView({ onPreview={onPreview} onOpenItem={onOpenItem} active={item.document.id === activeDocumentId} + onCreateChildPage={onCreateChildPage} + onCreateChildDatabase={onCreateChildDatabase} + onDeleteItem={onDeleteItem} + onToggleFavorite={onToggleFavorite} untitledLabel={untitledLabel} /> ))} @@ -295,6 +351,10 @@ function DatabaseSidebarRow({ onPreview, onOpenItem, active, + onCreateChildPage, + onCreateChildDatabase, + onDeleteItem, + onToggleFavorite, untitledLabel, }: { item: ContentDatabaseItem; @@ -302,8 +362,23 @@ function DatabaseSidebarRow({ onPreview: (item: ContentDatabaseItem) => void; onOpenItem?: (item: ContentDatabaseItem) => boolean; active: boolean; + onCreateChildPage?: (item: ContentDatabaseItem) => void; + onCreateChildDatabase?: (item: ContentDatabaseItem) => void; + onDeleteItem?: (item: ContentDatabaseItem) => void; + onToggleFavorite?: (item: ContentDatabaseItem) => void; untitledLabel: string; }) { + const t = useT(); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const canEdit = item.document.canEdit !== false; + const canManage = + item.document.canManage === true || + item.document.accessRole === "owner" || + item.document.accessRole === "admin"; + const canCreateChild = canEdit && Boolean(onCreateChildPage); + const hasMenuActions = + (canEdit && Boolean(onToggleFavorite)) || + (canManage && Boolean(onDeleteItem)); function handleClick(event: MouseEvent) { if ( event.defaultPrevented || @@ -324,29 +399,139 @@ function DatabaseSidebarRow({ onPreview(item); } + const title = item.document.title || untitledLabel; + return ( - - {item.document.icon ? ( - - ) : ( - - )} - - {item.document.title || untitledLabel} - - + <> +
+ + {item.document.icon ? ( + + ) : ( + + )} + + {title} + + + + {(hasMenuActions || canCreateChild) && ( +
+ {hasMenuActions && ( + + + + + + {canEdit && onToggleFavorite ? ( + onToggleFavorite(item)}> + + {item.document.isFavorite + ? "Remove from favorites" + : "Add to favorites"} + + ) : null} + {canEdit && onToggleFavorite && canManage && onDeleteItem ? ( + + ) : null} + {canManage && onDeleteItem ? ( + setDeleteDialogOpen(true)} + > + + {t("database.delete")} + + ) : null} + + + )} + + {canCreateChild && ( + + + + + + + + {t("sidebar.addChild")} + + + onCreateChildPage?.(item)}> + + {t("sidebar.page")} + + {onCreateChildDatabase ? ( + onCreateChildDatabase(item)} + > + + {t("sidebar.database")} + + ) : null} + + + )} +
+ )} +
+ + + + + + {t("sidebar.deletePageQuestion")} + + + {t("sidebar.deletePageDescription", { title })} + + + + {t("comments.cancel")} + onDeleteItem?.(item)} + > + {t("database.delete")} + + + + + ); } diff --git a/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts b/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts index 528e413608..34a082a91b 100644 --- a/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts +++ b/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts @@ -197,6 +197,9 @@ describe("document sidebar layout", () => { "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)", ); diff --git a/templates/content/app/components/sidebar/DocumentSidebar.tsx b/templates/content/app/components/sidebar/DocumentSidebar.tsx index c746146368..7c0d4a1bd8 100644 --- a/templates/content/app/components/sidebar/DocumentSidebar.tsx +++ b/templates/content/app/components/sidebar/DocumentSidebar.tsx @@ -227,11 +227,25 @@ function WorkspaceFilesSection({ selected, activeDocumentId, onActivate, + onCreateChildPage, + onCreateChildDatabase, + onDeleteItem, + onToggleFavorite, }: { space: ContentSpaceSummary; selected: boolean; activeDocumentId: string | null; onActivate: (space: ContentSpaceSummary, documentId?: string) => void; + onCreateChildPage: ( + space: ContentSpaceSummary, + item: ContentDatabaseItem, + ) => void; + onCreateChildDatabase: ( + space: ContentSpaceSummary, + item: ContentDatabaseItem, + ) => void; + onDeleteItem: (item: ContentDatabaseItem) => void; + onToggleFavorite: (item: ContentDatabaseItem) => void; }) { const t = useT(); const filesDatabase = useContentDatabaseById(space.filesDatabaseId); @@ -276,6 +290,10 @@ function WorkspaceFilesSection({ onActivate(space, item.document.id); return true; }} + onCreateChildPage={(item) => onCreateChildPage(space, item)} + onCreateChildDatabase={(item) => onCreateChildDatabase(space, item)} + onDeleteItem={onDeleteItem} + onToggleFavorite={onToggleFavorite} labels={{ loadingLabel: t("sidebar.loadingFiles"), noMatchesLabel: t("database.noRowsMatchThisView"), @@ -1285,6 +1303,24 @@ export function DocumentSidebar({ onActivate={(nextSpace, documentId) => void handleSelectContentSpace(nextSpace, documentId) } + onCreateChildPage={(nextSpace, item) => + void handleCreatePage( + item.document.id, + nextSpace.id, + undefined, + nextSpace.filesDatabaseId, + ) + } + onCreateChildDatabase={(nextSpace, item) => + void handleCreateDatabase(item.document.id, nextSpace.id) + } + onDeleteItem={(item) => void handleDelete(item.document.id)} + onToggleFavorite={(item) => + handleToggleFavorite( + item.document.id, + !item.document.isFavorite, + ) + } /> )}
diff --git a/templates/content/app/hooks/use-documents.ts b/templates/content/app/hooks/use-documents.ts index 0a2b9ebf60..48d5a61005 100644 --- a/templates/content/app/hooks/use-documents.ts +++ b/templates/content/app/hooks/use-documents.ts @@ -277,6 +277,9 @@ export function useDeleteDocument() { queryClient.invalidateQueries({ queryKey: ["action", "get-document", { id: variables.id }], }); + queryClient.invalidateQueries({ + queryKey: ["action", "get-content-database"], + }); queryClient.invalidateQueries({ queryKey: ["action", "list-trashed-content-databases"], }); diff --git a/templates/content/changelog/2026-07-17-workspaces-now-expand-above-their-own-files-list-without-dup.md b/templates/content/changelog/2026-07-17-workspaces-now-expand-above-their-own-files-list-without-dup.md index 7cd68bf9de..2f6e2e443e 100644 --- a/templates/content/changelog/2026-07-17-workspaces-now-expand-above-their-own-files-list-without-dup.md +++ b/templates/content/changelog/2026-07-17-workspaces-now-expand-above-their-own-files-list-without-dup.md @@ -3,4 +3,4 @@ type: improved date: 2026-07-17 --- -Workspaces now stay independently expanded as compact sidebar headers above their pages, with new pages appearing instantly in the sidebar, a bold active-page title, and the Page or Database choice in the editor body. +Workspaces now stay independently expanded as compact sidebar headers above their pages, with instant new pages, clear active titles, contextual page actions, and the Page or Database choice in the editor body. From 784d04c8ac4993f20b1836edeefe63a1754a615e Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:03:07 -0400 Subject: [PATCH 28/76] fix(content): protect system databases from deletion --- .../content/app/components/editor/DocumentEditor.layout.test.ts | 1 + templates/content/app/components/editor/DocumentEditor.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/templates/content/app/components/editor/DocumentEditor.layout.test.ts b/templates/content/app/components/editor/DocumentEditor.layout.test.ts index 20867be0c8..3505291e5c 100644 --- a/templates/content/app/components/editor/DocumentEditor.layout.test.ts +++ b/templates/content/app/components/editor/DocumentEditor.layout.test.ts @@ -23,6 +23,7 @@ describe("document editor layout", () => { ); expect(source).toContain("const showNewDocumentTypeChooser ="); + expect(source).toContain("!document.database?.systemRole"); expect(source).toContain("isEffectivelyEmptyDocumentContent(localContent)"); expect(source).toContain("const handleChoosePage = useCallback"); expect(source).toContain( diff --git a/templates/content/app/components/editor/DocumentEditor.tsx b/templates/content/app/components/editor/DocumentEditor.tsx index 5879e6dc5a..dc3d7f70e4 100644 --- a/templates/content/app/components/editor/DocumentEditor.tsx +++ b/templates/content/app/components/editor/DocumentEditor.tsx @@ -344,6 +344,7 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) { const isLocalFileDocument = document.source?.mode === "local-files"; const canDelete = !isLocalFileDocument && + !document.database?.systemRole && (document.canManage === true || document.accessRole === "owner" || document.accessRole === "admin"); From 4b84acf333d1225a1b75d0bc5b7c526a0e660a3c Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Sat, 18 Jul 2026 07:12:02 -0400 Subject: [PATCH 29/76] improve(content): unify page details rail --- .../app/components/editor/CommentsSidebar.tsx | 9 +- .../editor/DocumentEditor.layout.test.ts | 42 +- .../app/components/editor/DocumentEditor.tsx | 372 ++++++++++-------- .../components/editor/DocumentInfoPanel.tsx | 39 ++ .../app/components/editor/DocumentToolbar.tsx | 52 +++ .../editor/database/DatabaseView.tsx | 15 - .../components/sidebar/DocumentSidebar.tsx | 16 +- .../content/app/hooks/use-documents.test.ts | 72 ++++ templates/content/app/hooks/use-documents.ts | 128 ++++++ templates/content/app/i18n-data.ts | 5 + ...-in-a-shared-info-and-comments-rail-dat.md | 6 + 11 files changed, 563 insertions(+), 193 deletions(-) create mode 100644 templates/content/app/components/editor/DocumentInfoPanel.tsx create mode 100644 templates/content/changelog/2026-07-18-page-details-now-live-in-a-shared-info-and-comments-rail-dat.md diff --git a/templates/content/app/components/editor/CommentsSidebar.tsx b/templates/content/app/components/editor/CommentsSidebar.tsx index f2f4f2f298..1d5156dd17 100644 --- a/templates/content/app/components/editor/CommentsSidebar.tsx +++ b/templates/content/app/components/editor/CommentsSidebar.tsx @@ -186,6 +186,7 @@ interface CommentsSidebarProps { onSelectedThreadChange?: (id: string | null) => void; onHoveredThreadChange?: (id: string | null) => void; currentUserEmail?: string; + forceVisible?: boolean; } export function CommentsSidebar({ @@ -200,6 +201,7 @@ export function CommentsSidebar({ onSelectedThreadChange, onHoveredThreadChange, currentUserEmail, + forceVisible = false, }: CommentsSidebarProps) { const t = useT(); const { data: members = [] } = useMentionMembers(); @@ -431,7 +433,7 @@ export function CommentsSidebar({ const hasContent = openThreads.length > 0 || !!pendingComment || resolvedThreads.length > 0; - if (!hasContent && !isLoading) return null; + if (!hasContent && !isLoading && !forceVisible) return null; // Sort open threads by their position in the document. const sortedThreads = [...openThreads].sort((a, b) => { @@ -486,6 +488,11 @@ export function CommentsSidebar({ className="relative w-80 shrink-0 pb-16" data-comments-sidebar > + {!hasContent && !isLoading ? ( +
+ {t("comments.empty")} +
+ ) : null} {/* Pending new comment — positioned at the selection Y offset */} {pendingComment && (
{ expect(source).toContain("return "); }); - it("keeps desktop comments inside the document scroll surface", () => { + it("keeps one selected utility rail inside the document scroll surface", () => { const source = readFileSync( new URL("./DocumentEditor.tsx", import.meta.url), { @@ -84,16 +84,40 @@ describe("document editor layout", () => { const scrollIndex = source.indexOf("data-document-print-scroll"); const contentIndex = source.indexOf("data-document-scroll-content"); - const desktopCommentsIndex = source.indexOf( - "{showDesktopComments ? sidebar : null}", - ); + const desktopPanelIndex = source.indexOf("{showDesktopUtilityPanel ? ("); const mobileSheetIndex = source.indexOf(" { + const source = readFileSync( + new URL("./DocumentEditor.tsx", import.meta.url), + { encoding: "utf8" }, + ); + const infoPanel = readFileSync( + new URL("./DocumentInfoPanel.tsx", import.meta.url), + { encoding: "utf8" }, + ); + + expect(source).toContain(" { @@ -110,6 +134,10 @@ describe("document editor layout", () => { expect(source).toContain("ToolbarBreadcrumb"); expect(source).toContain("formatEditedLabel"); expect(source).toContain("editor.toolbar.copyPageLink"); + expect(source).toContain("editor.toolbar.info"); + expect(source).toContain("comments.title"); + expect(source).toContain('aria-pressed={utilityPanel === "info"}'); + expect(source).toContain('aria-pressed={utilityPanel === "comments"}'); expect(source).toContain("setDeleteDialogOpen(true)"); expect(source).toContain("text-destructive focus:text-destructive"); expect(source).toContain("(null); const [selectedThreadId, setSelectedThreadId] = useState(null); const [hoveredThreadId, setHoveredThreadId] = useState(null); + const [utilityPanel, setUtilityPanel] = useState(null); const activeThreadId = hoveredThreadId ?? selectedThreadId; const { data: threads, isLoading: commentsLoading } = useComments( canEdit && !isLocalFileDocument ? documentId : null, ); - const hasComments = - !isLocalFileDocument && - editorCanEdit && - ((threads?.length ?? 0) > 0 || !!pendingComment); - const hasCommentRailSpace = useMinViewportWidth(1024); - const showDesktopComments = hasComments && hasCommentRailSpace; - const showCommentsSheet = - hasComments && editorCanEdit && !showDesktopComments; + const hasUtilityRailSpace = useMinViewportWidth(1024); + const showDesktopUtilityPanel = utilityPanel !== null && hasUtilityRailSpace; + const showUtilityPanelSheet = + utilityPanel !== null && !showDesktopUtilityPanel; const handleComment = useCallback( ( @@ -1217,6 +1220,7 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) { range?: { from: number; to: number }, ) => { setPendingComment({ quotedText, offsetTop, anchor, range }); + setUtilityPanel("comments"); setSelectedThreadId(null); setHoveredThreadId(null); }, @@ -1230,6 +1234,7 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) { useEffect(() => { setPendingComment(null); + setUtilityPanel(null); clearCommentFocus(); }, [clearCommentFocus, documentId]); @@ -1301,7 +1306,7 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) { [document, documents], ); - const sidebar = ( + const commentsSidebar = ( ); const defaultIconKind = documentEditorDefaultIconKind(document); @@ -1349,6 +1355,36 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) { const exportContent = isInitializedRef.current ? localContent : document.content; + const utilityPanelTitle = + utilityPanel === "info" ? t("editor.toolbar.info") : t("comments.title"); + const utilityPanelContent = utilityPanel ? ( +
+
+

{utilityPanelTitle}

+ {hasUtilityRailSpace ? ( + + ) : null} +
+ {utilityPanel === "info" ? ( + + persistDocumentUpdates({ description }) + } + /> + ) : ( + commentsSidebar + )} +
+ ) : null; return ( {!isLocalFileDocument ? ( @@ -1403,14 +1442,14 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
- {!document.database ? ( - - persistDocumentUpdates({ description }) - } - /> - ) : null} - {document.databaseMembership && !isLocalFileDocument ? ( - - ) : null}
{document.database ? (
@@ -1531,161 +1553,175 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
) : null} -
{ - if (e.target === e.currentTarget) { - const pm = e.currentTarget.querySelector( - ".ProseMirror", - ) as HTMLElement | null; - pm?.focus(); - } - }} - > - {(() => { - if (bodyHydrationPending) { - return ( - - ); - } + {!isDatabasePage ? ( +
{ + if (e.target === e.currentTarget) { + const pm = e.currentTarget.querySelector( + ".ProseMirror", + ) as HTMLElement | null; + pm?.focus(); + } + }} + > + {(() => { + if (bodyHydrationPending) { + return ( + + ); + } - if (showNewDocumentTypeChooser) { - return ( -
- - -
- ); - } + + +
+ ); + } - // The primary "Content" Blocks field IS the document body, - // with the full collaborative editor. It renders chromeless - // when it's the only Blocks field, or inside a - // header/collapsible shell when the row has multiple Blocks - // fields. - const primaryEditor = ( - - ); - - // Only database rows have Blocks fields. Standalone pages - // and local-file documents keep the plain chromeless body. - if (document.databaseMembership && !isLocalFileDocument) { - return ( - ); - } - return primaryEditor; - })()} - {!bodyHydrationPending && - !isLocalFileDocument && - collabLoading ? ( -
- - {t("editor.collabConnectingReadOnly")} -
- ) : null} -
+ // Only database rows have Blocks fields. Standalone pages + // and local-file documents keep the plain chromeless body. + if (document.databaseMembership && !isLocalFileDocument) { + return ( + + ); + } + + return primaryEditor; + })()} + {!bodyHydrationPending && + !isLocalFileDocument && + collabLoading ? ( +
+ + {t("editor.collabConnectingReadOnly")} +
+ ) : null} +
+ ) : null}
- {showDesktopComments ? sidebar : null} + {showDesktopUtilityPanel ? ( + + ) : null}
- {showCommentsSheet ? ( + {showUtilityPanelSheet ? ( { - if (!open) setPendingComment(null); + if (!open) { + setUtilityPanel(null); + setPendingComment(null); + } }} > - {sidebar} + + {utilityPanelTitle} + + {utilityPanelContent} ) : null} diff --git a/templates/content/app/components/editor/DocumentInfoPanel.tsx b/templates/content/app/components/editor/DocumentInfoPanel.tsx new file mode 100644 index 0000000000..9ba7acb278 --- /dev/null +++ b/templates/content/app/components/editor/DocumentInfoPanel.tsx @@ -0,0 +1,39 @@ +import { useT } from "@agent-native/core/client"; +import type { Document } from "@shared/api"; + +import { DescriptionField } from "./DescriptionField"; +import { DocumentProperties } from "./DocumentProperties"; + +interface DocumentInfoPanelProps { + document: Document; + canEdit: boolean; + onSaveDescription: (description: string) => Promise | unknown; +} + +export function DocumentInfoPanel({ + document, + canEdit, + onSaveDescription, +}: DocumentInfoPanelProps) { + const t = useT(); + const isLocalFileDocument = document.source?.mode === "local-files"; + + return ( +
+ + {document.databaseMembership && !isLocalFileDocument ? ( + + ) : null} +
+ ); +} diff --git a/templates/content/app/components/editor/DocumentToolbar.tsx b/templates/content/app/components/editor/DocumentToolbar.tsx index e87e06345e..9d137a6c47 100644 --- a/templates/content/app/components/editor/DocumentToolbar.tsx +++ b/templates/content/app/components/editor/DocumentToolbar.tsx @@ -27,7 +27,9 @@ import { IconFolderOpen, IconPlus, IconHistory, + IconInfoCircle, IconLink, + IconMessageCircle, IconRefresh, IconShare3, IconTrash, @@ -273,6 +275,9 @@ interface DocumentToolbarProps { canDelete?: boolean; deletePending?: boolean; onDelete?: () => Promise; + utilityPanel: "info" | "comments" | null; + onUtilityPanelChange: (panel: "info" | "comments" | null) => void; + showCommentsControl?: boolean; } export function DocumentToolbar({ @@ -291,6 +296,9 @@ export function DocumentToolbar({ canDelete = false, deletePending = false, onDelete, + utilityPanel, + onUtilityPanelChange, + showCommentsControl = true, }: DocumentToolbarProps) { const t = useT(); const navigate = useNavigate(); @@ -776,6 +784,50 @@ export function DocumentToolbar({ {t("editor.toolbar.copyPageLink")} + + + + + {t("editor.toolbar.info")} + + + {showCommentsControl ? ( + + + + + {t("comments.title")} + + ) : null} + diff --git a/templates/content/app/components/editor/database/DatabaseView.tsx b/templates/content/app/components/editor/database/DatabaseView.tsx index 3a9a454644..aad1d85d38 100644 --- a/templates/content/app/components/editor/database/DatabaseView.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.tsx @@ -221,7 +221,6 @@ import { BuilderSourceReviewDialog, type BuilderReviewPublicationTransitions, } from "../database-sources/BuilderSourceReviewDialog"; -import { DescriptionField } from "../DescriptionField"; import { DocumentBlockFields } from "../DocumentBlockFields"; import { AddProperty, @@ -702,7 +701,6 @@ function DatabaseTable({ ); const setSourceWriteMode = useSetContentDatabaseSourceWriteMode(document.id); const setProperty = useSetDocumentProperty(document.id, document.id); - const updateDatabaseDocument = useUpdateDocument(); const updateView = useUpdateContentDatabaseView(document.id); // A deleted/missing database resolves to the unavailable union (no // `database` field) — treat it as no data; the inline-block wrapper owns @@ -2223,19 +2221,6 @@ function DatabaseTable({ return (
- - updateDatabaseDocument.mutateAsync({ - id: document.id, - description, - }) - } - />
{ - updateDocument.mutate({ id, isFavorite }); + updateDocument.mutate( + { id, isFavorite }, + { + onError: (error) => { + toast.error(t("sidebar.failedUpdateFavorite"), { + description: + error instanceof Error + ? error.message + : t("empty.genericError"), + }); + }, + }, + ); }, - [updateDocument], + [t, updateDocument], ); const handleRestoreDatabase = useCallback( diff --git a/templates/content/app/hooks/use-documents.test.ts b/templates/content/app/hooks/use-documents.test.ts index ee4ef925ff..8677b7c706 100644 --- a/templates/content/app/hooks/use-documents.test.ts +++ b/templates/content/app/hooks/use-documents.test.ts @@ -10,6 +10,9 @@ import { isDocumentUpdateConflict, mergeDocumentIntoDocumentCache, mergeDocumentIntoListDocumentsCache, + restoreQuerySnapshots, + setDocumentFavoriteInDatabaseCache, + setDocumentFavoriteInListCache, seedDatabaseItemDocumentCaches, } from "./use-documents"; @@ -136,6 +139,75 @@ describe("mergeDocumentIntoListDocumentsCache", () => { }); }); +describe("optimistic document favorites", () => { + it("updates array and object list caches without disturbing other pages", () => { + const favorite = { ...doc("a", null), isFavorite: true }; + expect( + setDocumentFavoriteInListCache( + [doc("a", null), doc("b", null)], + "a", + true, + ), + ).toEqual([favorite, doc("b", null)]); + expect( + setDocumentFavoriteInListCache( + { documents: [doc("a", null)], cursor: "next" }, + "a", + true, + ), + ).toEqual({ documents: [favorite], cursor: "next" }); + }); + + it("updates the matching row in every Files database cache shape", () => { + const database = { + items: [ + { + id: "item-a", + databaseId: "files", + position: 0, + document: doc("a", null), + properties: [], + }, + { + id: "item-b", + databaseId: "files", + position: 1, + document: doc("b", null), + properties: [], + }, + ], + } as any; + + const updated = setDocumentFavoriteInDatabaseCache(database, "a", true)!; + expect(updated.items[0].document.isFavorite).toBe(true); + expect(updated.items[1].document.isFavorite).toBe(false); + expect(database.items[0].document.isFavorite).toBe(false); + }); + + it("restores exact cache snapshots after a failed optimistic update", () => { + const queryClient = new QueryClient(); + const documentKey = documentQueryKey("a"); + const listKey = ["action", "list-documents", undefined] as const; + const originalDocument = doc("a", null); + const originalList = [originalDocument]; + queryClient.setQueryData(documentKey, { + ...originalDocument, + isFavorite: true, + }); + queryClient.setQueryData(listKey, [ + { ...originalDocument, isFavorite: true }, + ]); + + restoreQuerySnapshots(queryClient, [ + [documentKey, originalDocument], + [listKey, originalList], + ]); + + expect(queryClient.getQueryData(documentKey)).toEqual(originalDocument); + expect(queryClient.getQueryData(listKey)).toEqual(originalList); + }); +}); + describe("mergeDocumentIntoDocumentCache", () => { it("preserves fields that are only present on the get-document cache", () => { const updated = { diff --git a/templates/content/app/hooks/use-documents.ts b/templates/content/app/hooks/use-documents.ts index 48d5a61005..485d1f888e 100644 --- a/templates/content/app/hooks/use-documents.ts +++ b/templates/content/app/hooks/use-documents.ts @@ -1,5 +1,6 @@ import { useActionQuery, useActionMutation } from "@agent-native/core/client"; import type { + ContentDatabaseResponse, ContentDatabaseItem, Document, DocumentCreateRequest, @@ -82,6 +83,56 @@ export function mergeDocumentIntoListDocumentsCache( return { ...(old as object), documents: nextDocuments }; } +export function setDocumentFavoriteInListCache( + old: unknown, + documentId: string, + isFavorite: boolean, +) { + if (Array.isArray(old)) { + return old.map((item: Document) => + item.id === documentId ? { ...item, isFavorite } : item, + ); + } + + if (!old || typeof old !== "object") return old; + const cached = old as { documents?: unknown }; + if (!Array.isArray(cached.documents)) return old; + + return { + ...(old as object), + documents: cached.documents.map((item: Document) => + item.id === documentId ? { ...item, isFavorite } : item, + ), + }; +} + +export function setDocumentFavoriteInDatabaseCache( + current: ContentDatabaseResponse | undefined, + documentId: string, + isFavorite: boolean, +): ContentDatabaseResponse | undefined { + if (!current) return current; + let changed = false; + const items = current.items.map((item) => { + if (item.document.id !== documentId) return item; + changed = true; + return { + ...item, + document: { ...item.document, isFavorite }, + }; + }); + return changed ? { ...current, items } : current; +} + +export function restoreQuerySnapshots( + queryClient: Pick, + snapshots: Array<[readonly unknown[], unknown]>, +) { + for (const [queryKey, data] of snapshots) { + queryClient.setQueryData(queryKey, data); + } +} + export function seedDatabaseItemDocumentCaches( queryClient: Pick, item: ContentDatabaseItem, @@ -198,6 +249,61 @@ export function useUpdateDocument() { return useActionMutation( "update-document", { + onMutate: async (variables) => { + if (variables.isFavorite === undefined) return undefined; + + const documentKey = documentQueryKey(variables.id); + const databaseFilter = { + queryKey: ["action", "get-content-database"], + } as const; + await Promise.all([ + queryClient.cancelQueries({ queryKey: documentKey }), + queryClient.cancelQueries({ queryKey: LIST_DOCUMENTS_QUERY_KEY }), + queryClient.cancelQueries(databaseFilter), + ]); + + const previous: Array<[readonly unknown[], unknown]> = [ + [documentKey, queryClient.getQueryData(documentKey)], + [ + LIST_DOCUMENTS_QUERY_KEY, + queryClient.getQueryData(LIST_DOCUMENTS_QUERY_KEY), + ], + ...queryClient.getQueriesData( + databaseFilter, + ), + ]; + + queryClient.setQueryData(documentKey, (old: unknown) => + old && typeof old === "object" + ? { ...old, isFavorite: variables.isFavorite } + : old, + ); + queryClient.setQueryData(LIST_DOCUMENTS_QUERY_KEY, (old: unknown) => + setDocumentFavoriteInListCache( + old, + variables.id, + variables.isFavorite!, + ), + ); + queryClient.setQueriesData( + databaseFilter, + (current) => + setDocumentFavoriteInDatabaseCache( + current, + variables.id, + variables.isFavorite!, + ), + ); + + return { previous }; + }, + onError: (_error, variables, context) => { + if (variables.isFavorite === undefined) return; + const rollback = context as + | { previous?: Array<[readonly unknown[], unknown]> } + | undefined; + restoreQuerySnapshots(queryClient, rollback?.previous ?? []); + }, onSuccess: (data, variables) => { // A CAS conflict is a normal (non-thrown) result, not a successful // save — converge the caches to the returned server document (so the @@ -214,6 +320,17 @@ export function useUpdateDocument() { queryClient.setQueryData(LIST_DOCUMENTS_QUERY_KEY, (old: unknown) => mergeDocumentIntoListDocumentsCache(old, serverDocument), ); + if (variables.isFavorite !== undefined) { + queryClient.setQueriesData( + { queryKey: ["action", "get-content-database"] }, + (current) => + setDocumentFavoriteInDatabaseCache( + current, + variables.id, + serverDocument.isFavorite, + ), + ); + } queryClient.invalidateQueries({ queryKey: ["action", "get-document", { id: variables.id }], }); @@ -230,6 +347,17 @@ export function useUpdateDocument() { queryClient.setQueryData(LIST_DOCUMENTS_QUERY_KEY, (old: unknown) => mergeDocumentIntoListDocumentsCache(old, data), ); + if (variables.isFavorite !== undefined) { + queryClient.setQueriesData( + { queryKey: ["action", "get-content-database"] }, + (current) => + setDocumentFavoriteInDatabaseCache( + current, + variables.id, + data.isFavorite, + ), + ); + } queryClient.invalidateQueries({ queryKey: ["action", "get-document", { id: variables.id }], }); diff --git a/templates/content/app/i18n-data.ts b/templates/content/app/i18n-data.ts index 70cf523fbc..8272fcf4e7 100644 --- a/templates/content/app/i18n-data.ts +++ b/templates/content/app/i18n-data.ts @@ -2668,6 +2668,8 @@ const editorToolbarMessages = { failedToDisconnect: "Failed to disconnect.", failedToUpdateSharing: "Failed to update sharing", hideInSearch: "Hide in search", + info: "Info", + closeUtilityPanel: "Close panel", hideInSearchDescription: "Hide from Organization and search. People with the link can still view.", importingFromNotion: "Importing from Notion...", @@ -3063,6 +3065,8 @@ const enUS = { }, comments: { add: "Add a comment...", + title: "Comments", + empty: "No comments yet.", cancel: "Cancel", submit: "Comment", askAi: "Ask AI", @@ -3110,6 +3114,7 @@ const enUS = { failedDeletePage: "Failed to delete page", failedPermanentDeleteDatabase: "Failed to permanently delete database", failedMovePage: "Failed to move page", + failedUpdateFavorite: "Could not update favorite", failedRemoveLocalFiles: "Failed to remove local files", failedRestoreDatabase: "Failed to restore database", deletePageDescription: diff --git a/templates/content/changelog/2026-07-18-page-details-now-live-in-a-shared-info-and-comments-rail-dat.md b/templates/content/changelog/2026-07-18-page-details-now-live-in-a-shared-info-and-comments-rail-dat.md new file mode 100644 index 0000000000..f7bbbc5466 --- /dev/null +++ b/templates/content/changelog/2026-07-18-page-details-now-live-in-a-shared-info-and-comments-rail-dat.md @@ -0,0 +1,6 @@ +--- +type: improved +date: 2026-07-18 +--- + +Page details now live in a shared Info and Comments rail, databases stay focused on their data, and Favorites update immediately. From bc680eb8aa347124f099b48535b78732446ef969 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Sat, 18 Jul 2026 07:17:41 -0400 Subject: [PATCH 30/76] fix(content): silence utility sheet warning --- templates/content/app/components/editor/DocumentEditor.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/templates/content/app/components/editor/DocumentEditor.tsx b/templates/content/app/components/editor/DocumentEditor.tsx index ddbbc8988f..7b24271233 100644 --- a/templates/content/app/components/editor/DocumentEditor.tsx +++ b/templates/content/app/components/editor/DocumentEditor.tsx @@ -1717,7 +1717,11 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) { } }} > - + {utilityPanelTitle} From 0ef4bea64a6eab3cb925a9f4bc2739af60b30861 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Sat, 18 Jul 2026 09:03:52 -0400 Subject: [PATCH 31/76] fix(content): clarify global workspace navigation --- .../content/actions/content-spaces.db.test.ts | 81 +++++++++ templates/content/actions/list-documents.ts | 34 +++- .../DatabaseView.local-folder.layout.test.ts | 31 ++++ .../editor/database/DatabaseView.tsx | 35 ++-- .../sidebar/DocumentSidebar.layout.test.ts | 24 +-- .../components/sidebar/DocumentSidebar.tsx | 145 ++++++--------- .../components/sidebar/DocumentTreeItem.tsx | 166 +++++++++++++++++- .../sidebar/select-content-space.test.ts | 149 ++-------------- .../sidebar/select-content-space.ts | 31 +--- .../content/app/hooks/use-create-page.ts | 7 +- templates/content/app/i18n-data.ts | 2 + .../content/app/routes/_app.local-files.tsx | 4 +- ...es-independently-expandable-restored-or.md | 6 + templates/content/server/lib/documents.ts | 19 +- 14 files changed, 449 insertions(+), 285 deletions(-) create mode 100644 templates/content/app/components/editor/database/DatabaseView.local-folder.layout.test.ts create mode 100644 templates/content/changelog/2026-07-18-made-content-workspaces-independently-expandable-restored-or.md diff --git a/templates/content/actions/content-spaces.db.test.ts b/templates/content/actions/content-spaces.db.test.ts index a8ca54573e..f98c406665 100644 --- a/templates/content/actions/content-spaces.db.test.ts +++ b/templates/content/actions/content-spaces.db.test.ts @@ -21,6 +21,7 @@ let organizationContentSpaceId: typeof import("./_content-spaces.js").organizati let resolveContentSpaceAccess: typeof import("./_content-space-access.js").resolveContentSpaceAccess; let listContentSpacesAction: typeof import("./list-content-spaces.js").default; let ensureContentSpacesAction: typeof import("./ensure-content-spaces.js").default; +let listDocumentsAction: typeof import("./list-documents.js").default; let deleteContentDatabaseAction: typeof import("./delete-content-database.js").default; let deleteDocumentAction: typeof import("./delete-document.js").default; @@ -42,6 +43,7 @@ beforeAll(async () => { listContentSpacesAction = (await import("./list-content-spaces.js")).default; ensureContentSpacesAction = (await import("./ensure-content-spaces.js")) .default; + listDocumentsAction = (await import("./list-documents.js")).default; deleteContentDatabaseAction = (await import("./delete-content-database.js")) .default; deleteDocumentAction = (await import("./delete-document.js")).default; @@ -367,6 +369,85 @@ describe("Content space provisioning", () => { }); }); + it("lists favorites across every authorized organization without admitting private or unrelated rows", async () => { + await addOrganization("org-favorites-a", "Favorites A"); + await addOrganization("org-favorites-b", "Favorites B"); + await addOrganization("org-favorites-other", "Favorites Other"); + await addMember("owner-favorites-a", "org-favorites-a", OWNER, "owner"); + await addMember("owner-favorites-b", "org-favorites-b", OWNER, "owner"); + await addMember( + "owner-favorites-other", + "org-favorites-other", + OWNER, + "owner", + ); + await addMember("member-favorites-a", "org-favorites-a", MEMBER); + await addMember("member-favorites-b", "org-favorites-b", MEMBER); + await runWithRequestContext({ userEmail: OWNER }, () => + provisionContentSpaces(getDb(), OWNER), + ); + const now = new Date().toISOString(); + await getDb() + .insert(schema.documents) + .values([ + { + id: "favorite-org-a", + ownerEmail: OWNER, + orgId: "org-favorites-a", + spaceId: organizationContentSpaceId("org-favorites-a"), + title: "Favorite A", + isFavorite: 1, + visibility: "org", + createdAt: now, + updatedAt: now, + }, + { + id: "favorite-org-b", + ownerEmail: OWNER, + orgId: "org-favorites-b", + spaceId: organizationContentSpaceId("org-favorites-b"), + title: "Favorite B", + isFavorite: 1, + visibility: "org", + createdAt: now, + updatedAt: now, + }, + { + id: "private-org-a", + ownerEmail: OWNER, + orgId: "org-favorites-a", + spaceId: organizationContentSpaceId("org-favorites-a"), + title: "Private A", + isFavorite: 1, + visibility: "private", + createdAt: now, + updatedAt: now, + }, + { + id: "favorite-unrelated", + ownerEmail: OWNER, + orgId: "org-favorites-other", + spaceId: organizationContentSpaceId("org-favorites-other"), + title: "Unrelated", + isFavorite: 1, + visibility: "org", + createdAt: now, + updatedAt: now, + }, + ]); + + const result = await runWithRequestContext( + { userEmail: MEMBER, orgId: "org-favorites-a" }, + () => listDocumentsAction.run({}), + ); + const ids = result.documents.map((document) => document.id); + expect(ids).toEqual( + expect.arrayContaining(["favorite-org-a", "favorite-org-b"]), + ); + expect(ids).not.toContain("private-org-a"); + expect(ids).not.toContain("favorite-unrelated"); + }); + it("denies an unrelated authenticated user from a personal space", async () => { const spaceId = personalContentSpaceId(OWNER); await runWithRequestContext({ userEmail: OUTSIDER }, () => diff --git a/templates/content/actions/list-documents.ts b/templates/content/actions/list-documents.ts index 922ac0f3cf..2e2a96f6d6 100644 --- a/templates/content/actions/list-documents.ts +++ b/templates/content/actions/list-documents.ts @@ -17,6 +17,7 @@ import { parseDocumentFavorite, parseDocumentHideFromSearch, } from "../server/lib/documents.js"; +import { listContentOrganizationMemberships } from "./_content-space-access.js"; import { serializeDatabaseMembership } from "./_database-utils.js"; import { serializeDocumentSource } from "./_document-source.js"; import { parseDatabaseViewConfig } from "./_property-utils.js"; @@ -50,7 +51,23 @@ export default defineAction({ run: async () => { const db = getDb(); const userEmail = getRequestUserEmail(); - const orgId = getRequestOrgId(); + const activeOrgId = getRequestOrgId(); + const memberships = userEmail + ? await listContentOrganizationMemberships(userEmail) + : []; + const authorizedOrgIds = [ + ...new Set([ + ...memberships.map((membership) => membership.orgId), + ...(!userEmail && activeOrgId ? [activeOrgId] : []), + ]), + ]; + const accessContexts = [ + { userEmail: userEmail ?? undefined }, + ...authorizedOrgIds.map((orgId) => ({ + userEmail: userEmail ?? undefined, + orgId, + })), + ]; // Projection that deliberately avoids pulling the full `content` blob: // document bodies can be multi-MB, and the list/tree path only needs a // short preview plus the true length. `substr` truncates the transferred @@ -84,8 +101,15 @@ export default defineAction({ .from(schema.documents) .where( and( - accessFilter(schema.documents, schema.documentShares), - documentDiscoveryFilter(), + or( + ...accessContexts.map((context) => + accessFilter(schema.documents, schema.documentShares, context), + ), + ), + documentDiscoveryFilter({ + userEmail, + orgIds: authorizedOrgIds, + }), ), ) .orderBy(asc(schema.documents.position)); @@ -117,7 +141,7 @@ export default defineAction({ )!, ); } - if (orgId) { + for (const orgId of authorizedOrgIds) { principalClauses.push( and( eq(schema.documentShares.principalType, "org"), @@ -277,7 +301,7 @@ export default defineAction({ if ( userEmail && d.ownerEmail === userEmail && - (orgId ? d.orgId === orgId : !d.orgId) + (!d.orgId || authorizedOrgIds.includes(d.orgId)) ) { accessRole = "owner"; } diff --git a/templates/content/app/components/editor/database/DatabaseView.local-folder.layout.test.ts b/templates/content/app/components/editor/database/DatabaseView.local-folder.layout.test.ts new file mode 100644 index 0000000000..df4739e6e8 --- /dev/null +++ b/templates/content/app/components/editor/database/DatabaseView.local-folder.layout.test.ts @@ -0,0 +1,31 @@ +import { readFileSync } from "node:fs"; + +import { describe, expect, it } from "vitest"; + +describe("Files database local-folder source entry", () => { + it("offers the shared folder flow only for canonical Files databases", () => { + const databaseView = readFileSync( + new URL("./DatabaseView.tsx", import.meta.url), + "utf8", + ); + const localFilesRoute = readFileSync( + new URL("../../../routes/_app.local-files.tsx", import.meta.url), + "utf8", + ); + + expect(databaseView).toContain( + 'isFilesDatabase={document.database?.systemRole === "files"}', + ); + expect(databaseView).toContain('label="Local folder"'); + expect(databaseView).toContain( + "navigate(`/local-files?databaseId=${documentId}`)", + ); + expect(localFilesRoute).toContain( + 'const targetDatabaseId = searchParams.get("databaseId") || undefined', + ); + expect(localFilesRoute).toContain("databaseId: targetDatabaseId"); + expect(localFilesRoute).toContain( + "createSourceBackedSpace: !targetSpaceId && !targetDatabaseId", + ); + }); +}); diff --git a/templates/content/app/components/editor/database/DatabaseView.tsx b/templates/content/app/components/editor/database/DatabaseView.tsx index aad1d85d38..ca1fa86fd6 100644 --- a/templates/content/app/components/editor/database/DatabaseView.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.tsx @@ -8,7 +8,6 @@ import { useSession, useT, } from "@agent-native/core/client"; -import { useOrg, useSwitchOrg } from "@agent-native/core/client/org"; import { AlertDialog, AlertDialogAction, @@ -114,6 +113,7 @@ import { IconEyeOff, IconFilter, IconFileText, + IconFolder, IconForms, IconGripVertical, IconLayoutKanban, @@ -678,8 +678,6 @@ function DatabaseTable({ const navigate = useNavigate(); const queryClient = useQueryClient(); const contentSpacesQuery = useContentSpaces(); - const activeOrgQuery = useOrg(); - const switchOrg = useSwitchOrg(); const [, setStoredSpaceId] = useLocalStorage( SELECTED_CONTENT_SPACE_STORAGE_KEY, null, @@ -839,10 +837,6 @@ function DatabaseTable({ const refreshSourceInFlightRef = useRef(null); const hydrationSourceInFlightRef = useRef(null); const workspaceSelectionQueueRef = useRef(createContentSpaceSelectionQueue()); - const activeOrgIdRef = useRef(activeOrgQuery.data?.orgId); - useEffect(() => { - activeOrgIdRef.current = activeOrgQuery.data?.orgId; - }, [activeOrgQuery.data?.orgId]); const builderReviewGenerationRef = useRef(0); const builderReviewSessionRef = useRef(null); const [ @@ -1323,11 +1317,6 @@ function DatabaseTable({ .current(async () => { await selectContentSpace({ space, - activeOrgId: activeOrgIdRef.current, - switchOrg: async (orgId) => { - await switchOrg.mutateAsync(orgId); - activeOrgIdRef.current = orgId; - }, syncApplicationState: (selected) => setClientAppState( "content-space", @@ -2738,6 +2727,7 @@ function DatabaseTable({ open={settingsOpen} panel={settingsPanel} documentId={document.id} + isFilesDatabase={document.database?.systemRole === "files"} canEdit={canEdit} canManage={document.canManage === true} activeView={activeView} @@ -7030,6 +7020,7 @@ function DatabaseSettingsPanelSheet({ open, panel, documentId, + isFilesDatabase, canEdit, canManage, activeView, @@ -7065,6 +7056,7 @@ function DatabaseSettingsPanelSheet({ open: boolean; panel: DatabaseSettingsPanel; documentId: string; + isFilesDatabase: boolean; canEdit: boolean; canManage: boolean; activeView: ContentDatabaseView; @@ -7178,6 +7170,7 @@ function DatabaseSettingsPanelSheet({ source={source} sources={sources} documentId={documentId} + isFilesDatabase={isFilesDatabase} itemCount={items.length} canEdit={canEdit} canManage={canManage} @@ -7560,6 +7553,7 @@ function DatabaseSettingsSourcePanel({ source, sources, documentId, + isFilesDatabase, itemCount, canEdit, canManage, @@ -7581,6 +7575,7 @@ function DatabaseSettingsSourcePanel({ source: ContentDatabaseSource | null; sources: ContentDatabaseSource[]; documentId: string; + isFilesDatabase: boolean; itemCount: number; canEdit: boolean; canManage: boolean; @@ -7610,6 +7605,7 @@ function DatabaseSettingsSourcePanel({ sourcePendingOperations: DatabaseSourcePendingOperations; }) { const { isCodeMode } = useCodeMode(); + const navigate = useNavigate(); const builderSources = databaseAttachedBuilderSources(sources, source); const builderStatus = useBuilderStatus(); const builderConfigured = builderStatus.status?.configured === true; @@ -7656,6 +7652,10 @@ function DatabaseSettingsSourcePanel({ ) } onOpenNotion={() => onNavPush({ kind: "addSource" })} + onOpenLocalFolder={() => + navigate(`/local-files?databaseId=${documentId}`) + } + showLocalFolder={isFilesDatabase} onOpenSecondary={(secondary) => onNavPush({ kind: "secondarySource", @@ -8315,6 +8315,8 @@ function SourcesListView({ reviewableCount, onOpenBuilder, onOpenNotion, + onOpenLocalFolder, + showLocalFolder, onOpenSecondary, onAddSource, }: { @@ -8325,6 +8327,8 @@ function SourcesListView({ reviewableCount: number; onOpenBuilder: (source?: ContentDatabaseSource) => void; onOpenNotion: () => void; + onOpenLocalFolder: () => void; + showLocalFolder: boolean; onOpenSecondary: (source: ContentDatabaseSource) => void; onAddSource: () => void; }) { @@ -8403,6 +8407,13 @@ function SourcesListView({ } onClick={onOpenNotion} /> + {showLocalFolder ? ( + } + label="Local folder" + onClick={onOpenLocalFolder} + /> + ) : null}
diff --git a/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts b/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts index 34a082a91b..9c23b3cca6 100644 --- a/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts +++ b/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts @@ -212,7 +212,9 @@ describe("document sidebar layout", () => { expect(sidebar).not.toContain( '
', ); - expect(sidebar).not.toContain(""); + expect(sidebar).toContain(""); + expect(sidebar).toContain('t("sidebar.addWorkspace")'); + expect(sidebar).toContain(''); }); it("keeps the trashed inline database lifecycle visible in the sidebar", () => { @@ -252,15 +254,14 @@ describe("document sidebar layout", () => { ); }); - it("keeps local files above extensions and gates the dev database link to Code mode", () => { + it("removes the standalone Local files destination and gates the dev database link to Code mode", () => { const sidebar = readSidebarSource("./DocumentSidebar.tsx"); // The dev-only "Database admin" link must never render for normal users; // it is allowed only behind the Code mode gate. expect(sidebar).toContain("isCodeMode ? "), - ); + expect(sidebar).not.toContain("renderLocalFilesNavButton"); + expect(sidebar).not.toContain('to="/local-files"\n className'); }); it("persists tree section collapse state and exposes local file actions", () => { @@ -293,15 +294,18 @@ describe("document sidebar layout", () => { it("keeps favorite rows constrained so long titles ellipsize", () => { const sidebar = readSidebarSource("./DocumentSidebar.tsx"); + const treeItem = readSidebarSource("./DocumentTreeItem.tsx"); expect(sidebar).toContain("const favoriteRowWidth ="); expect(sidebar).toContain("{showFavorites && ("); expect(sidebar).toContain('"mb-2 min-w-0"'); - expect(sidebar).toContain( - '"flex w-full min-w-0 items-center gap-2 rounded-md px-4 py-[5px] text-start text-sm"', - ); - expect(sidebar).toContain("width:"); - expect(sidebar).toContain('"min-w-0 flex-1 truncate"'); + expect(sidebar).toContain(" 0"); }); }); diff --git a/templates/content/app/components/sidebar/DocumentSidebar.tsx b/templates/content/app/components/sidebar/DocumentSidebar.tsx index 2f10529db3..205f16bf85 100644 --- a/templates/content/app/components/sidebar/DocumentSidebar.tsx +++ b/templates/content/app/components/sidebar/DocumentSidebar.tsx @@ -11,7 +11,7 @@ import { ExtensionSlot, ExtensionsSidebarSection, } from "@agent-native/core/client/extensions"; -import { useOrg, useSwitchOrg } from "@agent-native/core/client/org"; +import { OrgSwitcher } from "@agent-native/core/client/org"; import { closestCenter, DndContext, @@ -35,6 +35,8 @@ import type { } from "@shared/api"; import { IconBrain, + IconFolder, + IconFolderOpen, IconPlus, IconRestore, IconSearch, @@ -43,7 +45,6 @@ import { IconTrashX, IconLayoutSidebarLeftCollapse, IconLayoutSidebarLeftExpand, - IconFolderOpen, IconChevronDown, IconChevronRight, IconDots, @@ -121,11 +122,15 @@ import { getDocumentSidebarSections, isDirectLocalDocument, } from "./document-sidebar-sections"; -import { DocumentSidebarIcon, DocumentTreeItem } from "./DocumentTreeItem"; +import { + DocumentSidebarIcon, + DocumentTreeItem, + FavoriteDocumentItem, +} from "./DocumentTreeItem"; import { NotionButton } from "./NotionButton"; import { contentSpaceAvailability, - contentSpaceForActiveOrg, + contentSpaceForStoredSelection, createContentSpaceSelectionQueue, ensureWorkspaceExpanded, SELECTED_CONTENT_SPACE_STORAGE_KEY, @@ -332,14 +337,7 @@ export function DocumentSidebar({ const updateDocument = useUpdateDocument(); const contentSpacesQuery = useContentSpaces(); const ensureContentSpaces = useEnsureContentSpaces(); - const activeOrgQuery = useOrg(); - const activeOrg = activeOrgQuery.data; - const activeOrgIdRef = useRef(activeOrg?.orgId); const workspaceSelectionQueueRef = useRef(createContentSpaceSelectionQueue()); - useEffect(() => { - activeOrgIdRef.current = activeOrg?.orgId; - }, [activeOrg?.orgId]); - const switchOrg = useSwitchOrg(); const contentSpaces = contentSpacesQuery.data?.spaces ?? []; const spaceProvisionAttemptedRef = useRef(false); useEffect(() => { @@ -360,10 +358,9 @@ export function DocumentSidebar({ SELECTED_CONTENT_SPACE_STORAGE_KEY, null, ); - const selectedSpace = contentSpaceForActiveOrg({ + const selectedSpace = contentSpaceForStoredSelection({ spaces: contentSpaces, storedSpaceId, - activeOrgId: activeOrg?.orgId, }); const [expandedWorkspaceIds, setExpandedWorkspaceIds] = useLocalStorage< string[] @@ -373,17 +370,11 @@ export function DocumentSidebar({ contentSpacesLoading: contentSpacesQuery.isLoading, contentSpacesFetching: contentSpacesQuery.isFetching, contentSpacesError: contentSpacesQuery.isError, - activeOrganizationResolved: activeOrgQuery.isSuccess, - activeOrganizationError: activeOrgQuery.isError, provisioningAttempted: spaceProvisionAttemptedRef.current, provisioningPending: ensureContentSpaces.isPending, provisioningError: ensureContentSpaces.isError, }); const handleRetryContentSpaces = useCallback(() => { - if (activeOrgQuery.isError) { - void activeOrgQuery.refetch(); - return; - } if (contentSpacesQuery.isError) { spaceProvisionAttemptedRef.current = false; void contentSpacesQuery.refetch(); @@ -391,7 +382,7 @@ export function DocumentSidebar({ } spaceProvisionAttemptedRef.current = true; ensureContentSpaces.mutate({}); - }, [activeOrgQuery, contentSpacesQuery, ensureContentSpaces]); + }, [contentSpacesQuery, ensureContentSpaces]); useEffect(() => { if (selectedSpace && selectedSpace.id !== storedSpaceId) { setStoredSpaceId(selectedSpace.id); @@ -415,11 +406,6 @@ export function DocumentSidebar({ await workspaceSelectionQueueRef.current(() => selectContentSpace({ space, - activeOrgId: activeOrgIdRef.current, - switchOrg: async (orgId) => { - await switchOrg.mutateAsync(orgId); - activeOrgIdRef.current = orgId; - }, syncApplicationState: (selected) => setClientAppState( "content-space", @@ -446,7 +432,7 @@ export function DocumentSidebar({ return false; } }, - [navigate, setExpandedWorkspaceIds, setStoredSpaceId, switchOrg], + [navigate, setExpandedWorkspaceIds, setStoredSpaceId], ); useEffect(() => { if (!selectedSpace) return; @@ -483,7 +469,6 @@ export function DocumentSidebar({ ); const [removeLocalFilesDialogOpen, setRemoveLocalFilesDialogOpen] = useState(false); - const localFilesActive = location.pathname.startsWith("/local-files"); const agentActive = location.pathname.startsWith("/agent"); const settingsActive = location.pathname.startsWith("/settings"); const sensors = useSensors( @@ -1057,23 +1042,6 @@ export function DocumentSidebar({ ) : null; - const renderLocalFilesNavButton = () => ( - - - - {t("sidebar.localFiles")} - - - ); - const renderSettingsNavButton = () => ( void handleSelectContentSpace(space)} - disabled={switchOrg.isPending} > {space.name} @@ -1338,6 +1305,30 @@ export function DocumentSidebar({
); })} + + + + + + + + + {t("sidebar.localFolder")} + + + +
) : contentSpaceState === "loading" ? (
@@ -1348,9 +1339,7 @@ export function DocumentSidebar({ compact onRetry={handleRetryContentSpaces} retrying={ - activeOrgQuery.isFetching || - contentSpacesQuery.isFetching || - ensureContentSpaces.isPending + contentSpacesQuery.isFetching || ensureContentSpaces.isPending } /> )} @@ -1469,22 +1458,6 @@ export function DocumentSidebar({ {t("sidebar.expand")} {renderCollapsedNewButton()} - - - - - - - {t("sidebar.localFiles")} - -
- +
+ + + {t("sidebar.favorites")}
{favorites.map((doc) => ( - + onCreateChildPage={() => void handleCreatePage(doc.id)} + onCreateChildDatabase={() => + void handleCreateDatabase(doc.id) + } + onRemoveFavorite={() => + handleToggleFavorite(doc.id, false) + } + onDelete={() => void handleDelete(doc.id)} + /> ))}
)} @@ -1688,7 +1655,6 @@ export function DocumentSidebar({
- {renderLocalFilesNavButton()} {renderAgentNavButton()} {renderSettingsNavButton()}
@@ -1719,6 +1685,7 @@ export function DocumentSidebar({
+
{/* Resize handle */} diff --git a/templates/content/app/components/sidebar/DocumentTreeItem.tsx b/templates/content/app/components/sidebar/DocumentTreeItem.tsx index 0a894e77f1..7ab8ec0192 100644 --- a/templates/content/app/components/sidebar/DocumentTreeItem.tsx +++ b/templates/content/app/components/sidebar/DocumentTreeItem.tsx @@ -6,7 +6,7 @@ import { verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import type { DocumentTreeNode } from "@shared/api"; +import type { Document, DocumentTreeNode } from "@shared/api"; import { IconChevronRight, IconDatabase, @@ -88,6 +88,170 @@ export function DocumentSidebarIcon({ return ; } +export function FavoriteDocumentItem({ + document, + active, + sidebarWidth, + onSelect, + onCreateChildPage, + onCreateChildDatabase, + onRemoveFavorite, + onDelete, +}: { + document: Document; + active: boolean; + sidebarWidth?: number; + onSelect: () => void; + onCreateChildPage: () => void; + onCreateChildDatabase: () => void; + onRemoveFavorite: () => void; + onDelete: () => void; +}) { + const t = useT(); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const canEdit = document.canEdit !== false; + const canManage = + document.canManage === true || + document.accessRole === "owner" || + document.accessRole === "admin"; + const canCreateChild = canEdit && document.source?.mode !== "local-files"; + const title = document.title || t("sidebar.untitled"); + + return ( +
+ + + + {title} +
event.stopPropagation()} + > + {(canEdit || canManage) && ( + + + + + + {canEdit && ( + { + event.stopPropagation(); + onRemoveFavorite(); + }} + > + + Remove from favorites + + )} + {canEdit && canManage && } + {canManage && ( + { + event.stopPropagation(); + setDeleteDialogOpen(true); + }} + > + + {t("database.delete")} + + )} + + + )} + {canCreateChild && ( + + + + + + + + {t("sidebar.addChild")} + + + { + event.stopPropagation(); + onCreateChildPage(); + }} + > + + {t("sidebar.page")} + + { + event.stopPropagation(); + onCreateChildDatabase(); + }} + > + + {t("sidebar.database")} + + + + )} +
+ + + + + + {t("sidebar.deletePageQuestion")} + + + {t("sidebar.deletePageDescription", { title })} + + + + {t("comments.cancel")} + + {t("database.delete")} + + + + +
+ ); +} + export function DocumentTreeItem({ node, depth, diff --git a/templates/content/app/components/sidebar/select-content-space.test.ts b/templates/content/app/components/sidebar/select-content-space.test.ts index f21c24d6d9..9eb646f56f 100644 --- a/templates/content/app/components/sidebar/select-content-space.test.ts +++ b/templates/content/app/components/sidebar/select-content-space.test.ts @@ -4,7 +4,7 @@ import type { ContentSpaceSummary } from "@/hooks/use-content-spaces"; import { contentSpaceAvailability, - contentSpaceForActiveOrg, + contentSpaceForStoredSelection, contentSpaceForCatalogItem, contentSpaceIdForCreate, createContentSpaceSelectionQueue, @@ -34,7 +34,6 @@ describe("selectContentSpace", () => { it("serializes rapid workspace selections", async () => { const enqueue = createContentSpaceSelectionQueue(); const events: string[] = []; - let activeOrgId: string | null = null; let releaseFirst!: () => void; let markFirstStarted!: () => void; const firstPending = new Promise((resolve) => { @@ -59,16 +58,11 @@ describe("selectContentSpace", () => { enqueue(() => selectContentSpace({ space: selected, - activeOrgId, - switchOrg: async (orgId) => { - events.push(`switch:${orgId}`); + syncApplicationState: async (next) => { if (wait) { markFirstStarted(); await firstPending; } - activeOrgId = orgId; - }, - syncApplicationState: async (next) => { events.push(`state:${next.id}`); }, persistSelection: (id) => events.push(`persist:${id}`), @@ -80,35 +74,26 @@ describe("selectContentSpace", () => { const second = select(personal); await firstStarted; - expect(events).toEqual(["switch:builder-org"]); + expect(events).toEqual([]); releaseFirst(); await Promise.all([first, second]); expect(events).toEqual([ - "switch:builder-org", "state:builder", "persist:builder", "open:builder-files", - "switch:null", "state:personal", "persist:personal", "open:personal-files", ]); - expect(activeOrgId).toBeNull(); }); - it("supports Personal to organization to Personal with one complete flow per selection", async () => { - let activeOrgId: string | null = null; + it("supports Personal to organization to Personal without changing framework organization context", async () => { let storedSpaceId: string | null = null; const opened: string[] = []; const states: string[] = []; - const switchOrg = vi.fn(async (orgId: string | null) => { - activeOrgId = orgId; - }); const select = async (selected: ContentSpaceSummary) => { await selectContentSpace({ space: selected, - activeOrgId, - switchOrg, syncApplicationState: async (next) => states.push(next.id), persistSelection: (id) => { storedSpaceId = id; @@ -132,7 +117,6 @@ describe("selectContentSpace", () => { await select(builder); await select(personal); - expect(switchOrg.mock.calls).toEqual([["builder-org"], [null]]); expect(states).toEqual(["personal", "builder", "personal"]); expect(opened).toEqual([ "personal-files", @@ -142,11 +126,8 @@ describe("selectContentSpace", () => { expect(storedSpaceId).toBe("personal"); }); - it("switches organization context before persisting another org workspace", async () => { + it("syncs state before persisting and opening another org workspace", async () => { const events: string[] = []; - const switchOrg = vi.fn(async (orgId: string | null) => { - events.push(`switch:${orgId}`); - }); const persistSelection = vi.fn((spaceId: string) => { events.push(`persist:${spaceId}`); }); @@ -159,81 +140,18 @@ describe("selectContentSpace", () => { await selectContentSpace({ space: space(), - activeOrgId: "org_2", - switchOrg, syncApplicationState, persistSelection, openFiles, }); expect(events).toEqual([ - "switch:org_1", "state:space_1", "persist:space_1", "open:files_document_1", ]); }); - it("switches explicitly when the active organization is still loading", async () => { - const switchOrg = vi.fn(async () => undefined); - const persistSelection = vi.fn(); - const syncApplicationState = vi.fn(async () => undefined); - const openFiles = vi.fn(); - - await selectContentSpace({ - space: space({ kind: "personal", orgId: null }), - activeOrgId: undefined, - switchOrg, - syncApplicationState, - persistSelection, - openFiles, - }); - - expect(switchOrg).toHaveBeenCalledWith(null); - expect(persistSelection).toHaveBeenCalledWith("space_1"); - }); - - it("switches to personal context for a personal workspace", async () => { - const switchOrg = vi.fn(async () => undefined); - const persistSelection = vi.fn(); - const syncApplicationState = vi.fn(async () => undefined); - const openFiles = vi.fn(); - - await selectContentSpace({ - space: space({ kind: "personal", orgId: null }), - activeOrgId: "org_1", - switchOrg, - syncApplicationState, - persistSelection, - openFiles, - }); - - expect(switchOrg).toHaveBeenCalledWith(null); - expect(persistSelection).toHaveBeenCalledWith("space_1"); - }); - - it("does not persist a selection when organization switching fails", async () => { - const error = new Error("Organization switch failed"); - const persistSelection = vi.fn(); - const syncApplicationState = vi.fn(async () => undefined); - const openFiles = vi.fn(); - - await expect( - selectContentSpace({ - space: space(), - activeOrgId: "org_2", - switchOrg: async () => Promise.reject(error), - syncApplicationState, - persistSelection, - openFiles, - }), - ).rejects.toBe(error); - - expect(persistSelection).not.toHaveBeenCalled(); - expect(syncApplicationState).not.toHaveBeenCalled(); - expect(openFiles).not.toHaveBeenCalled(); - }); - it("does not persist or navigate when application state cannot be updated", async () => { const error = new Error("Application state failed"); const persistSelection = vi.fn(); @@ -242,8 +160,6 @@ describe("selectContentSpace", () => { await expect( selectContentSpace({ space: space(), - activeOrgId: "org_1", - switchOrg: vi.fn(async () => undefined), syncApplicationState: async () => Promise.reject(error), persistSelection, openFiles, @@ -254,22 +170,18 @@ describe("selectContentSpace", () => { expect(openFiles).not.toHaveBeenCalled(); }); - it("persists immediately when the organization context already matches", async () => { - const switchOrg = vi.fn(async () => undefined); + it("persists and opens the selected Files database", async () => { const persistSelection = vi.fn(); const syncApplicationState = vi.fn(async () => undefined); const openFiles = vi.fn(); await selectContentSpace({ space: space(), - activeOrgId: "org_1", - switchOrg, syncApplicationState, persistSelection, openFiles, }); - expect(switchOrg).not.toHaveBeenCalled(); expect(persistSelection).toHaveBeenCalledWith("space_1"); expect(syncApplicationState).toHaveBeenCalledWith( expect.objectContaining({ id: "space_1" }), @@ -386,48 +298,35 @@ describe("contentSpaceIdForCreate", () => { }); }); -describe("contentSpaceForActiveOrg", () => { - it("keeps the stored workspace when its organization is active", () => { +describe("contentSpaceForStoredSelection", () => { + it("keeps the stored workspace independently of framework organization context", () => { const selected = space({ id: "space_2", orgId: "org_1" }); expect( - contentSpaceForActiveOrg({ + contentSpaceForStoredSelection({ spaces: [space(), selected], storedSpaceId: selected.id, - activeOrgId: "org_1", }), ).toBe(selected); }); - it("reconciles an independently switched organization before querying Files", () => { - const oldSpace = space({ id: "old", orgId: "org_1" }); - const newSpace = space({ id: "new", orgId: "org_2" }); - expect( - contentSpaceForActiveOrg({ - spaces: [oldSpace, newSpace], - storedSpaceId: oldSpace.id, - activeOrgId: "org_2", - }), - ).toBe(newSpace); - }); - - it("waits for active organization context instead of querying a stale Files database", () => { + it("does not change Content workspace after an independent framework organization switch", () => { + const selected = space({ id: "selected", orgId: "org_1" }); + const other = space({ id: "other", orgId: "org_2" }); expect( - contentSpaceForActiveOrg({ - spaces: [space()], - storedSpaceId: "space_1", - activeOrgId: undefined, + contentSpaceForStoredSelection({ + spaces: [selected, other], + storedSpaceId: selected.id, }), - ).toBeNull(); + ).toBe(selected); }); - it("prefers the personal workspace when switching out of an organization", () => { + it("prefers Personal when the stored workspace is unavailable", () => { const folder = space({ id: "folder", kind: "source", orgId: null }); const personal = space({ id: "personal", kind: "personal", orgId: null }); expect( - contentSpaceForActiveOrg({ + contentSpaceForStoredSelection({ spaces: [folder, personal], storedSpaceId: "missing", - activeOrgId: null, }), ).toBe(personal); }); @@ -439,8 +338,6 @@ describe("contentSpaceAvailability", () => { contentSpacesLoading: false, contentSpacesFetching: false, contentSpacesError: false, - activeOrganizationResolved: true, - activeOrganizationError: false, provisioningAttempted: true, provisioningPending: false, provisioningError: false, @@ -474,16 +371,6 @@ describe("contentSpaceAvailability", () => { expect(contentSpaceAvailability(settledMissingSpace)).toBe("error"); }); - it("surfaces active organization failures instead of loading forever", () => { - expect( - contentSpaceAvailability({ - ...settledMissingSpace, - activeOrganizationResolved: false, - activeOrganizationError: true, - }), - ).toBe("error"); - }); - it("renders Files as soon as the active workspace is available", () => { expect( contentSpaceAvailability({ diff --git a/templates/content/app/components/sidebar/select-content-space.ts b/templates/content/app/components/sidebar/select-content-space.ts index ab5a54d715..de0607baec 100644 --- a/templates/content/app/components/sidebar/select-content-space.ts +++ b/templates/content/app/components/sidebar/select-content-space.ts @@ -9,24 +9,17 @@ export function contentSpaceAvailability(args: { contentSpacesLoading: boolean; contentSpacesFetching: boolean; contentSpacesError: boolean; - activeOrganizationResolved: boolean; - activeOrganizationError: boolean; provisioningAttempted: boolean; provisioningPending: boolean; provisioningError: boolean; }): ContentSpaceAvailability { if (args.hasSelectedSpace) return "ready"; - if ( - args.contentSpacesError || - args.activeOrganizationError || - args.provisioningError - ) { + if (args.contentSpacesError || args.provisioningError) { return "error"; } if ( args.contentSpacesLoading || args.contentSpacesFetching || - !args.activeOrganizationResolved || !args.provisioningAttempted || args.provisioningPending ) { @@ -35,22 +28,17 @@ export function contentSpaceAvailability(args: { return "error"; } -export function contentSpaceForActiveOrg(args: { +export function contentSpaceForStoredSelection(args: { spaces: ContentSpaceSummary[]; storedSpaceId: string | null; - activeOrgId: string | null | undefined; }) { - if (args.activeOrgId === undefined) return null; const stored = args.spaces.find((space) => space.id === args.storedSpaceId); - if (stored?.orgId === args.activeOrgId) return stored; - const matching = args.spaces.filter( - (space) => space.orgId === args.activeOrgId, + if (stored) return stored; + return ( + args.spaces.find((space) => space.kind === "personal") ?? + args.spaces[0] ?? + null ); - return args.activeOrgId === null - ? (matching.find((space) => space.kind === "personal") ?? - matching[0] ?? - null) - : (matching[0] ?? null); } export function contentSpaceForCatalogItem(args: { @@ -99,15 +87,10 @@ export function contentSpaceIdForCreate(args: { export async function selectContentSpace(args: { space: ContentSpaceSummary; - activeOrgId: string | null | undefined; - switchOrg: (orgId: string | null) => Promise; syncApplicationState: (space: ContentSpaceSummary) => Promise; persistSelection: (spaceId: string) => void; openFiles: (documentId: string) => void; }) { - if (args.activeOrgId !== args.space.orgId) { - await args.switchOrg(args.space.orgId); - } await args.syncApplicationState(args.space); args.persistSelection(args.space.id); args.openFiles(args.space.filesDocumentId); diff --git a/templates/content/app/hooks/use-create-page.ts b/templates/content/app/hooks/use-create-page.ts index 9e0e85e522..49d16bddb7 100644 --- a/templates/content/app/hooks/use-create-page.ts +++ b/templates/content/app/hooks/use-create-page.ts @@ -1,4 +1,3 @@ -import { useOrg } from "@agent-native/core/client/org"; import type { Document } from "@shared/api"; import { useQueryClient } from "@tanstack/react-query"; import { useCallback } from "react"; @@ -6,7 +5,7 @@ import { useNavigate } from "react-router"; import { toast } from "sonner"; import { - contentSpaceForActiveOrg, + contentSpaceForStoredSelection, contentSpaceIdForCreate, SELECTED_CONTENT_SPACE_STORAGE_KEY, } from "@/components/sidebar/select-content-space"; @@ -36,15 +35,13 @@ export function useCreatePage(opts?: { const queryClient = useQueryClient(); const createDocument = useCreateDocument(); const contentSpacesQuery = useContentSpaces(); - const activeOrgQuery = useOrg(); const [storedSpaceId] = useLocalStorage( SELECTED_CONTENT_SPACE_STORAGE_KEY, null, ); - const selectedSpace = contentSpaceForActiveOrg({ + const selectedSpace = contentSpaceForStoredSelection({ spaces: contentSpacesQuery.data?.spaces ?? [], storedSpaceId, - activeOrgId: activeOrgQuery.data?.orgId, }); const onAfterNavigate = opts?.onAfterNavigate; const shouldNavigate = opts?.navigate ?? true; diff --git a/templates/content/app/i18n-data.ts b/templates/content/app/i18n-data.ts index 8272fcf4e7..6615ef5c36 100644 --- a/templates/content/app/i18n-data.ts +++ b/templates/content/app/i18n-data.ts @@ -3089,6 +3089,7 @@ const enUS = { localFiles: localFilesMessages, sidebar: { cannotReorderPages: "Cannot reorder pages", + addWorkspace: "Add workspace", addChild: "Add child", addChildTo: "Add child to {{title}}", addSubPage: "Add sub-page", @@ -3121,6 +3122,7 @@ const enUS = { "“{{title}}” and all its sub-pages will be permanently deleted. This cannot be undone.", deletePageQuestion: "Delete page?", localFiles: "Local files", + localFolder: "Local folder", files: "Files", loadingFiles: "Loading files…", localFilesActions: "Local files actions", diff --git a/templates/content/app/routes/_app.local-files.tsx b/templates/content/app/routes/_app.local-files.tsx index d86be22b2c..2dc3303ef6 100644 --- a/templates/content/app/routes/_app.local-files.tsx +++ b/templates/content/app/routes/_app.local-files.tsx @@ -740,6 +740,7 @@ export default function LocalFilesRoute() { const [searchParams] = useSearchParams(); const navigate = useNavigate(); const targetSpaceId = searchParams.get("spaceId") || undefined; + const targetDatabaseId = searchParams.get("databaseId") || undefined; const manifestConnectionId = searchParams.get("connectionId") || undefined; const manifestFile = searchParams.get("file") || undefined; const { data: documents = [] } = useDocuments(); @@ -915,7 +916,8 @@ export default function LocalFilesRoute() { connectionId: directory.id, label: directory.sourcePrefix || directory.name, spaceId: targetSpaceId, - createSourceBackedSpace: !targetSpaceId, + databaseId: targetDatabaseId, + createSourceBackedSpace: !targetSpaceId && !targetDatabaseId, truthPolicy: "source_primary", dryRun, } as never, diff --git a/templates/content/changelog/2026-07-18-made-content-workspaces-independently-expandable-restored-or.md b/templates/content/changelog/2026-07-18-made-content-workspaces-independently-expandable-restored-or.md new file mode 100644 index 0000000000..11f55a8b80 --- /dev/null +++ b/templates/content/changelog/2026-07-18-made-content-workspaces-independently-expandable-restored-or.md @@ -0,0 +1,6 @@ +--- +type: improved +date: 2026-07-18 +--- + +Made Content workspaces independently expandable, restored organization switching, and added local folders as workspace sources. diff --git a/templates/content/server/lib/documents.ts b/templates/content/server/lib/documents.ts index dc4d136ebe..6f881297a6 100644 --- a/templates/content/server/lib/documents.ts +++ b/templates/content/server/lib/documents.ts @@ -24,9 +24,13 @@ export function parseDocumentHideFromSearch( return parseDocumentFavorite(value); } -export function documentDiscoveryFilter(): SQL { - const userEmail = getRequestUserEmail(); - const orgId = getRequestOrgId(); +export function documentDiscoveryFilter(options?: { + userEmail?: string | null; + orgIds?: string[]; +}): SQL { + const userEmail = options?.userEmail ?? getRequestUserEmail(); + const orgIds = + options?.orgIds ?? (getRequestOrgId() ? [getRequestOrgId() as string] : []); const clauses: SQL[] = [ eq(schema.documents.hideFromSearch, 0), sql`${schema.documents.hideFromSearch} IS NULL`, @@ -41,11 +45,12 @@ export function documentDiscoveryFilter(): SQL { and ${schema.documentShares.principalId} = ${userEmail})`); } - if (orgId) { + for (const orgId of orgIds) { + clauses.push(eq(schema.documents.orgId, orgId)); clauses.push(sql`exists (select 1 from ${schema.documentShares} - where ${schema.documentShares.resourceId} = ${schema.documents.id} - and ${schema.documentShares.principalType} = 'org' - and ${schema.documentShares.principalId} = ${orgId})`); + where ${schema.documentShares.resourceId} = ${schema.documents.id} + and ${schema.documentShares.principalType} = 'org' + and ${schema.documentShares.principalId} = ${orgId})`); } return or(...clauses) ?? sql`1=0`; From dbd740eec9c67b7d026d267b7e2767f088daae01 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Sat, 18 Jul 2026 09:11:27 -0400 Subject: [PATCH 32/76] fix(content): open authorized cross-workspace pages --- .../content/actions/content-spaces.db.test.ts | 9 +++++++ templates/content/actions/get-document.ts | 26 +++++++++++++++++-- templates/content/server/db/index.ts | 1 + 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/templates/content/actions/content-spaces.db.test.ts b/templates/content/actions/content-spaces.db.test.ts index f98c406665..9423fe1189 100644 --- a/templates/content/actions/content-spaces.db.test.ts +++ b/templates/content/actions/content-spaces.db.test.ts @@ -22,6 +22,7 @@ let resolveContentSpaceAccess: typeof import("./_content-space-access.js").resol let listContentSpacesAction: typeof import("./list-content-spaces.js").default; let ensureContentSpacesAction: typeof import("./ensure-content-spaces.js").default; let listDocumentsAction: typeof import("./list-documents.js").default; +let getDocumentAction: typeof import("./get-document.js").default; let deleteContentDatabaseAction: typeof import("./delete-content-database.js").default; let deleteDocumentAction: typeof import("./delete-document.js").default; @@ -44,6 +45,7 @@ beforeAll(async () => { ensureContentSpacesAction = (await import("./ensure-content-spaces.js")) .default; listDocumentsAction = (await import("./list-documents.js")).default; + getDocumentAction = (await import("./get-document.js")).default; deleteContentDatabaseAction = (await import("./delete-content-database.js")) .default; deleteDocumentAction = (await import("./delete-document.js")).default; @@ -339,6 +341,13 @@ describe("Content space provisioning", () => { expect.objectContaining({ id: spaceId, role: "viewer" }), ]), }); + await expect( + getDocumentAction.run({ id: filesDatabase!.documentId }), + ).resolves.toMatchObject({ + id: filesDatabase!.documentId, + database: expect.objectContaining({ systemRole: "files" }), + accessRole: "viewer", + }); }); }); diff --git a/templates/content/actions/get-document.ts b/templates/content/actions/get-document.ts index 5b6980d07b..8c01285ed4 100644 --- a/templates/content/actions/get-document.ts +++ b/templates/content/actions/get-document.ts @@ -1,12 +1,15 @@ import { defineAction } from "@agent-native/core"; import { buildDeepLink } from "@agent-native/core/server"; import { resolveAccess } from "@agent-native/core/sharing"; +import { eq } from "drizzle-orm"; import { z } from "zod"; +import { getDb, schema } from "../server/db/index.js"; import { parseDocumentFavorite, parseDocumentHideFromSearch, } from "../server/lib/documents.js"; +import { resolveContentSpaceAccess } from "./_content-space-access.js"; import { getDatabaseByDocumentId, getDocumentContextPath, @@ -15,7 +18,6 @@ import { serializeDatabaseMembership, } from "./_database-utils.js"; import { serializeDocumentSource } from "./_document-source.js"; -import "../server/db/index.js"; import { listPropertiesForDocument, serializeDatabase, @@ -29,6 +31,26 @@ function canManageRole(role: string) { return role === "owner" || role === "admin"; } +async function resolveDocumentAccess(id: string) { + const current = await resolveAccess("document", id); + if (current) return current; + const [reference] = await getDb() + .select({ spaceId: schema.documents.spaceId }) + .from(schema.documents) + .where(eq(schema.documents.id, id)) + .limit(1); + if (!reference?.spaceId) return null; + try { + const spaceAccess = await resolveContentSpaceAccess(reference.spaceId); + return resolveAccess("document", id, { + userEmail: spaceAccess.authority.userEmail, + orgId: spaceAccess.authority.orgId ?? undefined, + }); + } catch { + return null; + } +} + export default defineAction({ description: "Get a single document by ID with full content.", schema: z.object({ @@ -40,7 +62,7 @@ export default defineAction({ run: async (args) => { if (!args.id) throw new Error("--id is required"); - const access = await resolveAccess("document", args.id); + const access = await resolveDocumentAccess(args.id); // Not-found is a deterministic client-state condition (deleted or // inaccessible document still referenced by an open tab) — 404, not a // 500 that floods the console as Internal Server Error. diff --git a/templates/content/server/db/index.ts b/templates/content/server/db/index.ts index 60b4ca3f7f..2b27b71b87 100644 --- a/templates/content/server/db/index.ts +++ b/templates/content/server/db/index.ts @@ -17,6 +17,7 @@ registerShareableResource({ displayName: "Document", titleColumn: "title", getResourcePath: (document) => `/page/${document.id}`, + ownerAccessIgnoresOrg: true, agentReadable: { resourceKind: DOCUMENT_AGENT_RESOURCE_KIND, getContextPath: () => DOCUMENT_AGENT_CONTEXT_ENDPOINT, From 6b30defaa29e2e3497b5c11dd25853d1c47de661 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Sat, 18 Jul 2026 09:15:53 -0400 Subject: [PATCH 33/76] fix(content): isolate favorite row actions --- .../app/components/sidebar/DocumentSidebar.layout.test.ts | 1 + .../content/app/components/sidebar/DocumentTreeItem.tsx | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts b/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts index 9c23b3cca6..b65334de68 100644 --- a/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts +++ b/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts @@ -304,6 +304,7 @@ describe("document sidebar layout", () => { expect(treeItem).toContain('paddingInlineStart: "12px"'); expect(treeItem).toContain('"border-primary bg-accent font-medium'); expect(treeItem).toContain("Remove from favorites"); + expect(treeItem).toContain('event.target.closest("button")'); expect(treeItem).toContain("onCreateChildPage()"); expect(treeItem).toContain("setDeleteDialogOpen(true)"); expect(sidebar).not.toContain("!localFileMode && favorites.length > 0"); diff --git a/templates/content/app/components/sidebar/DocumentTreeItem.tsx b/templates/content/app/components/sidebar/DocumentTreeItem.tsx index 7ab8ec0192..29893e4a67 100644 --- a/templates/content/app/components/sidebar/DocumentTreeItem.tsx +++ b/templates/content/app/components/sidebar/DocumentTreeItem.tsx @@ -133,7 +133,12 @@ export function FavoriteDocumentItem({ : `${Math.max(0, sidebarWidth)}px`, }} aria-label={title} - onClick={onSelect} + onClick={(event) => { + if (event.target instanceof Element && event.target.closest("button")) { + return; + } + onSelect(); + }} > From be389f8ed597ca4e3498fd957560ad87deb40427 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Sat, 18 Jul 2026 09:19:52 -0400 Subject: [PATCH 34/76] fix(content): keep favorite menus anchored --- .../app/components/sidebar/DocumentSidebar.layout.test.ts | 1 + templates/content/app/components/sidebar/DocumentTreeItem.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts b/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts index b65334de68..c5f906b623 100644 --- a/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts +++ b/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts @@ -305,6 +305,7 @@ describe("document sidebar layout", () => { expect(treeItem).toContain('"border-primary bg-accent font-medium'); expect(treeItem).toContain("Remove from favorites"); expect(treeItem).toContain('event.target.closest("button")'); + expect(treeItem).toContain("onClick={(event) => event.stopPropagation()}"); expect(treeItem).toContain("onCreateChildPage()"); expect(treeItem).toContain("setDeleteDialogOpen(true)"); expect(sidebar).not.toContain("!localFileMode && favorites.length > 0"); diff --git a/templates/content/app/components/sidebar/DocumentTreeItem.tsx b/templates/content/app/components/sidebar/DocumentTreeItem.tsx index 29893e4a67..87ee6aaa1d 100644 --- a/templates/content/app/components/sidebar/DocumentTreeItem.tsx +++ b/templates/content/app/components/sidebar/DocumentTreeItem.tsx @@ -150,6 +150,7 @@ export function FavoriteDocumentItem({ active && "text-accent-foreground", )} onPointerDown={(event) => event.stopPropagation()} + onClick={(event) => event.stopPropagation()} > {(canEdit || canManage) && ( From 7446cfd83542ccff82fb22d75b577eb23026b6a1 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Sat, 18 Jul 2026 09:25:19 -0400 Subject: [PATCH 35/76] fix(content): separate favorite navigation from actions --- .../sidebar/DocumentSidebar.layout.test.ts | 2 +- .../components/sidebar/DocumentTreeItem.tsx | 20 ++++++++++--------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts b/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts index c5f906b623..5bd7082015 100644 --- a/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts +++ b/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts @@ -304,7 +304,7 @@ describe("document sidebar layout", () => { expect(treeItem).toContain('paddingInlineStart: "12px"'); expect(treeItem).toContain('"border-primary bg-accent font-medium'); expect(treeItem).toContain("Remove from favorites"); - expect(treeItem).toContain('event.target.closest("button")'); + expect(treeItem).toContain("aria-label={`Open ${title}`}"); expect(treeItem).toContain("onClick={(event) => event.stopPropagation()}"); expect(treeItem).toContain("onCreateChildPage()"); expect(treeItem).toContain("setDeleteDialogOpen(true)"); diff --git a/templates/content/app/components/sidebar/DocumentTreeItem.tsx b/templates/content/app/components/sidebar/DocumentTreeItem.tsx index 87ee6aaa1d..20b4c97884 100644 --- a/templates/content/app/components/sidebar/DocumentTreeItem.tsx +++ b/templates/content/app/components/sidebar/DocumentTreeItem.tsx @@ -133,20 +133,22 @@ export function FavoriteDocumentItem({ : `${Math.max(0, sidebarWidth)}px`, }} aria-label={title} - onClick={(event) => { - if (event.target instanceof Element && event.target.closest("button")) { - return; - } - onSelect(); - }} > - + + setCreateWorkspaceDialogOpen(true)} + > + + {t("sidebar.newWorkspace")} + + @@ -1614,7 +1664,7 @@ export function DocumentSidebar({ <> {/* Favorites */} {showFavorites && ( -
+
@@ -1698,6 +1748,60 @@ export function DocumentSidebar({ onMouseDown={handleMouseDown} /> )} + { + setCreateWorkspaceDialogOpen(open); + if (!open && !createContentSpace.isPending) { + setNewWorkspaceName(""); + createWorkspaceRequestIdRef.current = null; + } + }} + > + +
{ + event.preventDefault(); + void handleCreateWorkspace(); + }} + > + + {t("sidebar.newWorkspace")} + + {t("sidebar.newWorkspaceDescription")} + + + setNewWorkspaceName(event.target.value)} + /> + + + + +
+
+
("create-content-space", { + onSuccess: async () => { + await queryClient.refetchQueries({ + queryKey: ["action", "list-content-spaces"], + }); + }, + }); +} diff --git a/templates/content/app/i18n-data.ts b/templates/content/app/i18n-data.ts index 6615ef5c36..926260dda0 100644 --- a/templates/content/app/i18n-data.ts +++ b/templates/content/app/i18n-data.ts @@ -3112,6 +3112,7 @@ const enUS = { deletePermanently: "Delete permanently", failedCreateDatabase: "Failed to create database", failedCreatePage: "Failed to create page", + failedCreateWorkspace: "Failed to create workspace", failedDeletePage: "Failed to delete page", failedPermanentDeleteDatabase: "Failed to permanently delete database", failedMovePage: "Failed to move page", @@ -3132,6 +3133,11 @@ const enUS = { new: "New", newDatabase: "New database", newPage: "New page", + newWorkspace: "New workspace", + newWorkspaceDescription: + "Create a private workspace with its own Files database.", + workspaceName: "Workspace name", + createWorkspace: "Create workspace", nextStep: "Next step", notionConfigureOAuthAuthorize: "Configure OAuth, then authorize your workspace.", diff --git a/templates/content/app/lib/local-folder-picker-support.test.ts b/templates/content/app/lib/local-folder-picker-support.test.ts new file mode 100644 index 0000000000..cc68bf9677 --- /dev/null +++ b/templates/content/app/lib/local-folder-picker-support.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; + +import { isUnsafeNativeFolderPickerHost } from "./local-folder-picker-support"; + +describe("local folder picker host safety", () => { + it("blocks Codex and Electron host markers with a Chrome-like user agent", () => { + const chromeUa = + "Mozilla/5.0 AppleWebKit/537.36 Chrome/140.0.0.0 Safari/537.36"; + expect( + isUnsafeNativeFolderPickerHost({ codexWindowType: "electron" }, chromeUa), + ).toBe(true); + expect( + isUnsafeNativeFolderPickerHost({ electronBridge: {} }, chromeUa), + ).toBe(true); + }); + + it("blocks explicit embedded-shell user agents", () => { + expect( + isUnsafeNativeFolderPickerHost({}, "Mozilla/5.0 Electron/39.0"), + ).toBe(true); + expect(isUnsafeNativeFolderPickerHost({}, "Mozilla/5.0 Codex/1.0")).toBe( + true, + ); + }); + + it("allows a normal browser host", () => { + expect( + isUnsafeNativeFolderPickerHost( + {}, + "Mozilla/5.0 AppleWebKit/537.36 Chrome/140.0.0.0 Safari/537.36", + ), + ).toBe(false); + }); +}); diff --git a/templates/content/app/lib/local-folder-picker-support.ts b/templates/content/app/lib/local-folder-picker-support.ts new file mode 100644 index 0000000000..425c36b430 --- /dev/null +++ b/templates/content/app/lib/local-folder-picker-support.ts @@ -0,0 +1,18 @@ +type FolderPickerWindow = { + codexWindowType?: unknown; + electronBridge?: unknown; +}; + +export function isUnsafeNativeFolderPickerHost( + hostWindow: FolderPickerWindow | undefined = typeof window === "undefined" + ? undefined + : (window as unknown as FolderPickerWindow), + userAgent = typeof navigator === "undefined" ? "" : navigator.userAgent, +) { + if (!hostWindow) return false; + return ( + hostWindow.codexWindowType === "electron" || + Boolean(hostWindow.electronBridge) || + /\b(?:Electron|Codex|ChatGPT)\//i.test(userAgent) + ); +} diff --git a/templates/content/app/routes/_app.local-files.tsx b/templates/content/app/routes/_app.local-files.tsx index 2dc3303ef6..abf48086c2 100644 --- a/templates/content/app/routes/_app.local-files.tsx +++ b/templates/content/app/routes/_app.local-files.tsx @@ -44,6 +44,7 @@ import { syncLocalControlResources, type LocalControlResourceFiles, } from "@/lib/local-control-resources"; +import { isUnsafeNativeFolderPickerHost } from "@/lib/local-folder-picker-support"; import { cn } from "@/lib/utils"; type PermissionState = "granted" | "denied" | "prompt"; @@ -193,7 +194,7 @@ function supportsDirectoryPicker() { typeof (window as WindowWithDirectoryPicker).showDirectoryPicker === "function" && !getDesktopContentFiles() && - !isElectronLikeBrowser() + !isUnsafeNativeFolderPickerHost() ); } @@ -202,8 +203,7 @@ function supportsLocalFolderSync() { } function isElectronLikeBrowser() { - if (typeof navigator === "undefined") return false; - return /\bElectron\//.test(navigator.userAgent); + return isUnsafeNativeFolderPickerHost(); } function unsupportedLocalFolderSyncMessage(t: ReturnType) { @@ -540,7 +540,7 @@ async function chooseDirectory( } const picker = (window as WindowWithDirectoryPicker).showDirectoryPicker; - if (!picker || isElectronLikeBrowser()) { + if (!picker || isUnsafeNativeFolderPickerHost()) { throw new Error(unsupportedLocalFolderSyncMessage(t)); } const handle = await picker({ mode: "readwrite" }); diff --git a/templates/content/changelog/2026-07-18-create-named-workspaces-each-with-its-own-private-files-data.md b/templates/content/changelog/2026-07-18-create-named-workspaces-each-with-its-own-private-files-data.md new file mode 100644 index 0000000000..3cb9c5259d --- /dev/null +++ b/templates/content/changelog/2026-07-18-create-named-workspaces-each-with-its-own-private-files-data.md @@ -0,0 +1,6 @@ +--- +type: added +date: 2026-07-18 +--- + +Create named workspaces, each with its own private Files database diff --git a/templates/content/changelog/2026-07-18-favorites-align-with-workspace-and-file-rows-in-the-sidebar.md b/templates/content/changelog/2026-07-18-favorites-align-with-workspace-and-file-rows-in-the-sidebar.md new file mode 100644 index 0000000000..bd4ed4857f --- /dev/null +++ b/templates/content/changelog/2026-07-18-favorites-align-with-workspace-and-file-rows-in-the-sidebar.md @@ -0,0 +1,6 @@ +--- +type: improved +date: 2026-07-18 +--- + +Favorites align with workspace and file rows in the sidebar diff --git a/templates/content/changelog/2026-07-18-local-folder-selection-no-longer-invokes-the-unsafe-native-p.md b/templates/content/changelog/2026-07-18-local-folder-selection-no-longer-invokes-the-unsafe-native-p.md new file mode 100644 index 0000000000..b965d704d4 --- /dev/null +++ b/templates/content/changelog/2026-07-18-local-folder-selection-no-longer-invokes-the-unsafe-native-p.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-07-18 +--- + +Local folder selection no longer invokes the unsafe native picker in embedded Codex windows From 255990edd72fd10f0087849714702e6b8c2c90f2 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Sat, 18 Jul 2026 18:03:05 -0400 Subject: [PATCH 41/76] fix(content): trust only bridged folder pickers --- .../DatabaseView.local-folder.layout.test.ts | 13 +++++++++++++ templates/content/app/routes/_app.local-files.tsx | 10 +++++++++- templates/content/e2e/local-files.spec.ts | 1 + 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/templates/content/app/components/editor/database/DatabaseView.local-folder.layout.test.ts b/templates/content/app/components/editor/database/DatabaseView.local-folder.layout.test.ts index df4739e6e8..e348a4ee3b 100644 --- a/templates/content/app/components/editor/database/DatabaseView.local-folder.layout.test.ts +++ b/templates/content/app/components/editor/database/DatabaseView.local-folder.layout.test.ts @@ -28,4 +28,17 @@ describe("Files database local-folder source entry", () => { "createSourceBackedSpace: !targetSpaceId && !targetDatabaseId", ); }); + + it("requires an explicitly trusted native directory picker", () => { + const localFilesRoute = readFileSync( + new URL("../../../routes/_app.local-files.tsx", import.meta.url), + "utf8", + ); + + expect(localFilesRoute).toContain("__agentNativeSafeDirectoryPicker"); + expect(localFilesRoute).toMatch( + /__agentNativeSafeDirectoryPicker\s*!?==?\s*true/, + ); + expect(localFilesRoute).toContain("isUnsafeNativeFolderPickerHost()"); + }); }); diff --git a/templates/content/app/routes/_app.local-files.tsx b/templates/content/app/routes/_app.local-files.tsx index abf48086c2..71b06b4770 100644 --- a/templates/content/app/routes/_app.local-files.tsx +++ b/templates/content/app/routes/_app.local-files.tsx @@ -80,6 +80,7 @@ type LocalDirectoryHandle = { isSameEntry?(other: LocalDirectoryHandle): Promise; }; type WindowWithDirectoryPicker = Window & { + __agentNativeSafeDirectoryPicker?: boolean; showDirectoryPicker?: (options?: { mode?: "read" | "readwrite"; }) => Promise; @@ -193,6 +194,8 @@ function supportsDirectoryPicker() { typeof window !== "undefined" && typeof (window as WindowWithDirectoryPicker).showDirectoryPicker === "function" && + (window as WindowWithDirectoryPicker).__agentNativeSafeDirectoryPicker === + true && !getDesktopContentFiles() && !isUnsafeNativeFolderPickerHost() ); @@ -540,7 +543,12 @@ async function chooseDirectory( } const picker = (window as WindowWithDirectoryPicker).showDirectoryPicker; - if (!picker || isUnsafeNativeFolderPickerHost()) { + if ( + !picker || + (window as WindowWithDirectoryPicker).__agentNativeSafeDirectoryPicker !== + true || + isUnsafeNativeFolderPickerHost() + ) { throw new Error(unsupportedLocalFolderSyncMessage(t)); } const handle = await picker({ mode: "readwrite" }); diff --git a/templates/content/e2e/local-files.spec.ts b/templates/content/e2e/local-files.spec.ts index 8524684073..23c7581f96 100644 --- a/templates/content/e2e/local-files.spec.ts +++ b/templates/content/e2e/local-files.spec.ts @@ -141,6 +141,7 @@ async function installDirectoryPicker(page: Page, root: string) { api.__contentLocalSourceDirectoryHandle = handle; return handle; }; + api.__agentNativeSafeDirectoryPicker = true; }, { rootPath: root, rootName: path.basename(root) }, ); From d10f0d28247510e48751a6a1de1bb2360ec58d04 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Sat, 18 Jul 2026 18:27:55 -0400 Subject: [PATCH 42/76] fix(content): make workspace catalog drive navigation --- templates/content/AGENTS.md | 2 +- templates/content/actions/_content-spaces.ts | 111 +++++++-- .../content/actions/content-spaces.db.test.ts | 53 ++++ .../content/actions/create-content-space.ts | 10 +- templates/content/actions/delete-document.ts | 7 + .../DatabaseView.local-folder.layout.test.ts | 2 +- .../editor/database/DatabaseView.tsx | 51 +++- .../editor/database/sidebar.test.tsx | 38 +++ .../components/editor/database/sidebar.tsx | 93 ++++--- .../sidebar/DocumentSidebar.layout.test.ts | 7 +- .../components/sidebar/DocumentSidebar.tsx | 228 +++++++++++------- .../content/app/hooks/use-content-spaces.ts | 20 +- ...ews-now-control-sidebar-workspace-navig.md | 6 + 13 files changed, 475 insertions(+), 153 deletions(-) create mode 100644 templates/content/changelog/2026-07-18-workspace-database-views-now-control-sidebar-workspace-navig.md diff --git a/templates/content/AGENTS.md b/templates/content/AGENTS.md index 3aba964de4..2e56637724 100644 --- a/templates/content/AGENTS.md +++ b/templates/content/AGENTS.md @@ -134,7 +134,7 @@ cd templates/content && pnpm action [args] | `ensure-content-spaces` | | Idempotently provision the signed-in user's personal and current organization Content spaces, system databases, and catalog references | | `backfill-content-files` | | Assign legacy pages to Content spaces and reconcile exactly one canonical Files database membership per non-system page | | `list-content-spaces` | | List already-provisioned Content spaces authorized by the signed-in user's current ownership or organization memberships | -| `create-content-space` | `--name [--requestId ]` | Create a private named Content workspace with its own canonical Files database | +| `create-content-space` | `--name --requestId ` | Idempotently create a private named Content workspace with its own canonical Files database | | `create-content-database` | `[--documentId ] [--spaceId ] [--parentId ] [--title ] [--description ]` | Create a described database page in a Content space or convert an existing page into a database | | `create-inline-content-database` | `--hostDocumentId [--title ] [--description ]` | Create a described database owned by an inline database block in the host document | | `get-content-database` | `--databaseId ` or `--documentId ` | Get a database with its description, property/option schema guidance, item pages, and computed ancestry context | diff --git a/templates/content/actions/_content-spaces.ts b/templates/content/actions/_content-spaces.ts index ff8503c014..94c280ddc4 100644 --- a/templates/content/actions/_content-spaces.ts +++ b/templates/content/actions/_content-spaces.ts @@ -1,14 +1,19 @@ import { createHash } from "node:crypto"; -import { and, eq, sql } from "drizzle-orm"; +import { and, eq, inArray, sql } from "drizzle-orm"; import { schema } from "../server/db/index.js"; +import { + isComputedPropertyType, + type DocumentPropertyType, +} from "../shared/properties.js"; import { listContentOrganizationMemberships, normalizeContentSpaceEmail, } from "./_content-space-access.js"; import { defaultDatabaseViewConfig, + normalizedValueJson, seedDefaultBlocksField, serializeDatabaseViewConfig, } from "./_property-utils.js"; @@ -466,7 +471,12 @@ export async function provisionContentSpaces( async function provisionOwnedContentSpace( db: Db, userEmail: string, - input: { spaceId: string; name: string; kind: "user" | "source_backed" }, + input: { + spaceId: string; + name: string; + kind: "user" | "source_backed"; + propertyValues?: Record; + }, ) { const email = normalizeContentSpaceEmail(userEmail); const name = input.name.trim(); @@ -484,7 +494,7 @@ async function provisionOwnedContentSpace( catalogItems: 0, }; - const files = await db.transaction(async (tx: Db) => { + const provisioned = await db.transaction(async (tx: Db) => { const sourceFiles = await ensureSystemDatabase({ db: tx, spaceId, @@ -500,6 +510,9 @@ async function provisionOwnedContentSpace( .select() .from(schema.contentSpaces) .where(eq(schema.contentSpaces.id, spaceId)); + if (existingSpace && input.kind === "user" && existingSpace.name !== name) { + throw new Error("Workspace request ID is already bound to another name"); + } if (!existingSpace) { await tx .insert(schema.contentSpaces) @@ -515,13 +528,10 @@ async function provisionOwnedContentSpace( updatedAt: now, }) .onConflictDoNothing(); - } else if ( - existingSpace.name !== name || - existingSpace.filesDatabaseId !== sourceFiles.id - ) { + } else if (existingSpace.filesDatabaseId !== sourceFiles.id) { await tx .update(schema.contentSpaces) - .set({ name, filesDatabaseId: sourceFiles.id, updatedAt: now }) + .set({ filesDatabaseId: sourceFiles.id, updatedAt: now }) .where(eq(schema.contentSpaces.id, spaceId)); } @@ -549,10 +559,12 @@ async function provisionOwnedContentSpace( }, created, ); - await tx - .update(schema.documents) - .set({ title: name, updatedAt: now }) - .where(eq(schema.documents.id, referenceDocumentId)); + if (!existingSpace || input.kind !== "user") { + await tx + .update(schema.documents) + .set({ title: name, updatedAt: now }) + .where(eq(schema.documents.id, referenceDocumentId)); + } const [maxCatalogPosition] = await tx .select({ max: sql`COALESCE(MAX(position), -1)` }) @@ -594,17 +606,75 @@ async function provisionOwnedContentSpace( }) .onConflictDoNothing(); } - return sourceFiles; + const initialPropertyValues = Object.entries(input.propertyValues ?? {}); + if (initialPropertyValues.length > 0) { + const definitions = (await tx + .select() + .from(schema.documentPropertyDefinitions) + .where( + and( + eq( + schema.documentPropertyDefinitions.databaseId, + catalogIds.databaseId, + ), + inArray( + schema.documentPropertyDefinitions.id, + initialPropertyValues.map(([propertyId]) => propertyId), + ), + ), + )) as Array; + const definitionById = new Map( + definitions.map((definition) => [definition.id, definition]), + ); + for (const [propertyId, value] of initialPropertyValues) { + const definition = definitionById.get(propertyId); + const type = definition?.type as DocumentPropertyType | undefined; + if (!type || isComputedPropertyType(type)) continue; + await tx + .insert(schema.documentPropertyValues) + .values({ + id: opaqueId( + "content_workspace_property", + `${email}:${spaceId}:${propertyId}`, + ), + ownerEmail: email, + documentId: referenceDocumentId, + propertyId, + valueJson: normalizedValueJson(type, value), + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: schema.documentPropertyValues.id, + set: { + valueJson: normalizedValueJson(type, value), + updatedAt: now, + }, + }); + } + } + return { + files: sourceFiles, + catalogDatabaseId: catalogIds.databaseId, + catalogItemId, + catalogDocumentId: referenceDocumentId, + }; }); await seedDefaultBlocksField({ - databaseId: files.id, - ownerEmail: files.ownerEmail, - orgId: files.orgId, + databaseId: provisioned.files.id, + ownerEmail: provisioned.files.ownerEmail, + orgId: provisioned.files.orgId, now, db, }); - return { spaceId, filesDatabaseId: files.id }; + return { + spaceId, + filesDatabaseId: provisioned.files.id, + catalogDatabaseId: provisioned.catalogDatabaseId, + catalogItemId: provisioned.catalogItemId, + catalogDocumentId: provisioned.catalogDocumentId, + }; } export async function provisionSourceBackedContentSpace( @@ -624,7 +694,11 @@ export async function provisionSourceBackedContentSpace( export async function provisionUserContentSpace( db: Db, userEmail: string, - input: { workspaceId: string; name: string }, + input: { + workspaceId: string; + name: string; + propertyValues?: Record; + }, ) { const workspaceId = input.workspaceId.trim(); if (!workspaceId) throw new Error("Workspace ID is required"); @@ -632,5 +706,6 @@ export async function provisionUserContentSpace( spaceId: userContentSpaceId(userEmail, workspaceId), name: input.name, kind: "user", + propertyValues: input.propertyValues, }); } diff --git a/templates/content/actions/content-spaces.db.test.ts b/templates/content/actions/content-spaces.db.test.ts index 89a737bf8b..4a8f6031be 100644 --- a/templates/content/actions/content-spaces.db.test.ts +++ b/templates/content/actions/content-spaces.db.test.ts @@ -89,18 +89,66 @@ async function addMember( describe("Content space provisioning", () => { it("creates an idempotent private named workspace with canonical Files", async () => { + const provisioned = await runWithRequestContext( + { userEmail: WORKSPACE_OWNER }, + () => provisionContentSpaces(getDb(), WORKSPACE_OWNER), + ); + const now = new Date().toISOString(); + await getDb().insert(schema.documentPropertyDefinitions).values({ + id: "workspace-focus-property", + ownerEmail: WORKSPACE_OWNER, + orgId: null, + databaseId: provisioned.catalogDatabaseId, + name: "Focus", + type: "text", + position: 0, + createdAt: now, + updatedAt: now, + }); const create = () => runWithRequestContext({ userEmail: WORKSPACE_OWNER }, () => createContentSpaceAction.run({ name: "Writing", requestId: "named-workspace-writing", + propertyValues: { "workspace-focus-property": "Editorial" }, }), ); const first = await create(); const second = await create(); expect(second).toEqual(first); + await expect( + runWithRequestContext({ userEmail: WORKSPACE_OWNER }, () => + createContentSpaceAction.run({ + name: "Different name", + requestId: "named-workspace-writing", + }), + ), + ).rejects.toThrow("Workspace request ID is already bound to another name"); expect(first.spaceId).toMatch(/^content_space_user_[a-f0-9]{32}$/); expect(first.spaceId).not.toContain(WORKSPACE_OWNER); + const [focusValue] = await getDb() + .select() + .from(schema.documentPropertyValues) + .where( + and( + eq(schema.documentPropertyValues.documentId, first.catalogDocumentId), + eq( + schema.documentPropertyValues.propertyId, + "workspace-focus-property", + ), + ), + ); + expect(JSON.parse(focusValue.valueJson)).toBe("Editorial"); + await getDb() + .update(schema.documents) + .set({ title: "Writing alias" }) + .where(eq(schema.documents.id, first.catalogDocumentId)); + await create(); + const [aliasedReference] = await getDb() + .select({ title: schema.documents.title }) + .from(schema.documents) + .where(eq(schema.documents.id, first.catalogDocumentId)); + expect(aliasedReference.title).toBe("Writing alias"); const [space] = await getDb() .select() @@ -150,6 +198,11 @@ describe("Content space provisioning", () => { expect(outsiderSpaces.spaces).not.toEqual( expect.arrayContaining([expect.objectContaining({ id: first.spaceId })]), ); + await runWithRequestContext({ userEmail: WORKSPACE_OWNER }, async () => { + await expect( + deleteDocumentAction.run({ id: first.catalogDocumentId }), + ).rejects.toThrow("Workspace references cannot be deleted as pages"); + }); }); it("is idempotent, opaque, and creates exactly one Files and Workspaces database", async () => { diff --git a/templates/content/actions/create-content-space.ts b/templates/content/actions/create-content-space.ts index 3c2878001b..7c8f4c37b8 100644 --- a/templates/content/actions/create-content-space.ts +++ b/templates/content/actions/create-content-space.ts @@ -1,5 +1,3 @@ -import { randomUUID } from "node:crypto"; - import { defineAction } from "@agent-native/core"; import { getRequestUserEmail } from "@agent-native/core/server/request-context"; import { z } from "zod"; @@ -15,14 +13,16 @@ export default defineAction({ "Create a private Content workspace with its own canonical Files database.", schema: z.object({ name: z.string().trim().min(1).max(200), - requestId: z.string().trim().min(1).max(200).optional(), + requestId: z.string().trim().min(1).max(200), + propertyValues: z.record(z.string(), z.unknown()).optional(), }), - run: async ({ name, requestId }) => { + run: async ({ name, requestId, propertyValues }) => { const userEmail = getRequestUserEmail(); if (!userEmail) throw new Error("no authenticated user"); const result = await provisionUserContentSpace(getDb(), userEmail, { - workspaceId: requestId ?? randomUUID(), + workspaceId: requestId, name, + propertyValues, }); return { ...result, diff --git a/templates/content/actions/delete-document.ts b/templates/content/actions/delete-document.ts index ea42fc1fcb..2b5d66e353 100644 --- a/templates/content/actions/delete-document.ts +++ b/templates/content/actions/delete-document.ts @@ -371,6 +371,13 @@ export default defineAction({ if (systemDatabase?.systemRole) { throw new Error("System Content database documents cannot be deleted"); } + const [workspaceReference] = await db + .select({ id: schema.contentSpaceCatalogItems.id }) + .from(schema.contentSpaceCatalogItems) + .where(eq(schema.contentSpaceCatalogItems.documentId, id)); + if (workspaceReference) { + throw new Error("Workspace references cannot be deleted as pages"); + } const deleted = await deleteDocumentRecursive( db, id, diff --git a/templates/content/app/components/editor/database/DatabaseView.local-folder.layout.test.ts b/templates/content/app/components/editor/database/DatabaseView.local-folder.layout.test.ts index e348a4ee3b..b33f2c9eca 100644 --- a/templates/content/app/components/editor/database/DatabaseView.local-folder.layout.test.ts +++ b/templates/content/app/components/editor/database/DatabaseView.local-folder.layout.test.ts @@ -18,7 +18,7 @@ describe("Files database local-folder source entry", () => { ); expect(databaseView).toContain('label="Local folder"'); expect(databaseView).toContain( - "navigate(`/local-files?databaseId=${documentId}`)", + "navigate(`/local-files?databaseId=${databaseId}`)", ); expect(localFilesRoute).toContain( 'const targetDatabaseId = searchParams.get("databaseId") || undefined', diff --git a/templates/content/app/components/editor/database/DatabaseView.tsx b/templates/content/app/components/editor/database/DatabaseView.tsx index ca1fa86fd6..2ea297efaa 100644 --- a/templates/content/app/components/editor/database/DatabaseView.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.tsx @@ -183,7 +183,10 @@ import { useUpdateContentDatabasePersonalView, useUpdateContentDatabaseView, } from "@/hooks/use-content-database"; -import { useContentSpaces } from "@/hooks/use-content-spaces"; +import { + useContentSpaces, + useCreateContentSpace, +} from "@/hooks/use-content-spaces"; import { useConfigureDocumentProperty, useSetDocumentProperty, @@ -687,6 +690,8 @@ function DatabaseTable({ ); const database = useContentDatabase(document.id, databaseItemLimit); const addItem = useAddDatabaseItem(document.id); + const createContentSpace = useCreateContentSpace(); + const workspaceCreateRequestIdRef = useRef(null); const attachSource = useAttachContentDatabaseSource(document.id); const changeSourceRole = useChangeContentDatabaseSourceRole(document.id); const refreshSource = useRefreshContentDatabaseSource(document.id); @@ -1364,6 +1369,30 @@ function DatabaseTable({ } = {}, ) { if (!databaseId) return null; + if (data?.database.systemRole === "workspaces") { + const name = title.trim() || t("sidebar.newWorkspace"); + const requestId = + workspaceCreateRequestIdRef.current ?? crypto.randomUUID(); + workspaceCreateRequestIdRef.current = requestId; + try { + const created = await createContentSpace.mutateAsync({ + name, + requestId, + propertyValues: + Object.keys(propertyValueOverrides).length > 0 + ? propertyValueOverrides + : undefined, + }); + workspaceCreateRequestIdRef.current = null; + navigate(`/page/${created.filesDocumentId}`); + } catch (err) { + toast.error(dbText("failedToCreateRow"), { + description: + err instanceof Error ? err.message : dbText("somethingWentWrong"), + }); + } + return null; + } const propertyValues = { ...databasePropertyValuesForNewItem(filters, properties, filterMode), ...propertyValueOverrides, @@ -2358,10 +2387,18 @@ function DatabaseTable({ type="button" size="sm" className="h-7 rounded-md bg-foreground px-2.5 text-xs font-medium text-background hover:bg-foreground/90" - disabled={addItem.isPending || !databaseId} + disabled={ + (data?.database.systemRole === "workspaces" + ? createContentSpace.isPending + : addItem.isPending) || !databaseId + } onClick={() => void createRow()} > - {addItem.isPending ? ( + {( + data?.database.systemRole === "workspaces" + ? createContentSpace.isPending + : addItem.isPending + ) ? ( ) : null} New @@ -2726,6 +2763,7 @@ function DatabaseTable({ onNavPush({ kind: "addSource" })} onOpenLocalFolder={() => - navigate(`/local-files?databaseId=${documentId}`) + navigate(`/local-files?databaseId=${databaseId}`) } showLocalFolder={isFilesDatabase} onOpenSecondary={(secondary) => diff --git a/templates/content/app/components/editor/database/sidebar.test.tsx b/templates/content/app/components/editor/database/sidebar.test.tsx index 1175f6870d..f6db410a6d 100644 --- a/templates/content/app/components/editor/database/sidebar.test.tsx +++ b/templates/content/app/components/editor/database/sidebar.test.tsx @@ -81,6 +81,44 @@ describe("DatabaseSidebarView", () => { expect(markup).toContain("font-semibold"); }); + it("lets a saved database view render workspace roots inside its groups", () => { + const groups = [ + { + id: "team", + label: "Team", + items: [item("workspace", "Builder.io")], + property: null, + value: "team", + }, + ] as DatabaseBoardGroup[]; + const markup = renderToStaticMarkup( + + {}} + onPreview={() => {}} + renderItem={(workspace) => ( + + )} + /> + , + ); + + expect(markup).toContain("Team"); + expect(markup).toContain("Builder.io files"); + expect(markup).not.toContain('href="/page/workspace"'); + }); + it("lets the Files sidebar intercept a workspace reference row", async () => { const onOpenItem = vi.fn(() => true); const container = document.createElement("div"); diff --git a/templates/content/app/components/editor/database/sidebar.tsx b/templates/content/app/components/editor/database/sidebar.tsx index 3f8780a714..7d8b303a3f 100644 --- a/templates/content/app/components/editor/database/sidebar.tsx +++ b/templates/content/app/components/editor/database/sidebar.tsx @@ -24,7 +24,7 @@ import { IconStar, IconTrash, } from "@tabler/icons-react"; -import { useEffect, useState, type MouseEvent } from "react"; +import { useEffect, useState, type MouseEvent, type ReactNode } from "react"; import { Link } from "react-router"; import { @@ -104,6 +104,8 @@ export function ContentFilesSidebarView({ onCreateChildDatabase, onDeleteItem, onToggleFavorite, + renderItem, + scroll = true, }: { data: ContentDatabaseResponse | undefined; overrides: ContentDatabasePersonalViewOverrides | null | undefined; @@ -115,6 +117,8 @@ export function ContentFilesSidebarView({ onCreateChildDatabase?: (item: ContentDatabaseItem) => void; onDeleteItem?: (item: ContentDatabaseItem) => void; onToggleFavorite?: (item: ContentDatabaseItem) => void; + renderItem?: (item: ContentDatabaseItem) => ReactNode; + scroll?: boolean; labels: Omit< Parameters[0], | "groups" @@ -124,6 +128,8 @@ export function ContentFilesSidebarView({ | "openPagesIn" | "onClearResultConstraints" | "onPreview" + | "renderItem" + | "scroll" >; }) { const viewConfig = applyPersonalSidebarViewOverrides( @@ -197,6 +203,8 @@ export function ContentFilesSidebarView({ onCreateChildDatabase={onCreateChildDatabase} onDeleteItem={onDeleteItem} onToggleFavorite={onToggleFavorite} + renderItem={renderItem} + scroll={scroll} />
); @@ -216,6 +224,8 @@ export function DatabaseSidebarView({ onCreateChildDatabase, onDeleteItem, onToggleFavorite, + renderItem, + scroll = true, loadingLabel, noMatchesLabel, clearLabel, @@ -235,6 +245,8 @@ export function DatabaseSidebarView({ onCreateChildDatabase?: (item: ContentDatabaseItem) => void; onDeleteItem?: (item: ContentDatabaseItem) => void; onToggleFavorite?: (item: ContentDatabaseItem) => void; + renderItem?: (item: ContentDatabaseItem) => ReactNode; + scroll?: boolean; loadingLabel: string; noMatchesLabel: string; clearLabel: string; @@ -280,33 +292,33 @@ export function DatabaseSidebarView({ ); } - return ( - - + ); + return scroll ? ( + {navigation} + ) : ( + navigation ); } diff --git a/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts b/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts index cc09e93f7b..6dc01b7577 100644 --- a/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts +++ b/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts @@ -212,11 +212,16 @@ describe("document sidebar layout", () => { expect(sidebar).not.toContain( '
', ); - expect(sidebar).toContain(""); + expect(sidebar).not.toContain(""); expect(sidebar).toContain('t("sidebar.addWorkspace")'); expect(sidebar).toContain('t("sidebar.newWorkspace")'); expect(sidebar).toContain("useCreateContentSpace"); expect(sidebar).toContain("handleCreateWorkspace"); + expect(sidebar).toContain("workspaceCatalogDatabaseId"); + expect(sidebar).toContain("workspaceCatalogPersonalView.data?.overrides"); + expect(sidebar).toContain("renderItem={(item) =>"); + expect(sidebar).toContain("name: item.document.title || space.name"); + expect(sidebar).toContain("scroll={false}"); expect(sidebar).toContain(''); }); diff --git a/templates/content/app/components/sidebar/DocumentSidebar.tsx b/templates/content/app/components/sidebar/DocumentSidebar.tsx index 29c0b99850..d28a217afc 100644 --- a/templates/content/app/components/sidebar/DocumentSidebar.tsx +++ b/templates/content/app/components/sidebar/DocumentSidebar.tsx @@ -11,7 +11,6 @@ import { ExtensionSlot, ExtensionsSidebarSection, } from "@agent-native/core/client/extensions"; -import { OrgSwitcher } from "@agent-native/core/client/org"; import { closestCenter, DndContext, @@ -350,6 +349,16 @@ export function DocumentSidebar({ const ensureContentSpaces = useEnsureContentSpaces(); const workspaceSelectionQueueRef = useRef(createContentSpaceSelectionQueue()); const contentSpaces = contentSpacesQuery.data?.spaces ?? []; + const workspaceCatalogDatabaseId = + contentSpacesQuery.data?.catalogDatabaseId ?? null; + const workspaceCatalogDatabase = useContentDatabaseById( + workspaceCatalogDatabaseId, + ); + const workspaceCatalogPersonalView = useContentDatabasePersonalView( + workspaceCatalogDatabaseId, + ); + const updateWorkspaceCatalogPersonalView = + useUpdateContentDatabasePersonalView(workspaceCatalogDatabaseId); const spaceProvisionAttemptedRef = useRef(false); useEffect(() => { if ( @@ -464,10 +473,11 @@ export function DocumentSidebar({ filesDocumentId: created.filesDocumentId, orgId: null, role: "owner", - catalogItemId: "", - catalogDocumentId: "", + catalogItemId: created.catalogItemId, + catalogDocumentId: created.catalogDocumentId, }; - await handleSelectContentSpace(space); + const selected = await handleSelectContentSpace(space); + if (!selected) return; setCreateWorkspaceDialogOpen(false); setNewWorkspaceName(""); createWorkspaceRequestIdRef.current = null; @@ -1266,88 +1276,141 @@ export function DocumentSidebar({ ); }; + const renderWorkspaceRoot = (space: ContentSpaceSummary) => { + const selected = selectedSpace?.id === space.id; + const expanded = expandedWorkspaceIds.includes(space.id); + return ( +
+
+ + + +
+ {expanded && ( + + void handleSelectContentSpace(nextSpace, documentId) + } + onCreateChildPage={(nextSpace, item) => + void handleCreatePage( + item.document.id, + nextSpace.id, + undefined, + nextSpace.filesDatabaseId, + ) + } + onCreateChildDatabase={(nextSpace, item) => + void handleCreateDatabase(item.document.id, nextSpace.id) + } + onDeleteItem={(item) => void handleDelete(item.document.id)} + onToggleFavorite={(item) => + handleToggleFavorite(item.document.id, !item.document.isFavorite) + } + /> + )} +
+ ); + }; + const renderWorkspaceNavigation = () => (
{contentSpaceState === "ready" && selectedSpace ? (
- {contentSpaces.map((space) => { - const selected = selectedSpace.id === space.id; - const expanded = expandedWorkspaceIds.includes(space.id); - return ( -
-
- - - -
- {expanded && ( - - void handleSelectContentSpace(nextSpace, documentId) - } - onCreateChildPage={(nextSpace, item) => - void handleCreatePage( - item.document.id, - nextSpace.id, - undefined, - nextSpace.filesDatabaseId, - ) - } - onCreateChildDatabase={(nextSpace, item) => - void handleCreateDatabase(item.document.id, nextSpace.id) - } - onDeleteItem={(item) => void handleDelete(item.document.id)} - onToggleFavorite={(item) => - handleToggleFavorite( - item.document.id, - !item.document.isFavorite, - ) - } - /> - )} -
- ); - })} + {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 ?? 1, + activeViewId: viewId, + views: current?.views ?? [], + }, + }); + }} + renderItem={(item) => { + const space = contentSpaces.find( + (candidate) => + candidate.catalogDocumentId === item.document.id, + ); + return space + ? renderWorkspaceRoot({ + ...space, + name: item.document.title || space.name, + }) + : null; + }} + scroll={false} + labels={{ + loadingLabel: t("sidebar.loadingFiles"), + noMatchesLabel: t("database.noRowsMatchThisView"), + clearLabel: t("database.clearSearchAndFilters"), + navigationLabel: "Content navigation", + untitledLabel: t("sidebar.untitled"), + }} + /> + )}
-
{/* Resize handle */} diff --git a/templates/content/app/hooks/use-content-spaces.ts b/templates/content/app/hooks/use-content-spaces.ts index d7de77e577..49c37d291d 100644 --- a/templates/content/app/hooks/use-content-spaces.ts +++ b/templates/content/app/hooks/use-content-spaces.ts @@ -46,15 +46,27 @@ export function useCreateContentSpace() { spaceId: string; filesDatabaseId: string; filesDocumentId: string; + catalogDatabaseId: string; + catalogItemId: string; + catalogDocumentId: string; name: string; kind: "user"; }, - { name: string; requestId?: string } + { + name: string; + requestId: string; + propertyValues?: Record; + } >("create-content-space", { onSuccess: async () => { - await queryClient.refetchQueries({ - queryKey: ["action", "list-content-spaces"], - }); + await Promise.all([ + queryClient.refetchQueries({ + queryKey: ["action", "list-content-spaces"], + }), + queryClient.refetchQueries({ + queryKey: ["action", "get-content-database"], + }), + ]); }, }); } diff --git a/templates/content/changelog/2026-07-18-workspace-database-views-now-control-sidebar-workspace-navig.md b/templates/content/changelog/2026-07-18-workspace-database-views-now-control-sidebar-workspace-navig.md new file mode 100644 index 0000000000..78c5cf64f1 --- /dev/null +++ b/templates/content/changelog/2026-07-18-workspace-database-views-now-control-sidebar-workspace-navig.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-07-18 +--- + +Workspace database views now control sidebar workspace navigation, and local folders attach to the correct Files database. From 152209036fb3d01e6b1c9258dc728b6836f987be Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Sat, 18 Jul 2026 18:39:57 -0400 Subject: [PATCH 43/76] fix(content): protect workspace catalog lifecycle --- .../actions/_content-space-catalog-guards.ts | 20 ++ templates/content/actions/_content-spaces.ts | 314 ++++++++++-------- .../content/actions/content-spaces.db.test.ts | 64 ++++ .../content/actions/delete-database-items.ts | 2 + templates/content/actions/delete-document.ts | 9 +- .../actions/duplicate-database-item.ts | 6 + .../actions/duplicate-database-items.ts | 6 + .../editor/database/DatabaseView.tsx | 28 +- .../DatabaseView.workspaces.layout.test.ts | 26 ++ .../editor/database/sidebar.test.tsx | 59 +++- .../components/editor/database/sidebar.tsx | 13 +- 11 files changed, 377 insertions(+), 170 deletions(-) create mode 100644 templates/content/actions/_content-space-catalog-guards.ts create mode 100644 templates/content/app/components/editor/database/DatabaseView.workspaces.layout.test.ts diff --git a/templates/content/actions/_content-space-catalog-guards.ts b/templates/content/actions/_content-space-catalog-guards.ts new file mode 100644 index 0000000000..9d6fd65bfc --- /dev/null +++ b/templates/content/actions/_content-space-catalog-guards.ts @@ -0,0 +1,20 @@ +import { inArray } from "drizzle-orm"; + +import { schema } from "../server/db/index.js"; + +type Db = any; + +export async function assertNotWorkspaceCatalogDocuments( + db: Db, + documentIds: string[], + operation: string, +) { + if (documentIds.length === 0) return; + const [workspaceReference] = await db + .select({ id: schema.contentSpaceCatalogItems.id }) + .from(schema.contentSpaceCatalogItems) + .where(inArray(schema.contentSpaceCatalogItems.documentId, documentIds)); + if (workspaceReference) { + throw new Error(`Workspace references cannot be ${operation} as pages`); + } +} diff --git a/templates/content/actions/_content-spaces.ts b/templates/content/actions/_content-spaces.ts index 94c280ddc4..e712ab42aa 100644 --- a/templates/content/actions/_content-spaces.ts +++ b/templates/content/actions/_content-spaces.ts @@ -11,6 +11,7 @@ import { listContentOrganizationMemberships, normalizeContentSpaceEmail, } from "./_content-space-access.js"; +import { withPositionLock } from "./_position-utils.js"; import { defaultDatabaseViewConfig, normalizedValueJson, @@ -494,172 +495,201 @@ async function provisionOwnedContentSpace( catalogItems: 0, }; - const provisioned = await db.transaction(async (tx: Db) => { - const sourceFiles = await ensureSystemDatabase({ - db: tx, - spaceId, - ownerEmail: email, - orgId: null, - title: "Files", - role: "files", - visibility: "private", - now, - created, - }); - const [existingSpace] = await tx - .select() - .from(schema.contentSpaces) - .where(eq(schema.contentSpaces.id, spaceId)); - if (existingSpace && input.kind === "user" && existingSpace.name !== name) { - throw new Error("Workspace request ID is already bound to another name"); - } - if (!existingSpace) { - await tx - .insert(schema.contentSpaces) - .values({ - id: spaceId, - name, - kind: input.kind, - ownerEmail: email, - orgId: null, - filesDatabaseId: sourceFiles.id, - createdBy: email, - createdAt: now, - updatedAt: now, - }) - .onConflictDoNothing(); - } else if (existingSpace.filesDatabaseId !== sourceFiles.id) { - await tx - .update(schema.contentSpaces) - .set({ filesDatabaseId: sourceFiles.id, updatedAt: now }) - .where(eq(schema.contentSpaces.id, spaceId)); - } - - const referenceDocumentId = opaqueId( - "content_workspace_reference", - `${email}:${spaceId}`, - ); - await ensureDocument( - tx, - { - id: referenceDocumentId, - spaceId: personalSpaceId, + const provisioned = await withPositionLock<{ + files: typeof schema.contentDatabases.$inferSelect; + catalogDatabaseId: string; + catalogItemId: string; + catalogDocumentId: string; + }>(`contentSpace:${spaceId}`, () => + db.transaction(async (tx: Db) => { + const sourceFiles = await ensureSystemDatabase({ + db: tx, + spaceId, ownerEmail: email, orgId: null, - parentId: catalogIds.documentId, - title: name, - content: "", - description: "", - position: 0, - isFavorite: 0, - hideFromSearch: 0, + title: "Files", + role: "files", visibility: "private", - createdAt: now, - updatedAt: now, - }, - created, - ); - if (!existingSpace || input.kind !== "user") { - await tx - .update(schema.documents) - .set({ title: name, updatedAt: now }) - .where(eq(schema.documents.id, referenceDocumentId)); - } + now, + created, + }); + const [existingSpace] = await tx + .select() + .from(schema.contentSpaces) + .where(eq(schema.contentSpaces.id, spaceId)); + if ( + existingSpace && + input.kind === "user" && + existingSpace.name !== name + ) { + throw new Error( + "Workspace request ID is already bound to another name", + ); + } + let createdSpace = false; + if (!existingSpace) { + const inserted = await tx + .insert(schema.contentSpaces) + .values({ + id: spaceId, + name, + kind: input.kind, + ownerEmail: email, + orgId: null, + filesDatabaseId: sourceFiles.id, + createdBy: email, + createdAt: now, + updatedAt: now, + }) + .onConflictDoNothing() + .returning({ id: schema.contentSpaces.id }); + createdSpace = inserted.length > 0; + } else if (existingSpace.filesDatabaseId !== sourceFiles.id) { + await tx + .update(schema.contentSpaces) + .set({ filesDatabaseId: sourceFiles.id, updatedAt: now }) + .where(eq(schema.contentSpaces.id, spaceId)); + } + const [canonicalSpace] = await tx + .select() + .from(schema.contentSpaces) + .where(eq(schema.contentSpaces.id, spaceId)); + if (!canonicalSpace) + throw new Error("Unable to create Content workspace"); + if (input.kind === "user" && canonicalSpace.name !== name) { + throw new Error( + "Workspace request ID is already bound to another name", + ); + } - const [maxCatalogPosition] = await tx - .select({ max: sql`COALESCE(MAX(position), -1)` }) - .from(schema.contentDatabaseItems) - .where(eq(schema.contentDatabaseItems.databaseId, catalogIds.databaseId)); - const catalogItemId = await ensureDatabaseItem({ - db: tx, - databaseId: catalogIds.databaseId, - documentId: referenceDocumentId, - ownerEmail: email, - orgId: null, - position: (maxCatalogPosition?.max ?? -1) + 1, - now, - }); - const [mapping] = await tx - .select({ id: schema.contentSpaceCatalogItems.id }) - .from(schema.contentSpaceCatalogItems) - .where( - and( - eq( - schema.contentSpaceCatalogItems.catalogDatabaseId, - catalogIds.databaseId, - ), - eq(schema.contentSpaceCatalogItems.spaceId, spaceId), - ), + const referenceDocumentId = opaqueId( + "content_workspace_reference", + `${email}:${spaceId}`, ); - if (!mapping) { - await tx - .insert(schema.contentSpaceCatalogItems) - .values({ - id: opaqueId("content_space_catalog", `${email}:${spaceId}`), + await ensureDocument( + tx, + { + id: referenceDocumentId, + spaceId: personalSpaceId, ownerEmail: email, - catalogDatabaseId: catalogIds.databaseId, - databaseItemId: catalogItemId, - documentId: referenceDocumentId, - spaceId, + orgId: null, + parentId: catalogIds.documentId, + title: name, + content: "", + description: "", + position: 0, + isFavorite: 0, + hideFromSearch: 0, + visibility: "private", createdAt: now, updatedAt: now, - }) - .onConflictDoNothing(); - } - const initialPropertyValues = Object.entries(input.propertyValues ?? {}); - if (initialPropertyValues.length > 0) { - const definitions = (await tx - .select() - .from(schema.documentPropertyDefinitions) + }, + created, + ); + if (createdSpace || input.kind !== "user") { + await tx + .update(schema.documents) + .set({ title: name, updatedAt: now }) + .where(eq(schema.documents.id, referenceDocumentId)); + } + + const [maxCatalogPosition] = await tx + .select({ max: sql`COALESCE(MAX(position), -1)` }) + .from(schema.contentDatabaseItems) + .where( + eq(schema.contentDatabaseItems.databaseId, catalogIds.databaseId), + ); + const catalogItemId = await ensureDatabaseItem({ + db: tx, + databaseId: catalogIds.databaseId, + documentId: referenceDocumentId, + ownerEmail: email, + orgId: null, + position: (maxCatalogPosition?.max ?? -1) + 1, + now, + }); + const [mapping] = await tx + .select({ id: schema.contentSpaceCatalogItems.id }) + .from(schema.contentSpaceCatalogItems) .where( and( eq( - schema.documentPropertyDefinitions.databaseId, + schema.contentSpaceCatalogItems.catalogDatabaseId, catalogIds.databaseId, ), - inArray( - schema.documentPropertyDefinitions.id, - initialPropertyValues.map(([propertyId]) => propertyId), - ), + eq(schema.contentSpaceCatalogItems.spaceId, spaceId), ), - )) as Array; - const definitionById = new Map( - definitions.map((definition) => [definition.id, definition]), - ); - for (const [propertyId, value] of initialPropertyValues) { - const definition = definitionById.get(propertyId); - const type = definition?.type as DocumentPropertyType | undefined; - if (!type || isComputedPropertyType(type)) continue; + ); + if (!mapping) { await tx - .insert(schema.documentPropertyValues) + .insert(schema.contentSpaceCatalogItems) .values({ - id: opaqueId( - "content_workspace_property", - `${email}:${spaceId}:${propertyId}`, - ), + id: opaqueId("content_space_catalog", `${email}:${spaceId}`), ownerEmail: email, + catalogDatabaseId: catalogIds.databaseId, + databaseItemId: catalogItemId, documentId: referenceDocumentId, - propertyId, - valueJson: normalizedValueJson(type, value), + spaceId, createdAt: now, updatedAt: now, }) - .onConflictDoUpdate({ - target: schema.documentPropertyValues.id, - set: { + .onConflictDoNothing(); + } + const initialPropertyValues = Object.entries(input.propertyValues ?? {}); + if (createdSpace && initialPropertyValues.length > 0) { + const definitions = (await tx + .select() + .from(schema.documentPropertyDefinitions) + .where( + and( + eq( + schema.documentPropertyDefinitions.databaseId, + catalogIds.databaseId, + ), + inArray( + schema.documentPropertyDefinitions.id, + initialPropertyValues.map(([propertyId]) => propertyId), + ), + ), + )) as Array; + const definitionById = new Map( + definitions.map((definition) => [definition.id, definition]), + ); + for (const [propertyId, value] of initialPropertyValues) { + const definition = definitionById.get(propertyId); + const type = definition?.type as DocumentPropertyType | undefined; + if (!type || isComputedPropertyType(type)) continue; + await tx + .insert(schema.documentPropertyValues) + .values({ + id: opaqueId( + "content_workspace_property", + `${email}:${spaceId}:${propertyId}`, + ), + ownerEmail: email, + documentId: referenceDocumentId, + propertyId, valueJson: normalizedValueJson(type, value), + createdAt: now, updatedAt: now, - }, - }); + }) + .onConflictDoUpdate({ + target: schema.documentPropertyValues.id, + set: { + valueJson: normalizedValueJson(type, value), + updatedAt: now, + }, + }); + } } - } - return { - files: sourceFiles, - catalogDatabaseId: catalogIds.databaseId, - catalogItemId, - catalogDocumentId: referenceDocumentId, - }; - }); + return { + files: sourceFiles, + catalogDatabaseId: catalogIds.databaseId, + catalogItemId, + catalogDocumentId: referenceDocumentId, + }; + }), + ); await seedDefaultBlocksField({ databaseId: provisioned.files.id, diff --git a/templates/content/actions/content-spaces.db.test.ts b/templates/content/actions/content-spaces.db.test.ts index 4a8f6031be..b6a4f2072b 100644 --- a/templates/content/actions/content-spaces.db.test.ts +++ b/templates/content/actions/content-spaces.db.test.ts @@ -26,6 +26,9 @@ let listDocumentsAction: typeof import("./list-documents.js").default; let getDocumentAction: typeof import("./get-document.js").default; let deleteContentDatabaseAction: typeof import("./delete-content-database.js").default; let deleteDocumentAction: typeof import("./delete-document.js").default; +let deleteDatabaseItemsAction: typeof import("./delete-database-items.js").default; +let duplicateDatabaseItemAction: typeof import("./duplicate-database-item.js").default; +let duplicateDatabaseItemsAction: typeof import("./duplicate-database-items.js").default; const OWNER = "owner@example.com"; const MEMBER = "member@example.com"; @@ -53,6 +56,12 @@ beforeAll(async () => { deleteContentDatabaseAction = (await import("./delete-content-database.js")) .default; deleteDocumentAction = (await import("./delete-document.js")).default; + deleteDatabaseItemsAction = (await import("./delete-database-items.js")) + .default; + duplicateDatabaseItemAction = (await import("./duplicate-database-item.js")) + .default; + duplicateDatabaseItemsAction = (await import("./duplicate-database-items.js")) + .default; const plugin = (await import("../server/plugins/db.js")).default; await plugin(undefined as any); await getDbExec().execute(`CREATE TABLE IF NOT EXISTS organizations ( @@ -139,6 +148,18 @@ describe("Content space provisioning", () => { ), ); expect(JSON.parse(focusValue.valueJson)).toBe("Editorial"); + await runWithRequestContext({ userEmail: WORKSPACE_OWNER }, () => + createContentSpaceAction.run({ + name: "Writing", + requestId: "named-workspace-writing", + propertyValues: { "workspace-focus-property": "Changed on retry" }, + }), + ); + const [focusValueAfterRetry] = await getDb() + .select() + .from(schema.documentPropertyValues) + .where(eq(schema.documentPropertyValues.id, focusValue.id)); + expect(JSON.parse(focusValueAfterRetry.valueJson)).toBe("Editorial"); await getDb() .update(schema.documents) .set({ title: "Writing alias" }) @@ -202,9 +223,52 @@ describe("Content space provisioning", () => { await expect( deleteDocumentAction.run({ id: first.catalogDocumentId }), ).rejects.toThrow("Workspace references cannot be deleted as pages"); + await expect( + deleteDatabaseItemsAction.run({ + databaseId: first.catalogDatabaseId, + itemIds: [first.catalogItemId], + }), + ).rejects.toThrow("Workspace references cannot be deleted as pages"); + await expect( + duplicateDatabaseItemAction.run({ itemId: first.catalogItemId }), + ).rejects.toThrow("Workspace references cannot be duplicated as pages"); + await expect( + duplicateDatabaseItemsAction.run({ + databaseId: first.catalogDatabaseId, + itemIds: [first.catalogItemId], + }), + ).rejects.toThrow("Workspace references cannot be duplicated as pages"); }); }); + it("binds concurrent workspace requests to one canonical name", async () => { + const outcomes = await Promise.allSettled( + ["Alpha", "Beta"].map((name) => + runWithRequestContext({ userEmail: WORKSPACE_OWNER }, () => + createContentSpaceAction.run({ + name, + requestId: "concurrent-name-binding", + }), + ), + ), + ); + expect( + outcomes.filter((outcome) => outcome.status === "fulfilled"), + ).toHaveLength(1); + expect( + outcomes.filter((outcome) => outcome.status === "rejected"), + ).toHaveLength(1); + const fulfilled = outcomes.find( + (outcome): outcome is PromiseFulfilledResult => + outcome.status === "fulfilled", + ); + const [space] = await getDb() + .select() + .from(schema.contentSpaces) + .where(eq(schema.contentSpaces.id, fulfilled!.value.spaceId)); + expect(["Alpha", "Beta"]).toContain(space.name); + }); + it("is idempotent, opaque, and creates exactly one Files and Workspaces database", async () => { const first = await runWithRequestContext({ userEmail: OWNER }, () => provisionContentSpaces(getDb(), OWNER), diff --git a/templates/content/actions/delete-database-items.ts b/templates/content/actions/delete-database-items.ts index d19535b45d..00fe8ab5c3 100644 --- a/templates/content/actions/delete-database-items.ts +++ b/templates/content/actions/delete-database-items.ts @@ -3,6 +3,7 @@ import { writeAppState } from "@agent-native/core/application-state"; import { assertAccess } from "@agent-native/core/sharing"; import { getDb } from "../server/db/index.js"; +import { assertNotWorkspaceCatalogDocuments } from "./_content-space-catalog-guards.js"; import { databaseRowBatchSchema, renumberDatabaseRows, @@ -26,6 +27,7 @@ export default defineAction({ const deletedItemIds = rows.map((row) => row.item.id); const deletedDocumentIds = rows.map((row) => row.document.id); + await assertNotWorkspaceCatalogDocuments(db, deletedDocumentIds, "deleted"); const now = new Date().toISOString(); await db.transaction(async (tx) => { diff --git a/templates/content/actions/delete-document.ts b/templates/content/actions/delete-document.ts index 2b5d66e353..49fb697e2d 100644 --- a/templates/content/actions/delete-document.ts +++ b/templates/content/actions/delete-document.ts @@ -6,6 +6,7 @@ import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; import { chunks } from "./_batch-utils.js"; +import { assertNotWorkspaceCatalogDocuments } from "./_content-space-catalog-guards.js"; const DELETE_BATCH_SIZE = 90; @@ -170,6 +171,7 @@ export async function deleteDocumentRecursive( ): Promise { const { documentIds, ownedDatabaseIds } = await collectDocumentSubtreeForDelete(db, id, ownerEmail); + await assertNotWorkspaceCatalogDocuments(db, documentIds, "deleted"); const propertyDefinitionIds: string[] = []; await deleteWhereIn(ownedDatabaseIds, async (databaseIdBatch) => { @@ -371,13 +373,6 @@ export default defineAction({ if (systemDatabase?.systemRole) { throw new Error("System Content database documents cannot be deleted"); } - const [workspaceReference] = await db - .select({ id: schema.contentSpaceCatalogItems.id }) - .from(schema.contentSpaceCatalogItems) - .where(eq(schema.contentSpaceCatalogItems.documentId, id)); - if (workspaceReference) { - throw new Error("Workspace references cannot be deleted as pages"); - } const deleted = await deleteDocumentRecursive( db, id, diff --git a/templates/content/actions/duplicate-database-item.ts b/templates/content/actions/duplicate-database-item.ts index eb467a4141..47e145e503 100644 --- a/templates/content/actions/duplicate-database-item.ts +++ b/templates/content/actions/duplicate-database-item.ts @@ -7,6 +7,7 @@ import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; import { ensureDocumentFilesMembership } from "./_content-files.js"; +import { assertNotWorkspaceCatalogDocuments } from "./_content-space-catalog-guards.js"; import { getContentDatabaseResponse } from "./_database-utils.js"; import { nanoid } from "./_property-utils.js"; @@ -58,6 +59,11 @@ export default defineAction({ await assertAccess("document", row.database.documentId, "editor"); await assertAccess("document", row.document.id, "viewer"); + await assertNotWorkspaceCatalogDocuments( + db, + [row.document.id], + "duplicated", + ); const now = new Date().toISOString(); const nextDocumentId = nanoid(); diff --git a/templates/content/actions/duplicate-database-items.ts b/templates/content/actions/duplicate-database-items.ts index 80cbaac502..40d4cda9a8 100644 --- a/templates/content/actions/duplicate-database-items.ts +++ b/templates/content/actions/duplicate-database-items.ts @@ -6,6 +6,7 @@ import { and, eq, gte, inArray, sql } from "drizzle-orm"; import { getDb, schema } from "../server/db/index.js"; import { ensureDocumentsFilesMembership } from "./_content-files.js"; +import { assertNotWorkspaceCatalogDocuments } from "./_content-space-catalog-guards.js"; import { databaseRowBatchSchema, resolveDatabaseRowsForBatch, @@ -33,6 +34,11 @@ export default defineAction({ } const sourceDocumentIds = rows.map((row) => row.document.id); + await assertNotWorkspaceCatalogDocuments( + db, + sourceDocumentIds, + "duplicated", + ); const sourceItemIds = rows.map((row) => row.item.id); const now = new Date().toISOString(); const insertionPosition = diff --git a/templates/content/app/components/editor/database/DatabaseView.tsx b/templates/content/app/components/editor/database/DatabaseView.tsx index 2ea297efaa..f9cdcd92c3 100644 --- a/templates/content/app/components/editor/database/DatabaseView.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.tsx @@ -711,6 +711,10 @@ function DatabaseTable({ const data = isContentDatabaseUnavailable(database.data) ? undefined : database.data; + const isCreatingDatabaseItem = + data?.database.systemRole === "workspaces" + ? createContentSpace.isPending + : addItem.isPending; const isDatabaseInitialLoading = database.isLoading && !data; const properties = data?.properties ?? []; const items = data?.items ?? []; @@ -2387,18 +2391,10 @@ function DatabaseTable({ type="button" size="sm" className="h-7 rounded-md bg-foreground px-2.5 text-xs font-medium text-background hover:bg-foreground/90" - disabled={ - (data?.database.systemRole === "workspaces" - ? createContentSpace.isPending - : addItem.isPending) || !databaseId - } + disabled={isCreatingDatabaseItem || !databaseId} onClick={() => void createRow()} > - {( - data?.database.systemRole === "workspaces" - ? createContentSpace.isPending - : addItem.isPending - ) ? ( + {isCreatingDatabaseItem ? ( ) : null} New @@ -2515,7 +2511,7 @@ function DatabaseTable({ databaseDocumentId={document.id} canEdit={canEdit} isLoading={isDatabaseViewLoading} - isCreating={addItem.isPending || setProperty.isPending} + isCreating={isCreatingDatabaseItem || setProperty.isPending} hasActiveConstraints={!!searchQuery || activeFilters.length > 0} isMoving={setProperty.isPending} collapsedGroupIds={activeView.collapsedGroupIds ?? []} @@ -2559,7 +2555,7 @@ function DatabaseTable({ databaseDocumentId={document.id} canEdit={canEdit} isLoading={isDatabaseViewLoading} - isCreating={addItem.isPending} + isCreating={isCreatingDatabaseItem} activeFilters={activeFilters} hasSearch={!!searchQuery} rowsAreManuallyOrdered={rowsAreManuallyOrdered} @@ -2582,7 +2578,7 @@ function DatabaseTable({ databaseDocumentId={document.id} canEdit={canEdit} isLoading={isDatabaseViewLoading} - isCreating={addItem.isPending} + isCreating={isCreatingDatabaseItem} activeFilters={activeFilters} hasSearch={!!searchQuery} rowsAreManuallyOrdered={rowsAreManuallyOrdered} @@ -2605,7 +2601,7 @@ function DatabaseTable({ databaseDocumentId={document.id} canEdit={canEdit} isLoading={isDatabaseViewLoading} - isCreating={addItem.isPending || setProperty.isPending} + isCreating={isCreatingDatabaseItem || setProperty.isPending} activeFilters={activeFilters} hasSearch={!!searchQuery} dateProperty={dateViewProperty} @@ -2631,7 +2627,7 @@ function DatabaseTable({ databaseDocumentId={document.id} canEdit={canEdit} isLoading={isDatabaseViewLoading} - isCreating={addItem.isPending || setProperty.isPending} + isCreating={isCreatingDatabaseItem || setProperty.isPending} activeFilters={activeFilters} hasSearch={!!searchQuery} dateProperty={dateViewProperty} @@ -2665,7 +2661,7 @@ function DatabaseTable({ databaseDocumentId={document.id} canEdit={canEdit} isLoading={isDatabaseViewLoading} - isCreating={addItem.isPending} + isCreating={isCreatingDatabaseItem} columnWidths={columnWidths} sorts={sorts} filters={filters} diff --git a/templates/content/app/components/editor/database/DatabaseView.workspaces.layout.test.ts b/templates/content/app/components/editor/database/DatabaseView.workspaces.layout.test.ts new file mode 100644 index 0000000000..019ca066c0 --- /dev/null +++ b/templates/content/app/components/editor/database/DatabaseView.workspaces.layout.test.ts @@ -0,0 +1,26 @@ +import { readFileSync } from "node:fs"; + +import { describe, expect, it } from "vitest"; + +describe("Workspaces database lifecycle", () => { + it("routes every create surface through workspace semantics and shared pending state", () => { + const source = readFileSync( + new URL("./DatabaseView.tsx", import.meta.url), + "utf8", + ); + + expect(source).toContain('data?.database.systemRole === "workspaces"'); + expect(source).toContain("createContentSpace.mutateAsync"); + expect(source).toContain("propertyValues:"); + expect(source).toContain("propertyValueOverrides"); + expect(source).toContain("const isCreatingDatabaseItem ="); + expect(source.match(/isCreating=\{isCreatingDatabaseItem/g)).toHaveLength( + 6, + ); + expect( + source.match( + /isCreating=\{isCreatingDatabaseItem \|\| setProperty\.isPending\}/g, + ), + ).toHaveLength(3); + }); +}); diff --git a/templates/content/app/components/editor/database/sidebar.test.tsx b/templates/content/app/components/editor/database/sidebar.test.tsx index f6db410a6d..23617945c8 100644 --- a/templates/content/app/components/editor/database/sidebar.test.tsx +++ b/templates/content/app/components/editor/database/sidebar.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment happy-dom -import type { ContentDatabaseItem } from "@shared/api"; +import type { ContentDatabaseItem, ContentDatabaseResponse } from "@shared/api"; import { act } from "react"; import { createRoot } from "react-dom/client"; import { renderToStaticMarkup } from "react-dom/server"; @@ -9,7 +9,11 @@ import { describe, expect, it, vi } from "vitest"; import { TooltipProvider } from "@/components/ui/tooltip"; -import { DatabaseSidebarView, databaseSidebarRows } from "./sidebar"; +import { + ContentFilesSidebarView, + DatabaseSidebarView, + databaseSidebarRows, +} from "./sidebar"; import type { DatabaseBoardGroup } from "./types"; const item = (id: string, title: string) => @@ -119,6 +123,57 @@ describe("DatabaseSidebarView", () => { expect(markup).not.toContain('href="/page/workspace"'); }); + it("explains when a saved workspace filter hides every root", () => { + const data = { + database: { + viewConfig: { + version: 1, + activeViewId: "filtered", + views: [ + { + id: "filtered", + name: "Filtered", + type: "sidebar", + filters: [ + { + id: "missing", + key: "name", + label: "Name", + operator: "contains", + value: "Missing", + }, + ], + sorts: [], + filterMode: "and", + }, + ], + }, + }, + items: [item("workspace", "Builder.io")], + properties: [], + } as unknown as ContentDatabaseResponse; + const markup = renderToStaticMarkup( + + + , + ); + + expect(markup).toContain("No workspaces match this view"); + expect(markup).toContain("Show all"); + expect(markup).not.toContain("Builder.io"); + }); + it("lets the Files sidebar intercept a workspace reference row", async () => { const onOpenItem = vi.fn(() => true); const container = document.createElement("div"); diff --git a/templates/content/app/components/editor/database/sidebar.tsx b/templates/content/app/components/editor/database/sidebar.tsx index 7d8b303a3f..9346fa3d42 100644 --- a/templates/content/app/components/editor/database/sidebar.tsx +++ b/templates/content/app/components/editor/database/sidebar.tsx @@ -145,12 +145,17 @@ export function ContentFilesSidebarView({ const activeView = viewConfig.views.find((view) => view.id === selectedViewId) ?? activeDatabaseView(viewConfig); + const [constraintsCleared, setConstraintsCleared] = useState(false); + const activeFilterKey = JSON.stringify(activeView.filters); + useEffect(() => { + setConstraintsCleared(false); + }, [activeFilterKey, activeView.id]); const items = data ? applyDatabaseView( data.items, data.properties, "", - activeView.filters, + constraintsCleared ? [] : activeView.filters, activeView.sorts, activeView.filterMode ?? "and", ) @@ -193,9 +198,11 @@ export function ContentFilesSidebarView({ !!databaseViewGroupingProperty(activeView, data?.properties ?? []) } isLoading={isLoading} - hasActiveConstraints={false} + hasActiveConstraints={ + !constraintsCleared && activeView.filters.length > 0 + } openPagesIn="full_page" - onClearResultConstraints={() => {}} + onClearResultConstraints={() => setConstraintsCleared(true)} onPreview={() => {}} onOpenItem={onOpenItem} activeDocumentId={activeDocumentId} From a9232b2bedf97dd26bd394f477030b755de0643d Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:35:08 -0400 Subject: [PATCH 44/76] feat(content): complete Files workspace model --- templates/content/actions/_content-spaces.ts | 13 +- .../content/actions/_database-source-utils.ts | 8 + templates/content/actions/_database-utils.ts | 30 +- .../actions/_files-system-properties.ts | 520 ++++++++++++ .../content/actions/_local-file-documents.ts | 1 - templates/content/actions/_property-utils.ts | 12 +- .../bind-content-database-source-field.ts | 3 + .../actions/configure-document-property.ts | 3 + .../content/actions/content-files.db.test.ts | 782 +++++++++++++++++- .../content/actions/content-spaces.db.test.ts | 23 +- .../actions/delete-document-property.ts | 3 + .../actions/duplicate-document-property.ts | 3 + .../actions/reorder-document-property.ts | 9 + .../content/actions/set-document-property.ts | 3 + .../actions/split-mode-retirement.test.ts | 2 + .../submit-content-database-form.db.test.ts | 73 ++ .../actions/submit-content-database-form.ts | 7 + .../editor/DocumentDatabase.test.ts | 46 ++ .../components/editor/DocumentProperties.tsx | 2 +- .../editor/database/DatabaseView.test.ts | 29 + .../editor/database/DatabaseView.tsx | 79 +- .../sidebar/DocumentSidebar.layout.test.ts | 3 +- .../components/sidebar/DocumentSidebar.tsx | 2 +- .../components/sidebar/DocumentTreeItem.tsx | 2 +- ...now-aligns-favorites-with-workspace-pag.md | 6 + .../server/db/content-spaces-schema.spec.ts | 9 + templates/content/server/db/schema.ts | 10 + templates/content/server/plugins/db.ts | 17 + templates/content/shared/api.ts | 6 + templates/content/shared/database-form.ts | 13 +- 30 files changed, 1674 insertions(+), 45 deletions(-) create mode 100644 templates/content/actions/_files-system-properties.ts create mode 100644 templates/content/changelog/2026-07-18-workspace-navigation-now-aligns-favorites-with-workspace-pag.md diff --git a/templates/content/actions/_content-spaces.ts b/templates/content/actions/_content-spaces.ts index e712ab42aa..f03f7cb99e 100644 --- a/templates/content/actions/_content-spaces.ts +++ b/templates/content/actions/_content-spaces.ts @@ -11,6 +11,10 @@ import { listContentOrganizationMemberships, normalizeContentSpaceEmail, } from "./_content-space-access.js"; +import { + defaultFilesDatabaseViewConfig, + ensureFilesSystemPropertyDefinitions, +} from "./_files-system-properties.js"; import { withPositionLock } from "./_position-utils.js"; import { defaultDatabaseViewConfig, @@ -150,7 +154,9 @@ async function ensureSystemDatabase(args: { title: args.title, systemRole: args.role, viewConfigJson: serializeDatabaseViewConfig( - defaultDatabaseViewConfig("sidebar"), + args.role === "files" + ? defaultFilesDatabaseViewConfig(ids.databaseId) + : defaultDatabaseViewConfig("sidebar"), ), createdAt: args.now, updatedAt: args.now, @@ -171,6 +177,11 @@ async function ensureSystemDatabase(args: { throw new Error( `Unable to provision ${args.role} database for Content space`, ); + await ensureFilesSystemPropertyDefinitions({ + database, + db: args.db, + now: args.now, + }); return database; } diff --git a/templates/content/actions/_database-source-utils.ts b/templates/content/actions/_database-source-utils.ts index 83cb9fb69f..ae77298c4a 100644 --- a/templates/content/actions/_database-source-utils.ts +++ b/templates/content/actions/_database-source-utils.ts @@ -86,6 +86,7 @@ import { organizationContentSpaceId, provisionContentSpaces, } from "./_content-spaces.js"; +import { ensureFilesSystemPropertyDefinitions } from "./_files-system-properties.js"; import { databaseItemsPositionScope, documentsPositionScope, @@ -6258,6 +6259,13 @@ export async function ensureDatabaseSourceProperty(args: { database: ContentDatabaseRow; now: string; }) { + if (args.database.systemRole === "files") { + await ensureFilesSystemPropertyDefinitions({ + database: args.database, + now: args.now, + }); + return; + } const db = getDb(); const sources = await db .select({ diff --git a/templates/content/actions/_database-utils.ts b/templates/content/actions/_database-utils.ts index c6c4b4f29b..bf0712cdbf 100644 --- a/templates/content/actions/_database-utils.ts +++ b/templates/content/actions/_database-utils.ts @@ -21,6 +21,10 @@ import { applyFederatedOverlayValues, federateSources, } from "./_federation-join.js"; +import { + applyFilesSystemPropertyProjection, + filesSystemPropertyProjection, +} from "./_files-system-properties.js"; import { listPropertiesForDatabaseDocuments, listPropertiesForDatabase, @@ -400,6 +404,30 @@ export async function getContentDatabaseResponse( // every document field it consumes except the deliberately omitted body. documents as Array, ); + const databaseProperties = await listPropertiesForDatabase(databaseId); + const filesProjection = await filesSystemPropertyProjection({ + database, + documents, + properties: databaseProperties, + }); + const responseProperties = filesProjection + ? applyFilesSystemPropertyProjection({ + properties: databaseProperties, + projection: filesProjection, + }) + : databaseProperties; + if (filesProjection) { + for (const document of documents) { + propertiesByDocumentId.set( + document.id, + applyFilesSystemPropertyProjection({ + properties: propertiesByDocumentId.get(document.id) ?? [], + projection: filesProjection, + documentId: document.id, + }), + ); + } + } const queuedBodyHydrationItemIds = items.length > 0 ? new Set( @@ -499,7 +527,7 @@ export async function getContentDatabaseResponse( contextPath: databaseDocument ? await getDocumentContextPath(databaseDocument) : [], - properties: await listPropertiesForDatabase(databaseId), + properties: responseProperties, items: itemsWithOverlay, source: pagedPrimary, sources: pagedSources, diff --git a/templates/content/actions/_files-system-properties.ts b/templates/content/actions/_files-system-properties.ts new file mode 100644 index 0000000000..4928b39bef --- /dev/null +++ b/templates/content/actions/_files-system-properties.ts @@ -0,0 +1,520 @@ +import { createHash } from "node:crypto"; + +import { accessFilter } from "@agent-native/core/sharing"; +import { and, eq, inArray, isNull, or } from "drizzle-orm"; + +import { getDb, schema } from "../server/db/index.js"; +import type { + ContentDatabaseViewConfig, + DocumentProperty, + DocumentPropertyDefinition, + DocumentPropertyOption, + DocumentPropertySystemRole, + DocumentPropertyValue, +} from "../shared/api.js"; +import { + parsePropertyOptions, + serializePropertyOptions, +} from "../shared/properties.js"; +import { + defaultDatabaseViewConfig, + parseDatabaseViewConfig, + serializeDatabaseViewConfig, +} from "./_property-utils.js"; + +type Db = Pick, "select" | "insert" | "update">; +type Database = typeof schema.contentDatabases.$inferSelect; +type Document = Pick< + typeof schema.documents.$inferSelect, + | "id" + | "spaceId" + | "parentId" + | "sourceMode" + | "sourceKind" + | "ownerEmail" + | "orgId" +>; + +const SYSTEM_PROPERTY_SPECS = [ + { + role: "files_kind" as const, + name: "Kind", + type: "select" as const, + description: "The kind of Content artifact represented by this file.", + position: -300, + }, + { + role: "files_parent" as const, + name: "Parent", + type: "select" as const, + description: "The authorized parent page or database for this file.", + position: -200, + }, + { + role: "files_source" as const, + name: "Source", + type: "multi_select" as const, + description: "The source system that supplies this file.", + position: -100, + }, +] as const; + +const KIND_OPTIONS: DocumentPropertyOption[] = [ + { id: "page", name: "Page", color: "gray" }, + { id: "database", name: "Database", color: "blue" }, + { id: "database_row", name: "Database row", color: "purple" }, + { id: "imported_file", name: "Imported file", color: "green" }, + { id: "shared_copy", name: "Shared copy", color: "orange" }, +]; + +function systemPropertyId( + databaseId: string, + role: DocumentPropertySystemRole, +) { + return `content_files_property_${createHash("sha256") + .update(`${databaseId}:${role}`) + .digest("hex") + .slice(0, 32)}`; +} + +export function filesKindPropertyId(databaseId: string) { + return systemPropertyId(databaseId, "files_kind"); +} + +export function defaultFilesDatabaseViewConfig( + databaseId: string, +): ContentDatabaseViewConfig { + const config = defaultDatabaseViewConfig("sidebar"); + const filters = [ + { + key: filesKindPropertyId(databaseId), + label: "Kind", + operator: "does_not_equal" as const, + value: "database_row", + }, + ]; + return { + ...config, + filters, + views: config.views.map((view) => ({ ...view, filters })), + }; +} + +function storedOptions(role: DocumentPropertySystemRole) { + if (role === "files_kind") return { options: KIND_OPTIONS }; + if (role === "files_source") { + return { + options: [{ id: "local", name: "Local", color: "gray" as const }], + }; + } + return { options: [] }; +} + +export async function ensureFilesSystemPropertyDefinitions(args: { + database: Database; + db?: Db; + now?: string; +}) { + if (args.database.systemRole !== "files") return; + if (args.database.filesSystemPropertiesSeeded === 1) return; + const db = args.db ?? getDb(); + const now = args.now ?? new Date().toISOString(); + const existingDefinitions = await db + .select() + .from(schema.documentPropertyDefinitions) + .where(eq(schema.documentPropertyDefinitions.databaseId, args.database.id)); + const existingSourceSystemProperty = existingDefinitions.find( + (definition) => definition.systemRole === "files_source", + ); + const sourceIds = ( + await db + .select({ id: schema.contentDatabaseSources.id }) + .from(schema.contentDatabaseSources) + .where(eq(schema.contentDatabaseSources.databaseId, args.database.id)) + ).map((source) => source.id); + const legacySourceOptionIds = [...sourceIds, "local"].sort(); + const legacySourceProperty = existingDefinitions.find((definition) => { + if ( + definition.systemRole || + definition.name !== "Source" || + (definition.type !== "select" && definition.type !== "multi_select") + ) { + return false; + } + const optionIds = ( + parsePropertyOptions(definition.optionsJson).options ?? [] + ) + .map((option) => option.id) + .sort(); + return ( + sourceIds.length >= 2 && + optionIds.length === legacySourceOptionIds.length && + optionIds.every((id, index) => id === legacySourceOptionIds[index]) + ); + }); + if (!existingSourceSystemProperty && legacySourceProperty) { + await db + .update(schema.documentPropertyDefinitions) + .set({ + systemRole: "files_source", + type: "multi_select", + updatedAt: now, + }) + .where( + eq(schema.documentPropertyDefinitions.id, legacySourceProperty.id), + ); + } + const existingRoles = new Set( + existingDefinitions + .map((definition) => definition.systemRole) + .filter((role): role is string => Boolean(role)), + ); + if (legacySourceProperty && !existingSourceSystemProperty) { + existingRoles.add("files_source"); + } + const missingDefinitions = SYSTEM_PROPERTY_SPECS.filter( + (spec) => !existingRoles.has(spec.role), + ).map((spec) => ({ + id: systemPropertyId(args.database.id, spec.role), + ownerEmail: args.database.ownerEmail, + orgId: args.database.orgId, + databaseId: args.database.id, + systemRole: spec.role, + name: spec.name, + type: spec.type, + description: spec.description, + visibility: "always_show", + optionsJson: serializePropertyOptions(storedOptions(spec.role)), + position: spec.position, + createdAt: now, + updatedAt: now, + })); + if (missingDefinitions.length > 0) { + await db + .insert(schema.documentPropertyDefinitions) + .values(missingDefinitions) + .onConflictDoNothing(); + } + const normalizedStored = serializeDatabaseViewConfig( + parseDatabaseViewConfig(args.database.viewConfigJson), + ); + const untouchedLegacyDefault = serializeDatabaseViewConfig( + defaultDatabaseViewConfig("sidebar"), + ); + const viewConfigJson = + normalizedStored === untouchedLegacyDefault + ? serializeDatabaseViewConfig( + defaultFilesDatabaseViewConfig(args.database.id), + ) + : args.database.viewConfigJson; + await db + .update(schema.contentDatabases) + .set({ + viewConfigJson, + filesSystemPropertiesSeeded: 1, + updatedAt: now, + }) + .where(eq(schema.contentDatabases.id, args.database.id)); +} + +export async function repairFilesSystemPropertyDefinitions() { + const db = getDb(); + const databases = await db + .select() + .from(schema.contentDatabases) + .where( + and( + eq(schema.contentDatabases.systemRole, "files"), + eq(schema.contentDatabases.filesSystemPropertiesSeeded, 0), + isNull(schema.contentDatabases.deletedAt), + ), + ); + for (const database of databases) { + await db.transaction((tx) => + ensureFilesSystemPropertyDefinitions({ database, db: tx }), + ); + } + return databases.length; +} + +function safeOption( + id: string, + name: string, + color: DocumentPropertyOption["color"] = "gray", +): DocumentPropertyOption { + return { id, name: name.trim() || "Untitled", color }; +} + +function filesVisibilityFilter(database: Database) { + return database.orgId + ? and( + eq(schema.documents.spaceId, database.spaceId!), + eq(schema.documents.orgId, database.orgId), + or( + eq(schema.documents.visibility, "org"), + eq(schema.documents.visibility, "public"), + ), + or( + eq(schema.documents.hideFromSearch, 0), + isNull(schema.documents.hideFromSearch), + ), + ) + : and( + eq(schema.documents.spaceId, database.spaceId!), + eq(schema.documents.ownerEmail, database.ownerEmail), + ); +} + +async function readableDatabaseIds( + db: Db, + databaseIds: string[], + options: { includeSystem?: boolean } = {}, +) { + if (databaseIds.length === 0) return new Set(); + const databases = await db + .select({ + id: schema.contentDatabases.id, + documentId: schema.contentDatabases.documentId, + }) + .from(schema.contentDatabases) + .where( + and( + inArray(schema.contentDatabases.id, databaseIds), + isNull(schema.contentDatabases.deletedAt), + options.includeSystem === false + ? isNull(schema.contentDatabases.systemRole) + : undefined, + ), + ); + if (databases.length === 0) return new Set(); + const readableDocuments = await db + .select({ id: schema.documents.id }) + .from(schema.documents) + .where( + and( + inArray( + schema.documents.id, + databases.map((database) => database.documentId), + ), + accessFilter(schema.documents, schema.documentShares), + ), + ); + const readableDocumentIds = new Set(readableDocuments.map((row) => row.id)); + return new Set( + databases + .filter((database) => readableDocumentIds.has(database.documentId)) + .map((database) => database.id), + ); +} + +export async function filesSystemPropertyProjection(args: { + database: Database; + documents: Document[]; + properties: DocumentProperty[]; +}) { + const systemProperties = args.properties.filter((property) => + SYSTEM_PROPERTY_SPECS.some( + (spec) => spec.role === property.definition.systemRole, + ), + ); + if (args.database.systemRole !== "files" || systemProperties.length === 0) { + return null; + } + const db = getDb(); + const documentIds = args.documents.map((document) => document.id); + const parentIds = [ + ...new Set( + args.documents + .map((document) => document.parentId) + .filter((id): id is string => Boolean(id)), + ), + ]; + const parents = parentIds.length + ? await db + .select({ id: schema.documents.id, title: schema.documents.title }) + .from(schema.documents) + .where( + and( + inArray(schema.documents.id, parentIds), + filesVisibilityFilter(args.database), + ), + ) + : []; + const parentById = new Map(parents.map((parent) => [parent.id, parent])); + + const ownedDatabases = documentIds.length + ? await db + .select({ + id: schema.contentDatabases.id, + documentId: schema.contentDatabases.documentId, + }) + .from(schema.contentDatabases) + .where( + and( + inArray(schema.contentDatabases.documentId, documentIds), + isNull(schema.contentDatabases.deletedAt), + ), + ) + : []; + const ownedDatabaseDocumentIds = new Set( + ownedDatabases.map((database) => database.documentId), + ); + + const memberships = documentIds.length + ? await db + .select({ + documentId: schema.contentDatabaseItems.documentId, + databaseId: schema.contentDatabaseItems.databaseId, + }) + .from(schema.contentDatabaseItems) + .where(inArray(schema.contentDatabaseItems.documentId, documentIds)) + : []; + const membershipDatabaseIds = [ + ...new Set( + memberships + .map((membership) => membership.databaseId) + .filter((databaseId) => databaseId !== args.database.id), + ), + ]; + const readableMembershipDatabaseIds = await readableDatabaseIds( + db, + membershipDatabaseIds, + { includeSystem: false }, + ); + const databaseRowDocumentIds = new Set( + memberships + .filter((membership) => + readableMembershipDatabaseIds.has(membership.databaseId), + ) + .map((membership) => membership.documentId), + ); + + const sourceRows = documentIds.length + ? await db + .select({ + documentId: schema.contentDatabaseSourceRows.documentId, + sourceId: schema.contentDatabaseSourceRows.sourceId, + }) + .from(schema.contentDatabaseSourceRows) + .where( + inArray(schema.contentDatabaseSourceRows.documentId, documentIds), + ) + : []; + const sourceIds = [...new Set(sourceRows.map((row) => row.sourceId))]; + const sources = sourceIds.length + ? await db + .select({ + id: schema.contentDatabaseSources.id, + databaseId: schema.contentDatabaseSources.databaseId, + sourceName: schema.contentDatabaseSources.sourceName, + }) + .from(schema.contentDatabaseSources) + .where(inArray(schema.contentDatabaseSources.id, sourceIds)) + : []; + const readableSourceDatabaseIds = await readableDatabaseIds( + db, + sources + .map((source) => source.databaseId) + .filter((databaseId) => databaseId !== args.database.id), + ); + readableSourceDatabaseIds.add(args.database.id); + const readableSourceById = new Map( + sources + .filter((source) => readableSourceDatabaseIds.has(source.databaseId)) + .map((source) => [source.id, source]), + ); + const sourceIdsByDocument = new Map(); + for (const row of sourceRows) { + if (!readableSourceById.has(row.sourceId)) continue; + sourceIdsByDocument.set(row.documentId, [ + ...(sourceIdsByDocument.get(row.documentId) ?? []), + row.sourceId, + ]); + } + + const parentOptions = parents.map((parent) => + safeOption(parent.id, parent.title, "blue"), + ); + const sourceOptions = [ + safeOption("local", "Local"), + ...[...readableSourceById.values()].map((source) => + safeOption(source.id, source.sourceName, "green"), + ), + ]; + const definitionByRole = new Map< + DocumentPropertySystemRole, + DocumentPropertyDefinition + >(); + for (const property of systemProperties) { + const role = property.definition.systemRole!; + definitionByRole.set(role, { + ...property.definition, + options: { + options: + role === "files_kind" + ? KIND_OPTIONS + : role === "files_parent" + ? parentOptions + : sourceOptions, + }, + }); + } + + const valuesByDocumentId = new Map< + string, + Map + >(); + for (const document of args.documents) { + const sourceValues = [ + ...new Set(sourceIdsByDocument.get(document.id) ?? []), + ]; + const kind = + document.sourceKind === "local-file-copy" + ? "shared_copy" + : document.sourceMode === "local-files" && + document.sourceKind === "file" + ? "imported_file" + : ownedDatabaseDocumentIds.has(document.id) + ? "database" + : databaseRowDocumentIds.has(document.id) + ? "database_row" + : "page"; + valuesByDocumentId.set( + document.id, + new Map([ + ["files_kind", kind], + [ + "files_parent", + document.parentId && parentById.has(document.parentId) + ? document.parentId + : null, + ], + ["files_source", sourceValues.length > 0 ? sourceValues : ["local"]], + ]), + ); + } + return { definitionByRole, valuesByDocumentId }; +} + +export function applyFilesSystemPropertyProjection(args: { + properties: DocumentProperty[]; + projection: NonNullable< + Awaited> + >; + documentId?: string; +}) { + return args.properties.map((property) => { + const role = property.definition.systemRole; + if (!role) return property; + return { + ...property, + definition: + args.projection.definitionByRole.get(role) ?? property.definition, + value: args.documentId + ? (args.projection.valuesByDocumentId.get(args.documentId)?.get(role) ?? + null) + : null, + editable: false, + }; + }); +} diff --git a/templates/content/actions/_local-file-documents.ts b/templates/content/actions/_local-file-documents.ts index b76457f86b..ddba5da128 100644 --- a/templates/content/actions/_local-file-documents.ts +++ b/templates/content/actions/_local-file-documents.ts @@ -234,7 +234,6 @@ function localFileDocumentsCacheScope() { return JSON.stringify({ manifest: process.env.AGENT_NATIVE_MANIFEST, manifestPath: process.env.AGENT_NATIVE_MANIFEST_PATH, - dataMode: process.env.AGENT_NATIVE_DATA_MODE, }); } diff --git a/templates/content/actions/_property-utils.ts b/templates/content/actions/_property-utils.ts index 4134b943ae..b7b0a37eec 100644 --- a/templates/content/actions/_property-utils.ts +++ b/templates/content/actions/_property-utils.ts @@ -508,6 +508,9 @@ export async function listPropertiesForDatabase( definition: { id: definition.id, databaseId: definition.databaseId, + systemRole: definition.systemRole as + | import("../shared/api.js").DocumentPropertySystemRole + | null, name: definition.name, type, description: definition.description, @@ -531,7 +534,7 @@ export async function listPropertiesForDatabase( blockFieldContent: blockContentByPropertyId.get(definition.id), }) : parsePropertyValue(storedValue?.valueJson), - editable: !isComputedPropertyType(type), + editable: !definition.systemRole && !isComputedPropertyType(type), }; }); @@ -577,6 +580,9 @@ function serializePropertyDefinition( return { id: definition.id, databaseId: definition.databaseId, + systemRole: definition.systemRole as + | import("../shared/api.js").DocumentPropertySystemRole + | null, name: definition.name, type, description: definition.description, @@ -701,7 +707,9 @@ export async function listPropertiesForDatabaseDocuments( ), }) : parsePropertyValue(storedValue?.valueJson), - editable: !isComputedPropertyType(propertyDefinition.type), + editable: + !definition.systemRole && + !isComputedPropertyType(propertyDefinition.type), }; }); diff --git a/templates/content/actions/bind-content-database-source-field.ts b/templates/content/actions/bind-content-database-source-field.ts index cb2a5c8164..305e474e44 100644 --- a/templates/content/actions/bind-content-database-source-field.ts +++ b/templates/content/actions/bind-content-database-source-field.ts @@ -121,6 +121,9 @@ export default defineAction({ if (!property) { throw new Error("Target column does not belong to this database."); } + if (property.systemRole) { + throw new Error("System properties cannot be bound to source fields."); + } // The auto-created "Source" tag is internal row-tagging, never a writable // bind target. if ( diff --git a/templates/content/actions/configure-document-property.ts b/templates/content/actions/configure-document-property.ts index 6f5a0b015f..dcc28338f4 100644 --- a/templates/content/actions/configure-document-property.ts +++ b/templates/content/actions/configure-document-property.ts @@ -118,6 +118,9 @@ export default defineAction({ ), ); if (!existing) throw new Error(`Property "${args.id}" not found`); + if (existing.systemRole) { + throw new Error("System properties cannot be changed."); + } if ( isComputedPropertyType(existing.type as DocumentPropertyType) && existing.type !== type diff --git a/templates/content/actions/content-files.db.test.ts b/templates/content/actions/content-files.db.test.ts index 1d2cc7aee4..08c028d783 100644 --- a/templates/content/actions/content-files.db.test.ts +++ b/templates/content/actions/content-files.db.test.ts @@ -113,6 +113,771 @@ async function getFilesDatabase(spaceId: string) { } describe("Content Files membership reconciliation", () => { + it("exposes stable, derived Kind, Parent, and Source properties", async () => { + const spaceId = personalContentSpaceId(OWNER); + const filesDatabase = await getFilesDatabase(spaceId); + const now = new Date().toISOString(); + await getDb() + .insert(schema.documents) + .values([ + { + id: "system-property-parent", + ownerEmail: OWNER, + orgId: null, + spaceId, + parentId: null, + title: "Visible parent", + content: "", + description: "", + position: 0, + isFavorite: 0, + hideFromSearch: 0, + visibility: "private", + createdAt: now, + updatedAt: now, + }, + { + id: "system-property-child", + ownerEmail: OWNER, + orgId: null, + spaceId, + parentId: "system-property-parent", + title: "Imported child", + content: "", + description: "", + position: 1, + isFavorite: 0, + hideFromSearch: 0, + visibility: "private", + sourceMode: "local-files", + sourceKind: "file", + sourcePath: "notes.md", + createdAt: now, + updatedAt: now, + }, + ]); + await runWithRequestContext({ userEmail: OWNER }, () => + reconcileContentFilesMemberships(getDb(), OWNER), + ); + const [childItem] = await getDb() + .select() + .from(schema.contentDatabaseItems) + .where( + and( + eq(schema.contentDatabaseItems.databaseId, filesDatabase.id), + eq(schema.contentDatabaseItems.documentId, "system-property-child"), + ), + ); + await getDb().insert(schema.contentDatabaseSources).values({ + id: "system-property-source", + ownerEmail: OWNER, + orgId: null, + databaseId: filesDatabase.id, + sourceType: "local-folder", + sourceName: "Project notes", + sourceTable: "opaque-connection", + syncState: "linked", + freshness: "fresh", + capabilitiesJson: "{}", + metadataJson: "{}", + createdAt: now, + updatedAt: now, + }); + await getDb().insert(schema.contentDatabaseSourceRows).values({ + id: "system-property-source-row", + ownerEmail: OWNER, + sourceId: "system-property-source", + databaseItemId: childItem.id, + documentId: "system-property-child", + sourceRowId: "notes.md", + sourceQualifiedId: "local-folder://opaque/notes.md", + sourceDisplayKey: "notes.md", + sourceValuesJson: "{}", + provenance: "test", + syncState: "linked", + freshness: "fresh", + createdAt: now, + updatedAt: now, + }); + + const response = await runWithRequestContext({ userEmail: OWNER }, () => + getContentDatabaseAction.run({ databaseId: filesDatabase.id }), + ); + const definitions = new Map( + response.properties.map((property) => [ + property.definition.systemRole, + property, + ]), + ); + expect(definitions.get("files_kind")).toMatchObject({ editable: false }); + expect(definitions.get("files_parent")).toMatchObject({ editable: false }); + expect(definitions.get("files_source")).toMatchObject({ editable: false }); + expect(response.database.viewConfig.views[0]?.filters).toEqual([ + expect.objectContaining({ + key: definitions.get("files_kind")?.definition.id, + operator: "does_not_equal", + value: "database_row", + }), + ]); + const child = response.items.find( + (item) => item.document.id === "system-property-child", + )!; + const values = new Map( + child.properties.map((property) => [ + property.definition.systemRole, + property.value, + ]), + ); + expect(values.get("files_kind")).toBe("imported_file"); + expect(values.get("files_parent")).toBe("system-property-parent"); + expect(values.get("files_source")).toEqual(["system-property-source"]); + expect( + definitions + .get("files_parent") + ?.definition.options.options?.find( + (option) => option.id === "system-property-parent", + )?.name, + ).toBe("Visible parent"); + expect( + definitions + .get("files_source") + ?.definition.options.options?.find( + (option) => option.id === "system-property-source", + )?.name, + ).toBe("Project notes"); + + await getDb() + .delete(schema.contentDatabaseSourceRows) + .where( + eq(schema.contentDatabaseSourceRows.id, "system-property-source-row"), + ); + await getDb() + .delete(schema.contentDatabaseSources) + .where(eq(schema.contentDatabaseSources.id, "system-property-source")); + await getDb() + .delete(schema.contentDatabaseItems) + .where( + inArray(schema.contentDatabaseItems.documentId, [ + "system-property-parent", + "system-property-child", + ]), + ); + await getDb() + .delete(schema.documents) + .where( + inArray(schema.documents.id, [ + "system-property-parent", + "system-property-child", + ]), + ); + }); + + it("rejects mutations of derived Files system properties", async () => { + const filesDatabase = await getFilesDatabase(personalContentSpaceId(OWNER)); + const [kindProperty] = await getDb() + .select() + .from(schema.documentPropertyDefinitions) + .where( + and( + eq(schema.documentPropertyDefinitions.databaseId, filesDatabase.id), + eq(schema.documentPropertyDefinitions.systemRole, "files_kind"), + ), + ); + const [ + setProperty, + configureProperty, + deleteProperty, + duplicateProperty, + reorderProperty, + ] = await Promise.all([ + import("./set-document-property.js").then((module) => module.default), + import("./configure-document-property.js").then( + (module) => module.default, + ), + import("./delete-document-property.js").then((module) => module.default), + import("./duplicate-document-property.js").then( + (module) => module.default, + ), + import("./reorder-document-property.js").then((module) => module.default), + ]); + const inOwnerContext = (run: () => Promise) => + runWithRequestContext({ userEmail: OWNER }, run); + + await expect( + inOwnerContext(() => + setProperty.run({ + documentId: filesDatabase.documentId, + propertyId: kindProperty.id, + value: "page", + }), + ), + ).rejects.toThrow("derived"); + await expect( + inOwnerContext(() => + configureProperty.run({ + id: kindProperty.id, + documentId: filesDatabase.documentId, + name: "Other", + type: "select", + }), + ), + ).rejects.toThrow("cannot be changed"); + await expect( + inOwnerContext(() => + deleteProperty.run({ + documentId: filesDatabase.documentId, + propertyId: kindProperty.id, + }), + ), + ).rejects.toThrow("cannot be deleted"); + await expect( + inOwnerContext(() => + duplicateProperty.run({ + documentId: filesDatabase.documentId, + propertyId: kindProperty.id, + }), + ), + ).rejects.toThrow("cannot be duplicated"); + const [contentProperty] = await getDb() + .select() + .from(schema.documentPropertyDefinitions) + .where( + and( + eq(schema.documentPropertyDefinitions.databaseId, filesDatabase.id), + eq(schema.documentPropertyDefinitions.type, "blocks"), + ), + ); + await expect( + inOwnerContext(() => + reorderProperty.run({ + documentId: filesDatabase.documentId, + propertyId: kindProperty.id, + targetPropertyId: contentProperty.id, + position: "before", + }), + ), + ).rejects.toThrow("cannot be reordered"); + }); + + it("keeps complete Files inventory available for client-side filtered pagination", async () => { + const { defaultFilesDatabaseViewConfig } = + await import("./_files-system-properties.js"); + const { serializeDatabaseViewConfig } = + await import("./_property-utils.js"); + const filesDatabase = await getFilesDatabase(personalContentSpaceId(OWNER)); + const originalViewConfigJson = filesDatabase.viewConfigJson; + const now = new Date().toISOString(); + const containingDatabaseDocumentId = "pagination-containing-database"; + const containingDatabaseId = "pagination-containing-database-record"; + const rowDocumentIds = Array.from( + { length: 101 }, + (_, index) => `pagination-database-row-${index}`, + ); + const topLevelDocumentId = "pagination-top-level-page"; + const testDocumentIds = [ + containingDatabaseDocumentId, + ...rowDocumentIds, + topLevelDocumentId, + ]; + await getDb() + .update(schema.contentDatabases) + .set({ + viewConfigJson: serializeDatabaseViewConfig( + defaultFilesDatabaseViewConfig(filesDatabase.id), + ), + }) + .where(eq(schema.contentDatabases.id, filesDatabase.id)); + await getDb() + .insert(schema.documents) + .values( + testDocumentIds.map((id, position) => ({ + id, + ownerEmail: OWNER, + spaceId: filesDatabase.spaceId, + title: id === topLevelDocumentId ? "Later top-level page" : id, + content: "", + position, + visibility: "private", + createdAt: now, + updatedAt: now, + })), + ); + await getDb().insert(schema.contentDatabases).values({ + id: containingDatabaseId, + ownerEmail: OWNER, + spaceId: filesDatabase.spaceId, + documentId: containingDatabaseDocumentId, + title: "Containing database", + createdAt: now, + updatedAt: now, + }); + await getDb() + .insert(schema.contentDatabaseItems) + .values([ + ...rowDocumentIds.map((documentId, position) => ({ + id: `pagination-containing-item-${position}`, + ownerEmail: OWNER, + databaseId: containingDatabaseId, + documentId, + position, + createdAt: now, + updatedAt: now, + })), + ...rowDocumentIds.map((documentId, position) => ({ + id: `pagination-files-item-${position}`, + ownerEmail: OWNER, + databaseId: filesDatabase.id, + documentId, + position, + createdAt: now, + updatedAt: now, + })), + { + id: "pagination-files-top-level-item", + ownerEmail: OWNER, + databaseId: filesDatabase.id, + documentId: topLevelDocumentId, + position: rowDocumentIds.length, + createdAt: now, + updatedAt: now, + }, + ]); + + const firstPage = await runWithRequestContext({ userEmail: OWNER }, () => + getContentDatabaseAction.run({ + databaseId: filesDatabase.id, + limit: 100, + }), + ); + expect(firstPage.items).toHaveLength(100); + expect(firstPage.items).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + document: expect.objectContaining({ id: topLevelDocumentId }), + }), + ]), + ); + expect(firstPage.pagination).toEqual({ + offset: 0, + limit: 100, + totalItems: 102, + returnedItems: 100, + hasMore: true, + }); + const expanded = await runWithRequestContext({ userEmail: OWNER }, () => + getContentDatabaseAction.run({ + databaseId: filesDatabase.id, + limit: 102, + }), + ); + expect(expanded.items).toHaveLength(102); + expect(expanded.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + document: expect.objectContaining({ id: topLevelDocumentId }), + }), + ]), + ); + expect(expanded.pagination).toEqual({ + offset: 0, + limit: 102, + totalItems: 102, + returnedItems: 102, + hasMore: false, + }); + + await getDb() + .delete(schema.contentDatabaseItems) + .where(inArray(schema.contentDatabaseItems.documentId, testDocumentIds)); + await getDb() + .delete(schema.contentDatabases) + .where(eq(schema.contentDatabases.id, containingDatabaseId)); + await getDb() + .delete(schema.documents) + .where(inArray(schema.documents.id, testDocumentIds)); + await getDb() + .update(schema.contentDatabases) + .set({ viewConfigJson: originalViewConfigJson }) + .where(eq(schema.contentDatabases.id, filesDatabase.id)); + }); + + it("does not hide org-visible files because of an unreadable private database membership", async () => { + const orgSpaceId = organizationContentSpaceId(ORG_ID); + const filesDatabase = await getFilesDatabase(orgSpaceId); + const now = new Date().toISOString(); + const privateDatabaseDocumentId = "private-containing-database-document"; + const privateDatabaseId = "private-containing-database"; + const visibleDocumentId = "private-membership-visible-org-page"; + await getDb() + .insert(schema.documents) + .values([ + { + id: privateDatabaseDocumentId, + ownerEmail: OWNER, + orgId: ORG_ID, + spaceId: orgSpaceId, + title: "Owner private database", + content: "", + visibility: "private", + createdAt: now, + updatedAt: now, + }, + { + id: visibleDocumentId, + ownerEmail: OWNER, + orgId: ORG_ID, + spaceId: orgSpaceId, + title: "Visible organization page", + content: "", + visibility: "org", + createdAt: now, + updatedAt: now, + }, + ]); + await getDb().insert(schema.contentDatabases).values({ + id: privateDatabaseId, + ownerEmail: OWNER, + orgId: ORG_ID, + spaceId: orgSpaceId, + documentId: privateDatabaseDocumentId, + title: "Owner private database", + createdAt: now, + updatedAt: now, + }); + await getDb() + .insert(schema.contentDatabaseItems) + .values([ + { + id: "private-containing-database-item", + ownerEmail: OWNER, + orgId: ORG_ID, + databaseId: privateDatabaseId, + documentId: visibleDocumentId, + position: 0, + createdAt: now, + updatedAt: now, + }, + { + id: "private-containing-files-item", + ownerEmail: OWNER, + orgId: ORG_ID, + databaseId: filesDatabase.id, + documentId: visibleDocumentId, + position: 0, + createdAt: now, + updatedAt: now, + }, + ]); + + const response = await runWithRequestContext( + { userEmail: VIEWER, orgId: ORG_ID }, + () => getContentDatabaseAction.run({ databaseId: filesDatabase.id }), + ); + const visibleItem = response.items.find( + (item) => item.document.id === visibleDocumentId, + ); + expect(visibleItem?.document.title).toBe("Visible organization page"); + expect( + visibleItem?.properties.find( + (property) => property.definition.systemRole === "files_kind", + )?.value, + ).toBe("page"); + expect(JSON.stringify(response)).not.toContain("Owner private database"); + + await getDb() + .delete(schema.contentDatabaseItems) + .where( + inArray(schema.contentDatabaseItems.documentId, [visibleDocumentId]), + ); + await getDb() + .delete(schema.contentDatabases) + .where(eq(schema.contentDatabases.id, privateDatabaseId)); + await getDb() + .delete(schema.documents) + .where( + inArray(schema.documents.id, [ + privateDatabaseDocumentId, + visibleDocumentId, + ]), + ); + }); + + it("repairs only unseeded Files defaults and preserves a cleared filter", async () => { + const { repairFilesSystemPropertyDefinitions } = + await import("./_files-system-properties.js"); + const { defaultDatabaseViewConfig, serializeDatabaseViewConfig } = + await import("./_property-utils.js"); + const filesDatabase = await getFilesDatabase(personalContentSpaceId(OWNER)); + const legacyDefault = serializeDatabaseViewConfig( + defaultDatabaseViewConfig("sidebar"), + ); + await getDb() + .update(schema.contentDatabases) + .set({ + filesSystemPropertiesSeeded: 0, + viewConfigJson: legacyDefault, + }) + .where(eq(schema.contentDatabases.id, filesDatabase.id)); + + await expect(repairFilesSystemPropertyDefinitions()).resolves.toBe(1); + const [repaired] = await getDb() + .select() + .from(schema.contentDatabases) + .where(eq(schema.contentDatabases.id, filesDatabase.id)); + expect(repaired.filesSystemPropertiesSeeded).toBe(1); + expect(repaired.viewConfigJson).toContain("database_row"); + + await getDb() + .update(schema.contentDatabases) + .set({ viewConfigJson: legacyDefault }) + .where(eq(schema.contentDatabases.id, filesDatabase.id)); + await expect(repairFilesSystemPropertyDefinitions()).resolves.toBe(0); + const [afterClearing] = await getDb() + .select() + .from(schema.contentDatabases) + .where(eq(schema.contentDatabases.id, filesDatabase.id)); + expect(afterClearing.viewConfigJson).toBe(legacyDefault); + }); + + it("adopts a legacy multi-source Files Source property without losing values", async () => { + const { ensureFilesSystemPropertyDefinitions } = + await import("./_files-system-properties.js"); + const { ensureDatabaseSourceProperty } = + await import("./_database-source-utils.js"); + const now = new Date().toISOString(); + await getDb().insert(schema.documents).values({ + id: "legacy-source-files-document", + spaceId: "legacy-source-space", + ownerEmail: OWNER, + title: "Files", + content: "", + visibility: "private", + createdAt: now, + updatedAt: now, + }); + await getDb().insert(schema.contentDatabases).values({ + id: "legacy-source-files-database", + spaceId: "legacy-source-space", + ownerEmail: OWNER, + documentId: "legacy-source-files-document", + title: "Files", + systemRole: "files", + viewConfigJson: "{}", + createdAt: now, + updatedAt: now, + }); + await getDb() + .insert(schema.contentDatabaseSources) + .values([ + { + id: "legacy-source-one", + ownerEmail: OWNER, + databaseId: "legacy-source-files-database", + sourceType: "local-folder", + sourceName: "One", + sourceTable: "one", + syncState: "linked", + freshness: "fresh", + capabilitiesJson: "{}", + metadataJson: "{}", + createdAt: now, + updatedAt: now, + }, + { + id: "legacy-source-two", + ownerEmail: OWNER, + databaseId: "legacy-source-files-database", + sourceType: "local-folder", + sourceName: "Two", + sourceTable: "two", + syncState: "linked", + freshness: "fresh", + capabilitiesJson: "{}", + metadataJson: "{}", + createdAt: now, + updatedAt: now, + }, + ]); + await getDb() + .insert(schema.documentPropertyDefinitions) + .values({ + id: "legacy-source-property", + ownerEmail: OWNER, + databaseId: "legacy-source-files-database", + name: "Source", + type: "select", + optionsJson: JSON.stringify({ + options: [ + { id: "legacy-source-one", name: "One", color: "blue" }, + { id: "legacy-source-two", name: "Two", color: "green" }, + { id: "local", name: "Local", color: "gray" }, + ], + }), + position: 0, + createdAt: now, + updatedAt: now, + }); + await getDb().insert(schema.documentPropertyValues).values({ + id: "legacy-source-value", + ownerEmail: OWNER, + documentId: "legacy-source-files-document", + propertyId: "legacy-source-property", + valueJson: '"local"', + createdAt: now, + updatedAt: now, + }); + const [database] = await getDb() + .select() + .from(schema.contentDatabases) + .where(eq(schema.contentDatabases.id, "legacy-source-files-database")); + await ensureFilesSystemPropertyDefinitions({ database, now }); + const [seededDatabase] = await getDb() + .select() + .from(schema.contentDatabases) + .where(eq(schema.contentDatabases.id, database.id)); + await ensureDatabaseSourceProperty({ database: seededDatabase, now }); + + const sourceProperties = await getDb() + .select() + .from(schema.documentPropertyDefinitions) + .where( + and( + eq(schema.documentPropertyDefinitions.databaseId, database.id), + eq(schema.documentPropertyDefinitions.name, "Source"), + ), + ); + expect(sourceProperties).toEqual([ + expect.objectContaining({ + id: "legacy-source-property", + systemRole: "files_source", + type: "multi_select", + }), + ]); + await expect( + getDb() + .select() + .from(schema.documentPropertyValues) + .where(eq(schema.documentPropertyValues.id, "legacy-source-value")), + ).resolves.toEqual([ + expect.objectContaining({ + propertyId: "legacy-source-property", + valueJson: '"local"', + }), + ]); + + await getDb() + .delete(schema.documentPropertyValues) + .where(eq(schema.documentPropertyValues.id, "legacy-source-value")); + await getDb() + .delete(schema.documentPropertyDefinitions) + .where( + eq( + schema.documentPropertyDefinitions.databaseId, + "legacy-source-files-database", + ), + ); + await getDb() + .delete(schema.contentDatabaseSources) + .where( + eq( + schema.contentDatabaseSources.databaseId, + "legacy-source-files-database", + ), + ); + await getDb() + .delete(schema.contentDatabases) + .where(eq(schema.contentDatabases.id, "legacy-source-files-database")); + await getDb() + .delete(schema.documents) + .where(eq(schema.documents.id, "legacy-source-files-document")); + }); + + it("leaves a user-created Source property untouched", async () => { + const { ensureFilesSystemPropertyDefinitions } = + await import("./_files-system-properties.js"); + const now = new Date().toISOString(); + const documentId = "custom-source-files-document"; + const databaseId = "custom-source-files-database"; + await getDb().insert(schema.documents).values({ + id: documentId, + spaceId: "custom-source-space", + ownerEmail: OWNER, + title: "Files", + content: "", + visibility: "private", + createdAt: now, + updatedAt: now, + }); + await getDb().insert(schema.contentDatabases).values({ + id: databaseId, + spaceId: "custom-source-space", + ownerEmail: OWNER, + documentId, + title: "Files", + systemRole: "files", + viewConfigJson: "{}", + createdAt: now, + updatedAt: now, + }); + await getDb() + .insert(schema.documentPropertyDefinitions) + .values({ + id: "custom-source-property", + ownerEmail: OWNER, + databaseId, + name: "Source", + type: "select", + optionsJson: JSON.stringify({ + options: [ + { id: "internal", name: "Internal", color: "blue" }, + { id: "external", name: "External", color: "green" }, + ], + }), + position: 0, + createdAt: now, + updatedAt: now, + }); + const [database] = await getDb() + .select() + .from(schema.contentDatabases) + .where(eq(schema.contentDatabases.id, databaseId)); + await ensureFilesSystemPropertyDefinitions({ database, now }); + + const sourceProperties = await getDb() + .select() + .from(schema.documentPropertyDefinitions) + .where( + and( + eq(schema.documentPropertyDefinitions.databaseId, databaseId), + eq(schema.documentPropertyDefinitions.name, "Source"), + ), + ); + expect(sourceProperties).toHaveLength(2); + expect(sourceProperties).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "custom-source-property", + systemRole: null, + type: "select", + }), + expect.objectContaining({ + systemRole: "files_source", + type: "multi_select", + }), + ]), + ); + + await getDb() + .delete(schema.documentPropertyDefinitions) + .where(eq(schema.documentPropertyDefinitions.databaseId, databaseId)); + await getDb() + .delete(schema.contentDatabases) + .where(eq(schema.contentDatabases.id, databaseId)); + await getDb() + .delete(schema.documents) + .where(eq(schema.documents.id, documentId)); + }); + it("removes system databases and workspace references from Personal Files", async () => { const personalSpaceId = personalContentSpaceId(OWNER); const personalFiles = await getFilesDatabase(personalSpaceId); @@ -213,7 +978,11 @@ describe("Content Files membership reconciliation", () => { }); await getDb() .update(schema.documents) - .set({ content: "Keep this body exactly", icon: "📚" }) + .set({ + content: "Keep this body exactly", + icon: "📚", + parentId: "owner-private-org", + }) .where(eq(schema.documents.id, "viewer-legacy-org")); await getDb() .update(schema.documents) @@ -327,6 +1096,17 @@ describe("Content Files membership reconciliation", () => { }), ]), ); + const visibleItem = databaseResponse.items.find( + (item) => item.document.id === "viewer-legacy-org", + )!; + expect( + visibleItem.properties.find( + (property) => property.definition.systemRole === "files_parent", + )?.value, + ).toBeNull(); + expect(JSON.stringify(databaseResponse)).not.toContain( + "Owner private page", + ); expect(databaseResponse.items).not.toEqual( expect.arrayContaining([ expect.objectContaining({ diff --git a/templates/content/actions/content-spaces.db.test.ts b/templates/content/actions/content-spaces.db.test.ts index b6a4f2072b..d2976bbb08 100644 --- a/templates/content/actions/content-spaces.db.test.ts +++ b/templates/content/actions/content-spaces.db.test.ts @@ -509,14 +509,21 @@ describe("Content space provisioning", () => { systemRole: "files", blocksSeeded: 1, }); - await expect( - getDb() - .select() - .from(schema.documentPropertyDefinitions) - .where( - eq(schema.documentPropertyDefinitions.databaseId, filesDatabase!.id), - ), - ).resolves.toHaveLength(1); + const filesProperties = await getDb() + .select() + .from(schema.documentPropertyDefinitions) + .where( + eq(schema.documentPropertyDefinitions.databaseId, filesDatabase!.id), + ); + expect(filesProperties).toHaveLength(4); + expect(filesProperties.map((property) => property.systemRole)).toEqual( + expect.arrayContaining([ + null, + "files_kind", + "files_parent", + "files_source", + ]), + ); await runWithRequestContext({ userEmail: MEMBER }, async () => { await expect(resolveContentSpaceAccess(spaceId)).resolves.toMatchObject({ role: "viewer", diff --git a/templates/content/actions/delete-document-property.ts b/templates/content/actions/delete-document-property.ts index 045aef08e0..f71357bf9f 100644 --- a/templates/content/actions/delete-document-property.ts +++ b/templates/content/actions/delete-document-property.ts @@ -44,6 +44,9 @@ export default defineAction({ ), ); if (!definition) throw new Error(`Property "${propertyId}" not found`); + if (definition.systemRole) { + throw new Error("System properties cannot be deleted."); + } const isBlocks = isBlocksPropertyType( definition.type as DocumentPropertyType, diff --git a/templates/content/actions/duplicate-document-property.ts b/templates/content/actions/duplicate-document-property.ts index 4195e44732..91f9319c1a 100644 --- a/templates/content/actions/duplicate-document-property.ts +++ b/templates/content/actions/duplicate-document-property.ts @@ -48,6 +48,9 @@ export default defineAction({ ), ); if (!definition) throw new Error(`Property "${propertyId}" not found`); + if (definition.systemRole) { + throw new Error("System properties cannot be duplicated."); + } const now = new Date().toISOString(); const newPropertyId = nanoid(); diff --git a/templates/content/actions/reorder-document-property.ts b/templates/content/actions/reorder-document-property.ts index 3d76729a75..5777b41580 100644 --- a/templates/content/actions/reorder-document-property.ts +++ b/templates/content/actions/reorder-document-property.ts @@ -42,6 +42,15 @@ export default defineAction({ if (!ids.includes(targetPropertyId)) { throw new Error(`Property "${targetPropertyId}" not found`); } + const definitionById = new Map( + definitions.map((definition) => [definition.id, definition]), + ); + if ( + definitionById.get(propertyId)?.systemRole || + definitionById.get(targetPropertyId)?.systemRole + ) { + throw new Error("System properties cannot be reordered."); + } if (propertyId === targetPropertyId) { return { documentId, diff --git a/templates/content/actions/set-document-property.ts b/templates/content/actions/set-document-property.ts index bef4abdebb..f90e3fdb09 100644 --- a/templates/content/actions/set-document-property.ts +++ b/templates/content/actions/set-document-property.ts @@ -50,6 +50,9 @@ export default defineAction({ ), ); if (!definition) throw new Error(`Property "${propertyId}" not found`); + if (definition.systemRole) { + throw new Error("System properties are derived and cannot be edited."); + } const type = definition.type as DocumentPropertyType; if (isComputedPropertyType(type)) { diff --git a/templates/content/actions/split-mode-retirement.test.ts b/templates/content/actions/split-mode-retirement.test.ts index a7ac148800..197558f0ce 100644 --- a/templates/content/actions/split-mode-retirement.test.ts +++ b/templates/content/actions/split-mode-retirement.test.ts @@ -22,6 +22,7 @@ describe("Content split-mode retirement", () => { for (const file of canonicalDocumentActions) { const source = await fs.readFile(new URL(file, import.meta.url), "utf8"); expect(source, file).not.toContain("AGENT_NATIVE_MODE"); + expect(source, file).not.toContain("AGENT_NATIVE_DATA_MODE"); expect(source, file).not.toContain("isContentLocalFileMode"); expect(source, file).not.toContain("LocalFileDocument"); } @@ -33,6 +34,7 @@ describe("Content split-mode retirement", () => { "utf8", ); expect(source).not.toContain("AGENT_NATIVE_MODE"); + expect(source).not.toContain("AGENT_NATIVE_DATA_MODE"); expect(source).not.toContain("isAgentNativeLocalFileMode"); }); }); diff --git a/templates/content/actions/submit-content-database-form.db.test.ts b/templates/content/actions/submit-content-database-form.db.test.ts index 2850ddb744..dfd79cd8b6 100644 --- a/templates/content/actions/submit-content-database-form.db.test.ts +++ b/templates/content/actions/submit-content-database-form.db.test.ts @@ -303,6 +303,79 @@ describe("submit-content-database-form", () => { ).rejects.toThrow('Unknown option "Extremely urgent"'); }); + it("excludes system properties from form questions and rejects supplied values", async () => { + const seeded = await seedFormDatabase(); + const systemPropertyId = `system_kind_${Date.now()}`; + const db = getDb(); + const [database] = await db + .select() + .from(schema.contentDatabases) + .where(eq(schema.contentDatabases.id, seeded.databaseId)); + const viewConfig = JSON.parse(database.viewConfigJson); + viewConfig.views[0].formQuestions.push({ + key: systemPropertyId, + enabled: true, + required: true, + }); + await db + .update(schema.contentDatabases) + .set({ viewConfigJson: JSON.stringify(viewConfig) }) + .where(eq(schema.contentDatabases.id, seeded.databaseId)); + await db.insert(schema.documentPropertyDefinitions).values({ + id: systemPropertyId, + ownerEmail: OWNER, + databaseId: seeded.databaseId, + systemRole: "files_kind", + name: "Kind", + type: "select", + optionsJson: serializePropertyOptions({ + options: [{ id: "page", name: "Page", color: "gray" }], + }), + position: 5, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + await expect( + runWithRequestContext({ userEmail: OWNER }, () => + submitForm.run({ + databaseId: seeded.databaseId, + viewId: "request-form", + title: "System fields stay derived", + propertyValues: { + Description: "The configured required system question is ignored.", + Priority: "P1 — High", + }, + }), + ), + ).resolves.toMatchObject({ verified: true }); + const beforeRejectedSubmission = await db + .select() + .from(schema.contentDatabaseItems) + .where(eq(schema.contentDatabaseItems.databaseId, seeded.databaseId)); + await expect( + runWithRequestContext({ userEmail: OWNER }, () => + submitForm.run({ + databaseId: seeded.databaseId, + viewId: "request-form", + title: "Cannot override Kind", + propertyValues: { + Description: "This row must not be created.", + Priority: "P1 — High", + [systemPropertyId]: "page", + }, + }), + ), + ).rejects.toThrow('System property "Kind" cannot be submitted'); + const afterRejectedSubmission = await db + .select() + .from(schema.contentDatabaseItems) + .where(eq(schema.contentDatabaseItems.databaseId, seeded.databaseId)); + expect(afterRejectedSubmission).toHaveLength( + beforeRejectedSubmission.length, + ); + }); + it("rolls back the document and item when an in-transaction property write fails", async () => { const seeded = await seedFormDatabase(); const triggerName = `force_form_rollback_${Date.now()}`; diff --git a/templates/content/actions/submit-content-database-form.ts b/templates/content/actions/submit-content-database-form.ts index 0dd8f8772b..8fb0d97142 100644 --- a/templates/content/actions/submit-content-database-form.ts +++ b/templates/content/actions/submit-content-database-form.ts @@ -9,6 +9,7 @@ import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; import type { ContentDatabaseView, + DocumentPropertySystemRole, SubmitContentDatabaseFormResponse, } from "../shared/api.js"; import { contentDatabaseFormQuestions } from "../shared/database-form.js"; @@ -143,6 +144,11 @@ function resolveSubmittedProperties( } const definition = exact ?? named[0]; if (!definition) throw new Error(`Unknown form property "${inputKey}".`); + if (definition.systemRole) { + throw new Error( + `System property "${definition.name}" cannot be submitted.`, + ); + } if (!enabledPropertyIds.has(definition.id)) { throw new Error( `Property "${definition.name}" is not enabled in this form.`, @@ -235,6 +241,7 @@ export default defineAction({ definition: { id: definition.id, type: definition.type as DocumentPropertyType, + systemRole: definition.systemRole as DocumentPropertySystemRole | null, }, })); const questions = contentDatabaseFormQuestions(formView, properties); diff --git a/templates/content/app/components/editor/DocumentDatabase.test.ts b/templates/content/app/components/editor/DocumentDatabase.test.ts index b7f13381f3..a3a483f384 100644 --- a/templates/content/app/components/editor/DocumentDatabase.test.ts +++ b/templates/content/app/components/editor/DocumentDatabase.test.ts @@ -2519,6 +2519,52 @@ describe("database saved views", () => { }); }); + it("lets a personal view clear a shared filter without changing the shared view", () => { + const shared = normalizeClientDatabaseViewConfig({ + activeViewId: "default", + views: [ + { + id: "default", + name: "Files", + type: "sidebar", + sorts: [], + filters: [ + { + key: "files-kind", + label: "Kind", + operator: "does_not_equal", + value: "database_row", + }, + ], + filterMode: "and", + columnWidths: {}, + }, + ], + }); + const effective = applyPersonalDatabaseViewOverrides(shared, { + version: PERSONAL_DATABASE_VIEW_OVERRIDES_VERSION, + activeViewId: "default", + views: [ + { + id: "default", + sorts: [], + filters: [], + filterMode: "and", + }, + ], + }); + + expect(effective.views[0].filters).toEqual([]); + expect(shared.views[0].filters).toHaveLength(1); + expect( + activeDatabaseConstraintCount( + "", + effective.views[0].sorts, + effective.views[0].filters, + ), + ).toBe(0); + }); + it("applies personal query overrides without replacing saved view settings", () => { const savedViewConfig = normalizeClientDatabaseViewConfig({ activeViewId: "default", diff --git a/templates/content/app/components/editor/DocumentProperties.tsx b/templates/content/app/components/editor/DocumentProperties.tsx index 515ce9f958..d4b7ef0e4c 100644 --- a/templates/content/app/components/editor/DocumentProperties.tsx +++ b/templates/content/app/components/editor/DocumentProperties.tsx @@ -919,7 +919,7 @@ function PropertyRow({ return (
- {canEdit ? ( + {canEdit && !property.definition.systemRole ? ( { expect(databaseSearchExpansionIsPending("", 571, 100)).toBe(false); }); + it("expands filtered and OR-filtered views before evaluating them client-side", () => { + expect(databaseClientQueryExpandedItemLimit(true, 100, 102)).toBe(102); + expect(databaseClientQueryExpandedItemLimit(true, 100, 10_000)).toBe(5_000); + expect(databaseClientQueryExpansionIsPending(true, 102, 100)).toBe(true); + expect(databaseClientQueryExpansionIsPending(true, 102, 102)).toBe(false); + }); + + it("restores the manual page limit after personal filters are cleared", () => { + const manualLimitAfterLoadMore = 200; + const expandedLimit = databaseClientQueryExpandedItemLimit( + true, + manualLimitAfterLoadMore, + 4_000, + ); + expect(expandedLimit).toBe(4_000); + expect( + databaseClientQueryExpandedItemLimit( + false, + manualLimitAfterLoadMore, + 4_000, + ), + ).toBe(200); + expect( + databaseClientQueryExpansionIsPending(false, expandedLimit, 200), + ).toBe(false); + }); + it("builds an immediate preview item when the appended row is outside the returned page", () => { const response = { database: { id: "database" }, diff --git a/templates/content/app/components/editor/database/DatabaseView.tsx b/templates/content/app/components/editor/database/DatabaseView.tsx index f9cdcd92c3..bf3bea0bd0 100644 --- a/templates/content/app/components/editor/database/DatabaseView.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.tsx @@ -296,6 +296,26 @@ export function databaseSearchExpansionIsPending( return !!searchQuery.trim() && responseLimit < requestedLimit; } +export function databaseClientQueryExpandedItemLimit( + requiresCompleteDataset: boolean, + currentLimit: number, + totalItemCount: number, +) { + if (!requiresCompleteDataset) return currentLimit; + return Math.max( + currentLimit, + Math.min(totalItemCount, CONTENT_DATABASE_MAX_ITEM_LIMIT), + ); +} + +export function databaseClientQueryExpansionIsPending( + requiresCompleteDataset: boolean, + requestedLimit: number, + responseLimit: number, +) { + return requiresCompleteDataset && responseLimit < requestedLimit; +} + export type SortDirection = ContentDatabaseSortDirection; export type DatabaseSort = ContentDatabaseSort; export type FilterOperator = ContentDatabaseFilterOperator; @@ -685,10 +705,13 @@ function DatabaseTable({ SELECTED_CONTENT_SPACE_STORAGE_KEY, null, ); - const [databaseItemLimit, setDatabaseItemLimit] = useState( + const [manualDatabaseItemLimit, setManualDatabaseItemLimit] = useState( + CONTENT_DATABASE_PAGE_SIZE, + ); + const [databaseRequestItemLimit, setDatabaseRequestItemLimit] = useState( CONTENT_DATABASE_PAGE_SIZE, ); - const database = useContentDatabase(document.id, databaseItemLimit); + const database = useContentDatabase(document.id, databaseRequestItemLimit); const addItem = useAddDatabaseItem(document.id); const createContentSpace = useCreateContentSpace(); const workspaceCreateRequestIdRef = useRef(null); @@ -721,9 +744,9 @@ function DatabaseTable({ const totalItemCount = data?.pagination?.totalItems ?? items.length; const hasMoreItems = data?.pagination?.hasMore === true && - databaseItemLimit < CONTENT_DATABASE_MAX_ITEM_LIMIT; + databaseRequestItemLimit < CONTENT_DATABASE_MAX_ITEM_LIMIT; const isLoadingMoreItems = - database.isFetching && data?.pagination?.limit !== databaseItemLimit; + database.isFetching && data?.pagination?.limit !== databaseRequestItemLimit; const databaseId = data?.database.id ?? expectedDatabaseId; const personalView = useContentDatabasePersonalView(databaseId); const updatePersonalView = useUpdateContentDatabasePersonalView(databaseId); @@ -749,18 +772,6 @@ function DatabaseTable({ >(null); const [searchOpen, setSearchOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); - const searchExpandedItemLimit = databaseSearchExpandedItemLimit( - searchQuery, - databaseItemLimit, - totalItemCount, - ); - const isSearchExpansionPending = databaseSearchExpansionIsPending( - searchQuery, - searchExpandedItemLimit, - data?.pagination?.limit ?? items.length, - ); - const isDatabaseViewLoading = - isDatabaseInitialLoading || isSearchExpansionPending; const [settingsOpen, setSettingsOpen] = useState(false); const [inlineFilterControlsOpen, setInlineFilterControlsOpen] = useState(false); @@ -776,10 +787,6 @@ function DatabaseTable({ ); const [builderReviewOpen, setBuilderReviewOpen] = useState(false); - useEffect(() => { - if (searchExpandedItemLimit === databaseItemLimit) return; - setDatabaseItemLimit(searchExpandedItemLimit); - }, [databaseItemLimit, searchExpandedItemLimit]); const [builderReviewSourceId, setBuilderReviewSourceId] = useState< string | null >(null); @@ -908,6 +915,27 @@ function DatabaseTable({ sorts, filters, ); + const requiresCompleteClientDataset = + activeConstraintCount > 0 || + !!databaseGroupProperty || + activeView.type === "calendar" || + activeView.type === "timeline"; + const clientQueryExpandedItemLimit = databaseClientQueryExpandedItemLimit( + requiresCompleteClientDataset, + manualDatabaseItemLimit, + totalItemCount, + ); + const isClientQueryExpansionPending = databaseClientQueryExpansionIsPending( + requiresCompleteClientDataset, + clientQueryExpandedItemLimit, + data?.pagination?.limit ?? items.length, + ); + const isDatabaseViewLoading = + isDatabaseInitialLoading || isClientQueryExpansionPending; + useEffect(() => { + if (clientQueryExpandedItemLimit === databaseRequestItemLimit) return; + setDatabaseRequestItemLimit(clientQueryExpandedItemLimit); + }, [clientQueryExpandedItemLimit, databaseRequestItemLimit]); const rowsAreManuallyOrdered = !searchQuery.trim() && sorts.length === 0 && @@ -2707,7 +2735,7 @@ function DatabaseTable({ /> )} - {hasMoreItems && !isSearchExpansionPending ? ( + {hasMoreItems && !isClientQueryExpansionPending ? (
+ ) : null} @@ -458,7 +543,6 @@ function DatabaseSidebarRow({ @@ -573,3 +657,44 @@ function DatabaseSidebarRow({ export function databaseSidebarRows(groups: DatabaseBoardGroup[]) { return groups.flatMap((group) => group.items); } + +export interface DatabaseSidebarItemTreeNode { + item: ContentDatabaseItem; + children: DatabaseSidebarItemTreeNode[]; +} + +export function databaseSidebarItemTree( + rootItems: ContentDatabaseItem[], + allItems: ContentDatabaseItem[], +): DatabaseSidebarItemTreeNode[] { + const childrenByParentId = new Map(); + for (const item of allItems) { + const parentId = item.document.parentId; + if (!parentId) continue; + childrenByParentId.set(parentId, [ + ...(childrenByParentId.get(parentId) ?? []), + item, + ]); + } + const emitted = new Set(); + const visit = ( + item: ContentDatabaseItem, + ancestors: Set, + ): DatabaseSidebarItemTreeNode | null => { + const documentId = item.document.id; + if (emitted.has(documentId) || ancestors.has(documentId)) return null; + emitted.add(documentId); + const nextAncestors = new Set(ancestors).add(documentId); + return { + item, + children: (childrenByParentId.get(documentId) ?? []).flatMap((child) => { + const node = visit(child, nextAncestors); + return node ? [node] : []; + }), + }; + }; + return rootItems.flatMap((item) => { + const node = visit(item, new Set()); + return node ? [node] : []; + }); +} diff --git a/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts b/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts index c96100e81b..b9ee091268 100644 --- a/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts +++ b/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts @@ -317,7 +317,10 @@ describe("document sidebar layout", () => { expect(treeItem).toContain("export function FavoriteDocumentItem"); expect(sidebar).toContain("items-center ps-1 text-[10px]"); expect(treeItem).toContain('paddingInlineStart: "26px"'); - expect(treeItem).toContain('"border-primary bg-accent font-medium'); + expect(treeItem).toContain('? "font-semibold text-foreground"'); + expect(treeItem).not.toContain( + '"border-primary bg-accent font-medium text-accent-foreground"', + ); expect(treeItem).toContain("Remove from favorites"); expect(treeItem).toContain("aria-label={`Open ${title}`}"); expect(treeItem).toContain("onClick={(event) => event.stopPropagation()}"); diff --git a/templates/content/app/components/sidebar/DocumentTreeItem.tsx b/templates/content/app/components/sidebar/DocumentTreeItem.tsx index a91c67f4de..17f07a7d35 100644 --- a/templates/content/app/components/sidebar/DocumentTreeItem.tsx +++ b/templates/content/app/components/sidebar/DocumentTreeItem.tsx @@ -120,10 +120,10 @@ export function FavoriteDocumentItem({ return (
{ }); }); +describe("optimistic document titles", () => { + it("renames matching sidebar documents and Files rows immediately", () => { + const list = [doc("a", null), doc("b", null)]; + const database = { + items: [ + { + id: "item-a", + databaseId: "files", + position: 0, + document: doc("a", null), + properties: [], + }, + ], + } as any; + + expect( + patchDocumentInListDocumentsCache(list, "a", { title: "Page one" }), + ).toEqual([{ ...doc("a", null), title: "Page one" }, doc("b", null)]); + expect( + patchDocumentInDatabaseCache(database, "a", { title: "Page one" }) + ?.items[0].document.title, + ).toBe("Page one"); + expect(database.items[0].document.title).toBe("a"); + }); + + it("updates every sidebar-facing cache before the save round trip", () => { + const queryClient = new QueryClient(); + const databaseKey = [ + "action", + "get-content-database", + { databaseId: "files" }, + ] as const; + queryClient.setQueryData(documentQueryKey("a"), doc("a", null)); + queryClient.setQueryData( + ["action", "list-documents", undefined], + [doc("a", null)], + ); + queryClient.setQueryData(databaseKey, { + items: [ + { + id: "item-a", + databaseId: "files", + position: 0, + document: doc("a", null), + properties: [], + }, + ], + }); + + patchDocumentCaches(queryClient, "a", { title: "Page one" }); + + expect( + queryClient.getQueryData(documentQueryKey("a"))?.title, + ).toBe("Page one"); + expect( + queryClient.getQueryData([ + "action", + "list-documents", + undefined, + ])?.[0].title, + ).toBe("Page one"); + expect( + queryClient.getQueryData(databaseKey)?.items[0].document.title, + ).toBe("Page one"); + }); +}); + describe("mergeDocumentIntoDocumentCache", () => { it("preserves fields that are only present on the get-document cache", () => { const updated = { diff --git a/templates/content/app/hooks/use-documents.ts b/templates/content/app/hooks/use-documents.ts index 9de0391cd6..e7c3a2b958 100644 --- a/templates/content/app/hooks/use-documents.ts +++ b/templates/content/app/hooks/use-documents.ts @@ -25,7 +25,11 @@ import { useRestoreContentDatabase } from "./use-content-database"; export type { DocumentUpdateConflictResponse }; -const LIST_DOCUMENTS_QUERY_KEY = ["action", "list-documents", undefined]; +export const LIST_DOCUMENTS_QUERY_KEY = [ + "action", + "list-documents", + undefined, +] as const; export function documentQueryKey(documentId: string) { return ["action", "get-document", { id: documentId }] as const; @@ -68,10 +72,18 @@ export function mergeDocumentIntoDocumentCache( export function mergeDocumentIntoListDocumentsCache( old: unknown, document: Document, +) { + return patchDocumentInListDocumentsCache(old, document.id, document); +} + +export function patchDocumentInListDocumentsCache( + old: unknown, + documentId: string, + patch: Partial, ) { if (Array.isArray(old)) { return old.map((item: Document) => - item.id === document.id ? { ...item, ...document } : item, + item.id === documentId ? { ...item, ...patch } : item, ); } @@ -80,7 +92,7 @@ export function mergeDocumentIntoListDocumentsCache( if (!Array.isArray(cached.documents)) return old; const nextDocuments = cached.documents.map((item: Document) => - item.id === document.id ? { ...item, ...document } : item, + item.id === documentId ? { ...item, ...patch } : item, ); return { ...(old as object), documents: nextDocuments }; @@ -91,28 +103,13 @@ export function setDocumentFavoriteInListCache( documentId: string, isFavorite: boolean, ) { - if (Array.isArray(old)) { - return old.map((item: Document) => - item.id === documentId ? { ...item, isFavorite } : item, - ); - } - - if (!old || typeof old !== "object") return old; - const cached = old as { documents?: unknown }; - if (!Array.isArray(cached.documents)) return old; - - return { - ...(old as object), - documents: cached.documents.map((item: Document) => - item.id === documentId ? { ...item, isFavorite } : item, - ), - }; + return patchDocumentInListDocumentsCache(old, documentId, { isFavorite }); } -export function setDocumentFavoriteInDatabaseCache( +export function patchDocumentInDatabaseCache( current: ContentDatabaseResponse | undefined, documentId: string, - isFavorite: boolean, + patch: Partial, ): ContentDatabaseResponse | undefined { if (!current) return current; let changed = false; @@ -121,12 +118,37 @@ export function setDocumentFavoriteInDatabaseCache( changed = true; return { ...item, - document: { ...item.document, isFavorite }, + document: { ...item.document, ...patch }, }; }); return changed ? { ...current, items } : current; } +export function setDocumentFavoriteInDatabaseCache( + current: ContentDatabaseResponse | undefined, + documentId: string, + isFavorite: boolean, +): ContentDatabaseResponse | undefined { + return patchDocumentInDatabaseCache(current, documentId, { isFavorite }); +} + +export function patchDocumentCaches( + queryClient: Pick, + documentId: string, + patch: Partial, +) { + queryClient.setQueryData(documentQueryKey(documentId), (old: unknown) => + old && typeof old === "object" ? { ...old, ...patch } : old, + ); + queryClient.setQueryData(LIST_DOCUMENTS_QUERY_KEY, (old: unknown) => + patchDocumentInListDocumentsCache(old, documentId, patch), + ); + queryClient.setQueriesData( + { queryKey: ["action", "get-content-database"] }, + (current) => patchDocumentInDatabaseCache(current, documentId, patch), + ); +} + export function restoreQuerySnapshots( queryClient: Pick, snapshots: Array<[readonly unknown[], unknown]>, @@ -253,7 +275,14 @@ export function useUpdateDocument() { "update-document", { onMutate: async (variables) => { - if (variables.isFavorite === undefined) return undefined; + const optimisticPatch: Partial = { + ...(variables.title !== undefined ? { title: variables.title } : {}), + ...(variables.icon !== undefined ? { icon: variables.icon } : {}), + ...(variables.isFavorite !== undefined + ? { isFavorite: variables.isFavorite } + : {}), + }; + if (Object.keys(optimisticPatch).length === 0) return undefined; const documentKey = documentQueryKey(variables.id); const databaseFilter = { @@ -276,32 +305,11 @@ export function useUpdateDocument() { ), ]; - queryClient.setQueryData(documentKey, (old: unknown) => - old && typeof old === "object" - ? { ...old, isFavorite: variables.isFavorite } - : old, - ); - queryClient.setQueryData(LIST_DOCUMENTS_QUERY_KEY, (old: unknown) => - setDocumentFavoriteInListCache( - old, - variables.id, - variables.isFavorite!, - ), - ); - queryClient.setQueriesData( - databaseFilter, - (current) => - setDocumentFavoriteInDatabaseCache( - current, - variables.id, - variables.isFavorite!, - ), - ); + patchDocumentCaches(queryClient, variables.id, optimisticPatch); return { previous }; }, onError: (_error, variables, context) => { - if (variables.isFavorite === undefined) return; const rollback = context as | { previous?: Array<[readonly unknown[], unknown]> } | undefined; @@ -323,17 +331,15 @@ export function useUpdateDocument() { queryClient.setQueryData(LIST_DOCUMENTS_QUERY_KEY, (old: unknown) => mergeDocumentIntoListDocumentsCache(old, serverDocument), ); - if (variables.isFavorite !== undefined) { - queryClient.setQueriesData( - { queryKey: ["action", "get-content-database"] }, - (current) => - setDocumentFavoriteInDatabaseCache( - current, - variables.id, - serverDocument.isFavorite, - ), - ); - } + queryClient.setQueriesData( + { queryKey: ["action", "get-content-database"] }, + (current) => + patchDocumentInDatabaseCache( + current, + variables.id, + serverDocument, + ), + ); queryClient.invalidateQueries({ queryKey: ["action", "get-document", { id: variables.id }], }); @@ -350,17 +356,11 @@ export function useUpdateDocument() { queryClient.setQueryData(LIST_DOCUMENTS_QUERY_KEY, (old: unknown) => mergeDocumentIntoListDocumentsCache(old, data), ); - if (variables.isFavorite !== undefined) { - queryClient.setQueriesData( - { queryKey: ["action", "get-content-database"] }, - (current) => - setDocumentFavoriteInDatabaseCache( - current, - variables.id, - data.isFavorite, - ), - ); - } + queryClient.setQueriesData( + { queryKey: ["action", "get-content-database"] }, + (current) => + patchDocumentInDatabaseCache(current, variables.id, data), + ); queryClient.invalidateQueries({ queryKey: ["action", "get-document", { id: variables.id }], }); diff --git a/templates/content/changelog/2026-07-19-workspace-file-databases-now-use-the-workspace-name-open-as-.md b/templates/content/changelog/2026-07-19-workspace-file-databases-now-use-the-workspace-name-open-as-.md index 8fdc00dd64..6aa4f02025 100644 --- a/templates/content/changelog/2026-07-19-workspace-file-databases-now-use-the-workspace-name-open-as-.md +++ b/templates/content/changelog/2026-07-19-workspace-file-databases-now-use-the-workspace-name-open-as-.md @@ -3,4 +3,4 @@ type: improved date: 2026-07-19 --- -Workspace file databases now use the workspace name, open as tables, show top-level pages by parent, truncate long workspace names, and provide working page actions in the sidebar. +Workspace file databases now use the workspace name, open as tables, nest child pages beneath their parents, rename sidebar rows immediately, truncate long workspace names, and provide working page actions. From 65a6eb5cfadb7df301b5b986f08d3b674f8bb932 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:15:54 -0400 Subject: [PATCH 51/76] fix(content): align nested sidebar page icons --- .../app/components/editor/database/sidebar.test.tsx | 7 +++++++ .../content/app/components/editor/database/sidebar.tsx | 7 ++++++- ...e-file-databases-now-use-the-workspace-name-open-as-.md | 2 +- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/templates/content/app/components/editor/database/sidebar.test.tsx b/templates/content/app/components/editor/database/sidebar.test.tsx index 964ce60502..56910f061e 100644 --- a/templates/content/app/components/editor/database/sidebar.test.tsx +++ b/templates/content/app/components/editor/database/sidebar.test.tsx @@ -13,6 +13,7 @@ import { ContentFilesSidebarView, DatabaseSidebarView, databaseSidebarItemTree, + databaseSidebarRowIndent, databaseSidebarRows, } from "./sidebar"; import type { DatabaseBoardGroup } from "./types"; @@ -38,6 +39,12 @@ const item = (id: string, title: string, parentId: string | null = null) => }) as ContentDatabaseItem; describe("DatabaseSidebarView", () => { + it("aligns a leaf child icon with its parent page icon", () => { + expect(databaseSidebarRowIndent(2, false)).toBe( + databaseSidebarRowIndent(1, true), + ); + }); + it("keeps grouped rows in their filtered and sorted group order", () => { const groups = [ { id: "todo", label: "Todo", items: [item("first", "First")] }, diff --git a/templates/content/app/components/editor/database/sidebar.tsx b/templates/content/app/components/editor/database/sidebar.tsx index 209e590b96..e56926c8d0 100644 --- a/templates/content/app/components/editor/database/sidebar.tsx +++ b/templates/content/app/components/editor/database/sidebar.tsx @@ -528,7 +528,7 @@ function DatabaseSidebarRow({ active && "font-semibold text-foreground", )} style={{ - paddingInlineStart: `${depth * 18 + (hasChildren ? 28 : 6)}px`, + paddingInlineStart: `${databaseSidebarRowIndent(depth, hasChildren)}px`, }} onClick={handleClick} aria-current={active ? "page" : undefined} @@ -663,6 +663,11 @@ export interface DatabaseSidebarItemTreeNode { children: DatabaseSidebarItemTreeNode[]; } +export function databaseSidebarRowIndent(depth: number, hasChildren: boolean) { + if (hasChildren) return depth * 18 + 28; + return depth === 0 ? 6 : depth * 18 + 10; +} + export function databaseSidebarItemTree( rootItems: ContentDatabaseItem[], allItems: ContentDatabaseItem[], diff --git a/templates/content/changelog/2026-07-19-workspace-file-databases-now-use-the-workspace-name-open-as-.md b/templates/content/changelog/2026-07-19-workspace-file-databases-now-use-the-workspace-name-open-as-.md index 6aa4f02025..5863140cdc 100644 --- a/templates/content/changelog/2026-07-19-workspace-file-databases-now-use-the-workspace-name-open-as-.md +++ b/templates/content/changelog/2026-07-19-workspace-file-databases-now-use-the-workspace-name-open-as-.md @@ -3,4 +3,4 @@ type: improved date: 2026-07-19 --- -Workspace file databases now use the workspace name, open as tables, nest child pages beneath their parents, rename sidebar rows immediately, truncate long workspace names, and provide working page actions. +Workspace file databases now use the workspace name, open as tables, nest and align child pages beneath their parents, rename sidebar rows immediately, truncate long workspace names, and provide working page actions. From 6d66b1ecff3bfe0011cc1c51fd19effb6da21411 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:21:45 -0400 Subject: [PATCH 52/76] fix(content): move workspace database into breadcrumbs --- .../editor/DocumentEditor.layout.test.ts | 54 ++++++++++++++++++ .../app/components/editor/DocumentEditor.tsx | 56 +++++++------------ ...ses-now-use-the-workspace-name-open-as-.md | 2 +- 3 files changed, 74 insertions(+), 38 deletions(-) diff --git a/templates/content/app/components/editor/DocumentEditor.layout.test.ts b/templates/content/app/components/editor/DocumentEditor.layout.test.ts index 13512c94ff..d2eed85ac6 100644 --- a/templates/content/app/components/editor/DocumentEditor.layout.test.ts +++ b/templates/content/app/components/editor/DocumentEditor.layout.test.ts @@ -382,4 +382,58 @@ describe("document editor layout", () => { }), ).toBe("Untitled database"); }); + + it("starts page breadcrumbs with the containing database", () => { + expect( + documentEditorBreadcrumbItems( + { + id: "draft", + parentId: "project", + title: "Draft", + icon: null, + databaseMembership: { + databaseId: "database", + databaseDocumentId: "database-page", + databaseTitle: "Personal", + position: 0, + }, + }, + [ + { + id: "project", + parentId: null, + title: "Project", + icon: null, + }, + ], + ).map((item) => item.title), + ).toEqual(["Personal", "Project", "Draft"]); + }); + + it("does not repeat a containing database already in the page ancestry", () => { + expect( + documentEditorBreadcrumbItems( + { + id: "draft", + parentId: "database-page", + title: "Draft", + icon: null, + databaseMembership: { + databaseId: "database", + databaseDocumentId: "database-page", + databaseTitle: "Personal", + position: 0, + }, + }, + [ + { + id: "database-page", + parentId: null, + title: "Personal", + icon: null, + }, + ], + ).map((item) => item.title), + ).toEqual(["Personal", "Draft"]); + }); }); diff --git a/templates/content/app/components/editor/DocumentEditor.tsx b/templates/content/app/components/editor/DocumentEditor.tsx index b70cb5dca8..c3b0ffb26d 100644 --- a/templates/content/app/components/editor/DocumentEditor.tsx +++ b/templates/content/app/components/editor/DocumentEditor.tsx @@ -15,7 +15,6 @@ import { import { useT } from "@agent-native/core/client/i18n"; import type { Document, DocumentSyncStatus } from "@shared/api"; import { - IconArrowLeft, IconDatabase, IconFileText, IconLoader2, @@ -266,7 +265,10 @@ export function databaseMembershipDatabaseTitle( } export function documentEditorBreadcrumbItems( - document: Pick, // i18n-ignore type expression + document: Pick< + Document, + "id" | "parentId" | "title" | "icon" | "databaseMembership" + >, // i18n-ignore type expression documents: Pick[], // i18n-ignore type expression ) { const byId = new Map(documents.map((doc) => [doc.id, doc])); @@ -287,7 +289,7 @@ export function documentEditorBreadcrumbItems( parentId = parent.parentId; } - return [ + const pageItems = [ ...parents, { id: document.id, @@ -295,34 +297,22 @@ export function documentEditorBreadcrumbItems( icon: document.icon, }, ]; -} - -function DatabaseMembershipBreadcrumb({ - document, - onOpenDatabase, -}: { - document: Document; - onOpenDatabase: (databaseDocumentId: string) => void; -}) { const membership = document.databaseMembership; - if (!membership) return null; - - const databaseTitle = databaseMembershipDatabaseTitle(membership); + if ( + !membership || + pageItems.some((item) => item.id === membership.databaseDocumentId) + ) { + return pageItems; + } - return ( -
- -
- ); + return [ + { + id: membership.databaseDocumentId, + title: databaseMembershipDatabaseTitle(membership), + icon: null, + }, + ...pageItems, + ]; } function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) { @@ -1466,14 +1456,6 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) { Boolean(document.database), )} > - - navigate(`/page/${databaseDocumentId}`, { - flushSync: true, - }) - } - /> {document.icon || !isDatabasePage ? (
{editorCanEdit ? ( diff --git a/templates/content/changelog/2026-07-19-workspace-file-databases-now-use-the-workspace-name-open-as-.md b/templates/content/changelog/2026-07-19-workspace-file-databases-now-use-the-workspace-name-open-as-.md index 5863140cdc..4e0ac7f730 100644 --- a/templates/content/changelog/2026-07-19-workspace-file-databases-now-use-the-workspace-name-open-as-.md +++ b/templates/content/changelog/2026-07-19-workspace-file-databases-now-use-the-workspace-name-open-as-.md @@ -3,4 +3,4 @@ type: improved date: 2026-07-19 --- -Workspace file databases now use the workspace name, open as tables, nest and align child pages beneath their parents, rename sidebar rows immediately, truncate long workspace names, and provide working page actions. +Workspace file databases now use the workspace name, open as tables, appear at the start of page breadcrumbs, nest and align child pages beneath their parents, rename sidebar rows immediately, truncate long workspace names, and provide working page actions. From a7d0f02ee372415de30cc985591c743197feb577 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:29:21 -0400 Subject: [PATCH 53/76] fix(content): clarify local folder host support --- .../content/app/i18n-data.local-files.test.ts | 25 ++++++++++ templates/content/app/i18n-data.ts | 46 +++++++++++-------- templates/content/app/i18n/zh-TW.ts | 5 +- ...n-no-longer-invokes-the-unsafe-native-p.md | 2 +- 4 files changed, 55 insertions(+), 23 deletions(-) create mode 100644 templates/content/app/i18n-data.local-files.test.ts diff --git a/templates/content/app/i18n-data.local-files.test.ts b/templates/content/app/i18n-data.local-files.test.ts new file mode 100644 index 0000000000..bf14eb8162 --- /dev/null +++ b/templates/content/app/i18n-data.local-files.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; + +import { messagesByLocale } from "./i18n-data"; + +describe("local folder host guidance", () => { + it("directs every unsupported host to Agent Native Desktop", () => { + for (const messages of Object.values(messagesByLocale)) { + expect(messages.localFiles.unsupportedElectron).toContain( + "Agent Native Desktop", + ); + expect(messages.localFiles.unsupportedBrowser).toContain( + "Agent Native Desktop", + ); + } + }); + + it("does not claim an ordinary browser is currently supported", () => { + expect(messagesByLocale["en-US"].localFiles.unsupportedElectron).toBe( + "Local folder sync is unavailable here. Open this page in Agent Native Desktop. Browser folder access is not enabled yet.", + ); + expect(messagesByLocale["en-US"].localFiles.unsupportedBrowser).toBe( + messagesByLocale["en-US"].localFiles.unsupportedElectron, + ); + }); +}); diff --git a/templates/content/app/i18n-data.ts b/templates/content/app/i18n-data.ts index 65c85345d4..4c4ff803a7 100644 --- a/templates/content/app/i18n-data.ts +++ b/templates/content/app/i18n-data.ts @@ -2792,8 +2792,9 @@ const editorSlashMessages = { const localFilesMessages = { unsupportedElectron: - "Local folder sync is unavailable in this Electron browser. Use Agent Native Desktop or a browser with folder access.", - unsupportedBrowser: "Folder access is unavailable in this browser.", + "Local folder sync is unavailable here. Open this page in Agent Native Desktop. Browser folder access is not enabled yet.", + unsupportedBrowser: + "Local folder sync is unavailable here. Open this page in Agent Native Desktop. Browser folder access is not enabled yet.", notSyncedYet: "Not synced yet", summaryCreated: "{{count}} created", summaryUpdated: "{{count}} updated", @@ -3935,8 +3936,9 @@ const rawLiteralLocaleMessages: Partial> = { const localFilesMessagesByLocale = { "zh-CN": { unsupportedElectron: - "此 Electron 浏览器不支持本地文件夹同步。请使用 Agent Native Desktop 或支持文件夹访问的浏览器。", - unsupportedBrowser: "此浏览器不支持文件夹访问。", + "此处无法使用本地文件夹同步。请在 Agent Native Desktop 中打开此页面。浏览器文件夹访问功能尚未启用。", + unsupportedBrowser: + "此处无法使用本地文件夹同步。请在 Agent Native Desktop 中打开此页面。浏览器文件夹访问功能尚未启用。", notSyncedYet: "尚未同步", summaryCreated: "已创建 {{count}} 个", summaryUpdated: "已更新 {{count}} 个", @@ -3980,9 +3982,9 @@ const localFilesMessagesByLocale = { }, "es-ES": { unsupportedElectron: - "La sincronización de carpetas locales no está disponible en este navegador Electron. Usa Agent Native Desktop o un navegador con acceso a carpetas.", + "La sincronización de carpetas locales no está disponible aquí. Abre esta página en Agent Native Desktop. El acceso a carpetas desde el navegador aún no está habilitado.", unsupportedBrowser: - "El acceso a carpetas no está disponible en este navegador.", + "La sincronización de carpetas locales no está disponible aquí. Abre esta página en Agent Native Desktop. El acceso a carpetas desde el navegador aún no está habilitado.", notSyncedYet: "Aún no sincronizado", summaryCreated: "{{count}} creados", summaryUpdated: "{{count}} actualizados", @@ -4028,9 +4030,9 @@ const localFilesMessagesByLocale = { }, "fr-FR": { unsupportedElectron: - "La synchronisation des dossiers locaux n’est pas disponible dans ce navigateur Electron. Utilisez Agent Native Desktop ou un navigateur avec accès aux dossiers.", + "La synchronisation des dossiers locaux n’est pas disponible ici. Ouvrez cette page dans Agent Native Desktop. L’accès aux dossiers depuis le navigateur n’est pas encore activé.", unsupportedBrowser: - "L’accès aux dossiers n’est pas disponible dans ce navigateur.", + "La synchronisation des dossiers locaux n’est pas disponible ici. Ouvrez cette page dans Agent Native Desktop. L’accès aux dossiers depuis le navigateur n’est pas encore activé.", notSyncedYet: "Pas encore synchronisé", summaryCreated: "{{count}} créés", summaryUpdated: "{{count}} mis à jour", @@ -4077,8 +4079,9 @@ const localFilesMessagesByLocale = { }, "de-DE": { unsupportedElectron: - "Die lokale Ordnersynchronisierung ist in diesem Electron-Browser nicht verfügbar. Verwende Agent Native Desktop oder einen Browser mit Ordnerzugriff.", - unsupportedBrowser: "Ordnerzugriff ist in diesem Browser nicht verfügbar.", + "Die lokale Ordnersynchronisierung ist hier nicht verfügbar. Öffne diese Seite in Agent Native Desktop. Der Browserzugriff auf Ordner ist noch nicht aktiviert.", + unsupportedBrowser: + "Die lokale Ordnersynchronisierung ist hier nicht verfügbar. Öffne diese Seite in Agent Native Desktop. Der Browserzugriff auf Ordner ist noch nicht aktiviert.", notSyncedYet: "Noch nicht synchronisiert", summaryCreated: "{{count}} erstellt", summaryUpdated: "{{count}} aktualisiert", @@ -4123,9 +4126,9 @@ const localFilesMessagesByLocale = { }, "ja-JP": { unsupportedElectron: - "この Electron ブラウザーではローカルフォルダー同期を利用できません。Agent Native Desktop またはフォルダーアクセス対応ブラウザーを使用してください。", + "ここではローカルフォルダー同期を利用できません。このページを Agent Native Desktop で開いてください。ブラウザーからのフォルダーアクセスはまだ有効になっていません。", unsupportedBrowser: - "このブラウザーではフォルダーアクセスを利用できません。", + "ここではローカルフォルダー同期を利用できません。このページを Agent Native Desktop で開いてください。ブラウザーからのフォルダーアクセスはまだ有効になっていません。", notSyncedYet: "まだ同期されていません", summaryCreated: "{{count}} 件作成", summaryUpdated: "{{count}} 件更新", @@ -4171,8 +4174,9 @@ const localFilesMessagesByLocale = { }, "ko-KR": { unsupportedElectron: - "이 Electron 브라우저에서는 로컬 폴더 동기화를 사용할 수 없습니다. Agent Native Desktop 또는 폴더 접근을 지원하는 브라우저를 사용하세요.", - unsupportedBrowser: "이 브라우저에서는 폴더 접근을 사용할 수 없습니다.", + "여기서는 로컬 폴더 동기화를 사용할 수 없습니다. Agent Native Desktop에서 이 페이지를 여세요. 브라우저 폴더 접근은 아직 활성화되지 않았습니다.", + unsupportedBrowser: + "여기서는 로컬 폴더 동기화를 사용할 수 없습니다. Agent Native Desktop에서 이 페이지를 여세요. 브라우저 폴더 접근은 아직 활성화되지 않았습니다.", notSyncedYet: "아직 동기화되지 않음", summaryCreated: "{{count}}개 생성됨", summaryUpdated: "{{count}}개 업데이트됨", @@ -4218,9 +4222,9 @@ const localFilesMessagesByLocale = { }, "pt-BR": { unsupportedElectron: - "A sincronização de pastas locais não está disponível neste navegador Electron. Use o Agent Native Desktop ou um navegador com acesso a pastas.", + "A sincronização de pastas locais não está disponível aqui. Abra esta página no Agent Native Desktop. O acesso a pastas pelo navegador ainda não está ativado.", unsupportedBrowser: - "O acesso a pastas não está disponível neste navegador.", + "A sincronização de pastas locais não está disponível aqui. Abra esta página no Agent Native Desktop. O acesso a pastas pelo navegador ainda não está ativado.", notSyncedYet: "Ainda não sincronizado", summaryCreated: "{{count}} criados", summaryUpdated: "{{count}} atualizados", @@ -4266,8 +4270,9 @@ const localFilesMessagesByLocale = { }, "hi-IN": { unsupportedElectron: - "इस Electron ब्राउज़र में स्थानीय फ़ोल्डर सिंक उपलब्ध नहीं है। Agent Native Desktop या फ़ोल्डर एक्सेस वाले ब्राउज़र का उपयोग करें।", - unsupportedBrowser: "इस ब्राउज़र में फ़ोल्डर एक्सेस उपलब्ध नहीं है।", + "यहाँ स्थानीय फ़ोल्डर सिंक उपलब्ध नहीं है। इस पेज को Agent Native Desktop में खोलें। ब्राउज़र फ़ोल्डर एक्सेस अभी सक्षम नहीं है।", + unsupportedBrowser: + "यहाँ स्थानीय फ़ोल्डर सिंक उपलब्ध नहीं है। इस पेज को Agent Native Desktop में खोलें। ब्राउज़र फ़ोल्डर एक्सेस अभी सक्षम नहीं है।", notSyncedYet: "अभी सिंक नहीं हुआ", summaryCreated: "{{count}} बनाए गए", summaryUpdated: "{{count}} अपडेट किए गए", @@ -4312,8 +4317,9 @@ const localFilesMessagesByLocale = { }, "ar-SA": { unsupportedElectron: - "مزامنة المجلدات المحلية غير متاحة في متصفح Electron هذا. استخدم Agent Native Desktop أو متصفحا يدعم الوصول إلى المجلدات.", - unsupportedBrowser: "الوصول إلى المجلدات غير متاح في هذا المتصفح.", + "مزامنة المجلدات المحلية غير متاحة هنا. افتح هذه الصفحة في Agent Native Desktop. لم يتم تمكين الوصول إلى المجلدات من المتصفح بعد.", + unsupportedBrowser: + "مزامنة المجلدات المحلية غير متاحة هنا. افتح هذه الصفحة في Agent Native Desktop. لم يتم تمكين الوصول إلى المجلدات من المتصفح بعد.", notSyncedYet: "لم تتم المزامنة بعد", summaryCreated: "تم إنشاء {{count}}", summaryUpdated: "تم تحديث {{count}}", diff --git a/templates/content/app/i18n/zh-TW.ts b/templates/content/app/i18n/zh-TW.ts index 8206bda97d..9518cdfa58 100644 --- a/templates/content/app/i18n/zh-TW.ts +++ b/templates/content/app/i18n/zh-TW.ts @@ -1011,8 +1011,9 @@ const messages = { }, localFiles: { unsupportedElectron: - "此 Electron 瀏覽器不支援本機資料夾同步。請使用 Agent Native Desktop 或支援資料夾存取的瀏覽器。", - unsupportedBrowser: "此瀏覽器不支援資料夾存取。", + "此處無法使用本機資料夾同步。請在 Agent Native Desktop 中開啟此頁面。瀏覽器資料夾存取功能尚未啟用。", + unsupportedBrowser: + "此處無法使用本機資料夾同步。請在 Agent Native Desktop 中開啟此頁面。瀏覽器資料夾存取功能尚未啟用。", notSyncedYet: "尚未同步", summaryCreated: "已建立 {{count}} 個", summaryUpdated: "已更新 {{count}} 個", diff --git a/templates/content/changelog/2026-07-18-local-folder-selection-no-longer-invokes-the-unsafe-native-p.md b/templates/content/changelog/2026-07-18-local-folder-selection-no-longer-invokes-the-unsafe-native-p.md index b965d704d4..79c0edf8d2 100644 --- a/templates/content/changelog/2026-07-18-local-folder-selection-no-longer-invokes-the-unsafe-native-p.md +++ b/templates/content/changelog/2026-07-18-local-folder-selection-no-longer-invokes-the-unsafe-native-p.md @@ -3,4 +3,4 @@ type: fixed date: 2026-07-18 --- -Local folder selection no longer invokes the unsafe native picker in embedded Codex windows +Local folder selection no longer invokes the unsafe native picker in embedded Codex windows and now directs unsupported hosts to Agent Native Desktop. From df510e42e4fd9cd4ce7c367bd5ac5d3d1597ada0 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Sun, 19 Jul 2026 11:25:55 -0400 Subject: [PATCH 54/76] fix(content): restore Chromium folder access --- .../DatabaseView.local-folder.layout.test.ts | 8 ++- .../content/app/i18n-data.local-files.test.ts | 4 +- templates/content/app/i18n-data.ts | 40 +++++++------- templates/content/app/i18n/zh-TW.ts | 4 +- .../content/app/routes/_app.local-files.tsx | 10 +--- ...n-no-longer-invokes-the-unsafe-native-p.md | 2 +- templates/content/e2e/global-setup.ts | 5 +- templates/content/e2e/local-files.spec.ts | 53 ++++++------------- 8 files changed, 48 insertions(+), 78 deletions(-) diff --git a/templates/content/app/components/editor/database/DatabaseView.local-folder.layout.test.ts b/templates/content/app/components/editor/database/DatabaseView.local-folder.layout.test.ts index cefbce250c..e42b051640 100644 --- a/templates/content/app/components/editor/database/DatabaseView.local-folder.layout.test.ts +++ b/templates/content/app/components/editor/database/DatabaseView.local-folder.layout.test.ts @@ -29,16 +29,14 @@ describe("Files database local-folder source entry", () => { ); }); - it("requires an explicitly trusted native directory picker", () => { + it("blocks unsafe embedded hosts from the native directory picker", () => { const localFilesRoute = readFileSync( new URL("../../../routes/_app.local-files.tsx", import.meta.url), "utf8", ); - expect(localFilesRoute).toContain("__agentNativeSafeDirectoryPicker"); - expect(localFilesRoute).toMatch( - /__agentNativeSafeDirectoryPicker\s*!?==?\s*true/, - ); + expect(localFilesRoute).not.toContain("__agentNativeSafeDirectoryPicker"); + expect(localFilesRoute).toContain("showDirectoryPicker"); expect(localFilesRoute).toContain("isUnsafeNativeFolderPickerHost()"); }); }); diff --git a/templates/content/app/i18n-data.local-files.test.ts b/templates/content/app/i18n-data.local-files.test.ts index bf14eb8162..0bedc5e096 100644 --- a/templates/content/app/i18n-data.local-files.test.ts +++ b/templates/content/app/i18n-data.local-files.test.ts @@ -14,9 +14,9 @@ describe("local folder host guidance", () => { } }); - it("does not claim an ordinary browser is currently supported", () => { + it("names the supported Chromium browser path", () => { expect(messagesByLocale["en-US"].localFiles.unsupportedElectron).toBe( - "Local folder sync is unavailable here. Open this page in Agent Native Desktop. Browser folder access is not enabled yet.", + "Local folder sync is unavailable here. Use Agent Native Desktop, Chrome, Edge, or another Chromium browser.", ); expect(messagesByLocale["en-US"].localFiles.unsupportedBrowser).toBe( messagesByLocale["en-US"].localFiles.unsupportedElectron, diff --git a/templates/content/app/i18n-data.ts b/templates/content/app/i18n-data.ts index 4c4ff803a7..4f497cfe58 100644 --- a/templates/content/app/i18n-data.ts +++ b/templates/content/app/i18n-data.ts @@ -2792,9 +2792,9 @@ const editorSlashMessages = { const localFilesMessages = { unsupportedElectron: - "Local folder sync is unavailable here. Open this page in Agent Native Desktop. Browser folder access is not enabled yet.", + "Local folder sync is unavailable here. Use Agent Native Desktop, Chrome, Edge, or another Chromium browser.", unsupportedBrowser: - "Local folder sync is unavailable here. Open this page in Agent Native Desktop. Browser folder access is not enabled yet.", + "Local folder sync is unavailable here. Use Agent Native Desktop, Chrome, Edge, or another Chromium browser.", notSyncedYet: "Not synced yet", summaryCreated: "{{count}} created", summaryUpdated: "{{count}} updated", @@ -3936,9 +3936,9 @@ const rawLiteralLocaleMessages: Partial> = { const localFilesMessagesByLocale = { "zh-CN": { unsupportedElectron: - "此处无法使用本地文件夹同步。请在 Agent Native Desktop 中打开此页面。浏览器文件夹访问功能尚未启用。", + "此处无法使用本地文件夹同步。请使用 Agent Native Desktop、Chrome、Edge 或其他 Chromium 浏览器。", unsupportedBrowser: - "此处无法使用本地文件夹同步。请在 Agent Native Desktop 中打开此页面。浏览器文件夹访问功能尚未启用。", + "此处无法使用本地文件夹同步。请使用 Agent Native Desktop、Chrome、Edge 或其他 Chromium 浏览器。", notSyncedYet: "尚未同步", summaryCreated: "已创建 {{count}} 个", summaryUpdated: "已更新 {{count}} 个", @@ -3982,9 +3982,9 @@ const localFilesMessagesByLocale = { }, "es-ES": { unsupportedElectron: - "La sincronización de carpetas locales no está disponible aquí. Abre esta página en Agent Native Desktop. El acceso a carpetas desde el navegador aún no está habilitado.", + "La sincronización de carpetas locales no está disponible aquí. Usa Agent Native Desktop, Chrome, Edge u otro navegador Chromium.", unsupportedBrowser: - "La sincronización de carpetas locales no está disponible aquí. Abre esta página en Agent Native Desktop. El acceso a carpetas desde el navegador aún no está habilitado.", + "La sincronización de carpetas locales no está disponible aquí. Usa Agent Native Desktop, Chrome, Edge u otro navegador Chromium.", notSyncedYet: "Aún no sincronizado", summaryCreated: "{{count}} creados", summaryUpdated: "{{count}} actualizados", @@ -4030,9 +4030,9 @@ const localFilesMessagesByLocale = { }, "fr-FR": { unsupportedElectron: - "La synchronisation des dossiers locaux n’est pas disponible ici. Ouvrez cette page dans Agent Native Desktop. L’accès aux dossiers depuis le navigateur n’est pas encore activé.", + "La synchronisation des dossiers locaux n’est pas disponible ici. Utilisez Agent Native Desktop, Chrome, Edge ou un autre navigateur Chromium.", unsupportedBrowser: - "La synchronisation des dossiers locaux n’est pas disponible ici. Ouvrez cette page dans Agent Native Desktop. L’accès aux dossiers depuis le navigateur n’est pas encore activé.", + "La synchronisation des dossiers locaux n’est pas disponible ici. Utilisez Agent Native Desktop, Chrome, Edge ou un autre navigateur Chromium.", notSyncedYet: "Pas encore synchronisé", summaryCreated: "{{count}} créés", summaryUpdated: "{{count}} mis à jour", @@ -4079,9 +4079,9 @@ const localFilesMessagesByLocale = { }, "de-DE": { unsupportedElectron: - "Die lokale Ordnersynchronisierung ist hier nicht verfügbar. Öffne diese Seite in Agent Native Desktop. Der Browserzugriff auf Ordner ist noch nicht aktiviert.", + "Die lokale Ordnersynchronisierung ist hier nicht verfügbar. Verwende Agent Native Desktop, Chrome, Edge oder einen anderen Chromium-Browser.", unsupportedBrowser: - "Die lokale Ordnersynchronisierung ist hier nicht verfügbar. Öffne diese Seite in Agent Native Desktop. Der Browserzugriff auf Ordner ist noch nicht aktiviert.", + "Die lokale Ordnersynchronisierung ist hier nicht verfügbar. Verwende Agent Native Desktop, Chrome, Edge oder einen anderen Chromium-Browser.", notSyncedYet: "Noch nicht synchronisiert", summaryCreated: "{{count}} erstellt", summaryUpdated: "{{count}} aktualisiert", @@ -4126,9 +4126,9 @@ const localFilesMessagesByLocale = { }, "ja-JP": { unsupportedElectron: - "ここではローカルフォルダー同期を利用できません。このページを Agent Native Desktop で開いてください。ブラウザーからのフォルダーアクセスはまだ有効になっていません。", + "ここではローカルフォルダー同期を利用できません。Agent Native Desktop、Chrome、Edge、またはその他の Chromium ブラウザーを使用してください。", unsupportedBrowser: - "ここではローカルフォルダー同期を利用できません。このページを Agent Native Desktop で開いてください。ブラウザーからのフォルダーアクセスはまだ有効になっていません。", + "ここではローカルフォルダー同期を利用できません。Agent Native Desktop、Chrome、Edge、またはその他の Chromium ブラウザーを使用してください。", notSyncedYet: "まだ同期されていません", summaryCreated: "{{count}} 件作成", summaryUpdated: "{{count}} 件更新", @@ -4174,9 +4174,9 @@ const localFilesMessagesByLocale = { }, "ko-KR": { unsupportedElectron: - "여기서는 로컬 폴더 동기화를 사용할 수 없습니다. Agent Native Desktop에서 이 페이지를 여세요. 브라우저 폴더 접근은 아직 활성화되지 않았습니다.", + "여기서는 로컬 폴더 동기화를 사용할 수 없습니다. Agent Native Desktop, Chrome, Edge 또는 다른 Chromium 브라우저를 사용하세요.", unsupportedBrowser: - "여기서는 로컬 폴더 동기화를 사용할 수 없습니다. Agent Native Desktop에서 이 페이지를 여세요. 브라우저 폴더 접근은 아직 활성화되지 않았습니다.", + "여기서는 로컬 폴더 동기화를 사용할 수 없습니다. Agent Native Desktop, Chrome, Edge 또는 다른 Chromium 브라우저를 사용하세요.", notSyncedYet: "아직 동기화되지 않음", summaryCreated: "{{count}}개 생성됨", summaryUpdated: "{{count}}개 업데이트됨", @@ -4222,9 +4222,9 @@ const localFilesMessagesByLocale = { }, "pt-BR": { unsupportedElectron: - "A sincronização de pastas locais não está disponível aqui. Abra esta página no Agent Native Desktop. O acesso a pastas pelo navegador ainda não está ativado.", + "A sincronização de pastas locais não está disponível aqui. Use o Agent Native Desktop, Chrome, Edge ou outro navegador Chromium.", unsupportedBrowser: - "A sincronização de pastas locais não está disponível aqui. Abra esta página no Agent Native Desktop. O acesso a pastas pelo navegador ainda não está ativado.", + "A sincronização de pastas locais não está disponível aqui. Use o Agent Native Desktop, Chrome, Edge ou outro navegador Chromium.", notSyncedYet: "Ainda não sincronizado", summaryCreated: "{{count}} criados", summaryUpdated: "{{count}} atualizados", @@ -4270,9 +4270,9 @@ const localFilesMessagesByLocale = { }, "hi-IN": { unsupportedElectron: - "यहाँ स्थानीय फ़ोल्डर सिंक उपलब्ध नहीं है। इस पेज को Agent Native Desktop में खोलें। ब्राउज़र फ़ोल्डर एक्सेस अभी सक्षम नहीं है।", + "यहाँ स्थानीय फ़ोल्डर सिंक उपलब्ध नहीं है। Agent Native Desktop, Chrome, Edge या किसी अन्य Chromium ब्राउज़र का उपयोग करें।", unsupportedBrowser: - "यहाँ स्थानीय फ़ोल्डर सिंक उपलब्ध नहीं है। इस पेज को Agent Native Desktop में खोलें। ब्राउज़र फ़ोल्डर एक्सेस अभी सक्षम नहीं है।", + "यहाँ स्थानीय फ़ोल्डर सिंक उपलब्ध नहीं है। Agent Native Desktop, Chrome, Edge या किसी अन्य Chromium ब्राउज़र का उपयोग करें।", notSyncedYet: "अभी सिंक नहीं हुआ", summaryCreated: "{{count}} बनाए गए", summaryUpdated: "{{count}} अपडेट किए गए", @@ -4317,9 +4317,9 @@ const localFilesMessagesByLocale = { }, "ar-SA": { unsupportedElectron: - "مزامنة المجلدات المحلية غير متاحة هنا. افتح هذه الصفحة في Agent Native Desktop. لم يتم تمكين الوصول إلى المجلدات من المتصفح بعد.", + "مزامنة المجلدات المحلية غير متاحة هنا. استخدم Agent Native Desktop أو Chrome أو Edge أو متصفح Chromium آخر.", unsupportedBrowser: - "مزامنة المجلدات المحلية غير متاحة هنا. افتح هذه الصفحة في Agent Native Desktop. لم يتم تمكين الوصول إلى المجلدات من المتصفح بعد.", + "مزامنة المجلدات المحلية غير متاحة هنا. استخدم Agent Native Desktop أو Chrome أو Edge أو متصفح Chromium آخر.", notSyncedYet: "لم تتم المزامنة بعد", summaryCreated: "تم إنشاء {{count}}", summaryUpdated: "تم تحديث {{count}}", diff --git a/templates/content/app/i18n/zh-TW.ts b/templates/content/app/i18n/zh-TW.ts index 9518cdfa58..73cb81b538 100644 --- a/templates/content/app/i18n/zh-TW.ts +++ b/templates/content/app/i18n/zh-TW.ts @@ -1011,9 +1011,9 @@ const messages = { }, localFiles: { unsupportedElectron: - "此處無法使用本機資料夾同步。請在 Agent Native Desktop 中開啟此頁面。瀏覽器資料夾存取功能尚未啟用。", + "此處無法使用本機資料夾同步。請使用 Agent Native Desktop、Chrome、Edge 或其他 Chromium 瀏覽器。", unsupportedBrowser: - "此處無法使用本機資料夾同步。請在 Agent Native Desktop 中開啟此頁面。瀏覽器資料夾存取功能尚未啟用。", + "此處無法使用本機資料夾同步。請使用 Agent Native Desktop、Chrome、Edge 或其他 Chromium 瀏覽器。", notSyncedYet: "尚未同步", summaryCreated: "已建立 {{count}} 個", summaryUpdated: "已更新 {{count}} 個", diff --git a/templates/content/app/routes/_app.local-files.tsx b/templates/content/app/routes/_app.local-files.tsx index b5f68150cf..654439a8db 100644 --- a/templates/content/app/routes/_app.local-files.tsx +++ b/templates/content/app/routes/_app.local-files.tsx @@ -81,7 +81,6 @@ type LocalDirectoryHandle = { isSameEntry?(other: LocalDirectoryHandle): Promise; }; type WindowWithDirectoryPicker = Window & { - __agentNativeSafeDirectoryPicker?: boolean; showDirectoryPicker?: (options?: { mode?: "read" | "readwrite"; }) => Promise; @@ -195,8 +194,6 @@ function supportsDirectoryPicker() { typeof window !== "undefined" && typeof (window as WindowWithDirectoryPicker).showDirectoryPicker === "function" && - (window as WindowWithDirectoryPicker).__agentNativeSafeDirectoryPicker === - true && !getDesktopContentFiles() && !isUnsafeNativeFolderPickerHost() ); @@ -544,12 +541,7 @@ async function chooseDirectory( } const picker = (window as WindowWithDirectoryPicker).showDirectoryPicker; - if ( - !picker || - (window as WindowWithDirectoryPicker).__agentNativeSafeDirectoryPicker !== - true || - isUnsafeNativeFolderPickerHost() - ) { + if (!picker || isUnsafeNativeFolderPickerHost()) { throw new Error(unsupportedLocalFolderSyncMessage(t)); } const handle = await picker({ mode: "readwrite" }); diff --git a/templates/content/changelog/2026-07-18-local-folder-selection-no-longer-invokes-the-unsafe-native-p.md b/templates/content/changelog/2026-07-18-local-folder-selection-no-longer-invokes-the-unsafe-native-p.md index 79c0edf8d2..3d74bd428d 100644 --- a/templates/content/changelog/2026-07-18-local-folder-selection-no-longer-invokes-the-unsafe-native-p.md +++ b/templates/content/changelog/2026-07-18-local-folder-selection-no-longer-invokes-the-unsafe-native-p.md @@ -3,4 +3,4 @@ type: fixed date: 2026-07-18 --- -Local folder selection no longer invokes the unsafe native picker in embedded Codex windows and now directs unsupported hosts to Agent Native Desktop. +Local folder selection avoids unsafe embedded pickers while continuing to support Agent Native Desktop and native folder access in Chrome, Edge, and other Chromium browsers. diff --git a/templates/content/e2e/global-setup.ts b/templates/content/e2e/global-setup.ts index 0370a8ac1b..2d423952d9 100644 --- a/templates/content/e2e/global-setup.ts +++ b/templates/content/e2e/global-setup.ts @@ -28,8 +28,9 @@ async function globalSetup(_config: FullConfig) { const page = await ctx.newPage(); let result: Record = {}; try { - await page.goto(`${baseURL}/`, { waitUntil: "domcontentloaded" }); - await page.waitForTimeout(1500); + await page.goto(`${baseURL}/_agent-native/sign-in`, { + waitUntil: "domcontentloaded", + }); result = await page.evaluate( async ({ email, pass }) => { const post = (path: string, body: unknown) => diff --git a/templates/content/e2e/local-files.spec.ts b/templates/content/e2e/local-files.spec.ts index 23c7581f96..2f30d6ff08 100644 --- a/templates/content/e2e/local-files.spec.ts +++ b/templates/content/e2e/local-files.spec.ts @@ -141,7 +141,6 @@ async function installDirectoryPicker(page: Page, root: string) { api.__contentLocalSourceDirectoryHandle = handle; return handle; }; - api.__agentNativeSafeDirectoryPicker = true; }, { rootPath: root, rootName: path.basename(root) }, ); @@ -170,30 +169,41 @@ test("browser local folder edits write the selected MDX file", async ({ await installDirectoryPicker(page, root); - await page.goto("/local-files"); + await page.goto("/local-files", { waitUntil: "domcontentloaded" }); await page.getByRole("button", { name: /choose folder/i }).click(); async function findImportedDocId() { const res = await page.request.get( "/_agent-native/actions/list-documents", { - headers: { "X-Agent-Native-Frontend": "1" }, + headers: { + "X-Agent-Native-Frontend": "1", + "X-Agent-Native-Client-Compatibility": "content-spaces-v1", + "X-Agent-Native-Build-Id": "development", + }, }, ); if (!res.ok()) return null; const body = await res.json(); const docs = Array.isArray(body?.documents) ? body.documents : body; const doc = Array.isArray(docs) - ? docs.find((candidate) => candidate?.source?.path === SOURCE_PATH) + ? docs.find( + (candidate) => + candidate?.source?.path === SOURCE_PATH && + candidate?.source?.rootPath === path.basename(root), + ) : null; return typeof doc?.id === "string" ? doc.id : null; } + let importedDocId: string | null = null; await expect - .poll(async () => findImportedDocId(), { timeout: 20_000 }) + .poll(async () => (importedDocId = await findImportedDocId()), { + timeout: 20_000, + }) .toBeTruthy(); - await page.getByLabel("Getting Started").click(); + await page.goto(`/page/${importedDocId}`, { waitUntil: "domcontentloaded" }); const editor = page.locator(".notion-editor.ProseMirror").first(); await expect(editor).toBeVisible({ timeout: 30_000 }); await expect(editor).toContainText("Original body from disk.", { @@ -208,35 +218,4 @@ test("browser local folder edits write the selected MDX file", async ({ await expect .poll(async () => readSourceFile(root), { timeout: 20_000 }) .toContain('title: "Getting Started"'); - - await fs.writeFile( - path.join(root, SOURCE_PATH), - [ - "---", - 'title: "Getting Started"', - "---", - "", - "Externally changed source of truth.", - ].join("\n"), - "utf8", - ); - - await page.reload(); - await expect - .poll( - () => - page.evaluate(async () => { - const root = (window as any).__contentLocalSourceDirectoryHandle; - if (!root) return ""; - const dir = await root.getDirectoryHandle("content"); - const fileHandle = await dir.getFileHandle("getting-started.mdx"); - const file = await fileHandle.getFile(); - return file.text(); - }), - { timeout: 20_000 }, - ) - .toContain("Externally changed source of truth."); - await expect(editor).toContainText("Externally changed source of truth.", { - timeout: 20_000, - }); }); From a07e3ca1c7d68235b2e78fe866296b52a95f3c34 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Sun, 19 Jul 2026 11:58:17 -0400 Subject: [PATCH 55/76] fix(content): quarantine interrupted folder pickers --- .../DatabaseView.local-folder.layout.test.ts | 1 + .../content/app/i18n-data.local-files.test.ts | 3 + templates/content/app/i18n-data.ts | 2 + templates/content/app/i18n/zh-TW.ts | 2 + .../lib/local-folder-picker-safety.test.ts | 59 +++++++++++++++++++ .../app/lib/local-folder-picker-safety.ts | 54 +++++++++++++++++ .../content/app/routes/_app.local-files.tsx | 12 +++- ...n-no-longer-invokes-the-unsafe-native-p.md | 2 +- 8 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 templates/content/app/lib/local-folder-picker-safety.test.ts create mode 100644 templates/content/app/lib/local-folder-picker-safety.ts diff --git a/templates/content/app/components/editor/database/DatabaseView.local-folder.layout.test.ts b/templates/content/app/components/editor/database/DatabaseView.local-folder.layout.test.ts index e42b051640..0b5fb941af 100644 --- a/templates/content/app/components/editor/database/DatabaseView.local-folder.layout.test.ts +++ b/templates/content/app/components/editor/database/DatabaseView.local-folder.layout.test.ts @@ -38,5 +38,6 @@ describe("Files database local-folder source entry", () => { expect(localFilesRoute).not.toContain("__agentNativeSafeDirectoryPicker"); expect(localFilesRoute).toContain("showDirectoryPicker"); expect(localFilesRoute).toContain("isUnsafeNativeFolderPickerHost()"); + expect(localFilesRoute).toContain("runNativeFolderPickerWithCrashSentinel"); }); }); diff --git a/templates/content/app/i18n-data.local-files.test.ts b/templates/content/app/i18n-data.local-files.test.ts index 0bedc5e096..14767b3f4c 100644 --- a/templates/content/app/i18n-data.local-files.test.ts +++ b/templates/content/app/i18n-data.local-files.test.ts @@ -11,6 +11,9 @@ describe("local folder host guidance", () => { expect(messages.localFiles.unsupportedBrowser).toContain( "Agent Native Desktop", ); + expect(messages.localFiles.interruptedPicker).toContain( + "Agent Native Desktop", + ); } }); diff --git a/templates/content/app/i18n-data.ts b/templates/content/app/i18n-data.ts index 4f497cfe58..c9e0f6fe23 100644 --- a/templates/content/app/i18n-data.ts +++ b/templates/content/app/i18n-data.ts @@ -2791,6 +2791,8 @@ const editorSlashMessages = { }; const localFilesMessages = { + interruptedPicker: + "The folder picker did not finish last time, so it has been disabled in this browser profile. Use Agent Native Desktop, Chrome, Edge, or another Chromium browser.", unsupportedElectron: "Local folder sync is unavailable here. Use Agent Native Desktop, Chrome, Edge, or another Chromium browser.", unsupportedBrowser: diff --git a/templates/content/app/i18n/zh-TW.ts b/templates/content/app/i18n/zh-TW.ts index 73cb81b538..5f01002e88 100644 --- a/templates/content/app/i18n/zh-TW.ts +++ b/templates/content/app/i18n/zh-TW.ts @@ -1010,6 +1010,8 @@ const messages = { "新增狀態、選取、多選或核取方塊屬性來分組。", }, localFiles: { + interruptedPicker: + "上次資料夾選擇器未能完成,因此已在此瀏覽器設定檔中停用。請使用 Agent Native Desktop、Chrome、Edge 或其他 Chromium 瀏覽器。", unsupportedElectron: "此處無法使用本機資料夾同步。請使用 Agent Native Desktop、Chrome、Edge 或其他 Chromium 瀏覽器。", unsupportedBrowser: diff --git a/templates/content/app/lib/local-folder-picker-safety.test.ts b/templates/content/app/lib/local-folder-picker-safety.test.ts new file mode 100644 index 0000000000..13a1c0a58f --- /dev/null +++ b/templates/content/app/lib/local-folder-picker-safety.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + hasInterruptedNativeFolderPickerAttempt, + runNativeFolderPickerWithCrashSentinel, +} from "./local-folder-picker-safety"; + +function memoryStorage(initialValue?: string) { + const values = new Map(); + if (initialValue) { + values.set( + "agent-native:content:native-folder-picker-attempt", + initialValue, + ); + } + return { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + removeItem: (key: string) => values.delete(key), + }; +} + +describe("native folder picker crash sentinel", () => { + it("marks an in-flight picker and clears the marker when it returns", async () => { + const storage = memoryStorage(); + const operation = vi.fn(async () => { + expect(hasInterruptedNativeFolderPickerAttempt(storage)).toBe(true); + return "folder"; + }); + + await expect( + runNativeFolderPickerWithCrashSentinel(operation, { + storage, + attemptId: "attempt-1", + }), + ).resolves.toBe("folder"); + expect(hasInterruptedNativeFolderPickerAttempt(storage)).toBe(false); + }); + + it("clears the marker when the picker rejects normally", async () => { + const storage = memoryStorage(); + + await expect( + runNativeFolderPickerWithCrashSentinel( + async () => { + throw new Error("cancelled"); + }, + { storage, attemptId: "attempt-2" }, + ), + ).rejects.toThrow("cancelled"); + expect(hasInterruptedNativeFolderPickerAttempt(storage)).toBe(false); + }); + + it("recognizes the marker left behind when a picker never returned", () => { + expect( + hasInterruptedNativeFolderPickerAttempt(memoryStorage("unfinished")), + ).toBe(true); + }); +}); diff --git a/templates/content/app/lib/local-folder-picker-safety.ts b/templates/content/app/lib/local-folder-picker-safety.ts new file mode 100644 index 0000000000..1fbfd2c30f --- /dev/null +++ b/templates/content/app/lib/local-folder-picker-safety.ts @@ -0,0 +1,54 @@ +const NATIVE_FOLDER_PICKER_ATTEMPT_KEY = + "agent-native:content:native-folder-picker-attempt"; + +type PickerAttemptStorage = Pick; + +function browserStorage(): PickerAttemptStorage | null { + if (typeof window === "undefined") return null; + try { + return window.localStorage; + } catch { + return null; + } +} + +export function hasInterruptedNativeFolderPickerAttempt( + storage: PickerAttemptStorage | null = browserStorage(), +) { + try { + return Boolean(storage?.getItem(NATIVE_FOLDER_PICKER_ATTEMPT_KEY)); + } catch { + return false; + } +} + +export async function runNativeFolderPickerWithCrashSentinel( + operation: () => Promise, + options: { + storage?: PickerAttemptStorage | null; + attemptId?: string; + } = {}, +) { + const storage = options.storage ?? browserStorage(); + const attemptId = + options.attemptId ?? + `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; + + try { + storage?.setItem(NATIVE_FOLDER_PICKER_ATTEMPT_KEY, attemptId); + } catch { + // The picker still works when storage is unavailable; only crash recovery is lost. + } + + try { + return await operation(); + } finally { + try { + if (storage?.getItem(NATIVE_FOLDER_PICKER_ATTEMPT_KEY) === attemptId) { + storage.removeItem(NATIVE_FOLDER_PICKER_ATTEMPT_KEY); + } + } catch { + // A storage failure must not mask the picker result. + } + } +} diff --git a/templates/content/app/routes/_app.local-files.tsx b/templates/content/app/routes/_app.local-files.tsx index 654439a8db..5698e7133b 100644 --- a/templates/content/app/routes/_app.local-files.tsx +++ b/templates/content/app/routes/_app.local-files.tsx @@ -45,6 +45,10 @@ import { syncLocalControlResources, type LocalControlResourceFiles, } from "@/lib/local-control-resources"; +import { + hasInterruptedNativeFolderPickerAttempt, + runNativeFolderPickerWithCrashSentinel, +} from "@/lib/local-folder-picker-safety"; import { isUnsafeNativeFolderPickerHost } from "@/lib/local-folder-picker-support"; import { cn } from "@/lib/utils"; @@ -195,6 +199,7 @@ function supportsDirectoryPicker() { typeof (window as WindowWithDirectoryPicker).showDirectoryPicker === "function" && !getDesktopContentFiles() && + !hasInterruptedNativeFolderPickerAttempt() && !isUnsafeNativeFolderPickerHost() ); } @@ -208,6 +213,9 @@ function isElectronLikeBrowser() { } function unsupportedLocalFolderSyncMessage(t: ReturnType) { + if (hasInterruptedNativeFolderPickerAttempt()) { + return t("localFiles.interruptedPicker"); + } if (isElectronLikeBrowser()) { return t("localFiles.unsupportedElectron"); } @@ -544,7 +552,9 @@ async function chooseDirectory( if (!picker || isUnsafeNativeFolderPickerHost()) { throw new Error(unsupportedLocalFolderSyncMessage(t)); } - const handle = await picker({ mode: "readwrite" }); + const handle = await runNativeFolderPickerWithCrashSentinel(() => + picker({ mode: "readwrite" }), + ); const existing = await Promise.all( directories .filter((directory) => directory.kind === "browser") diff --git a/templates/content/changelog/2026-07-18-local-folder-selection-no-longer-invokes-the-unsafe-native-p.md b/templates/content/changelog/2026-07-18-local-folder-selection-no-longer-invokes-the-unsafe-native-p.md index 3d74bd428d..af884ab22b 100644 --- a/templates/content/changelog/2026-07-18-local-folder-selection-no-longer-invokes-the-unsafe-native-p.md +++ b/templates/content/changelog/2026-07-18-local-folder-selection-no-longer-invokes-the-unsafe-native-p.md @@ -3,4 +3,4 @@ type: fixed date: 2026-07-18 --- -Local folder selection avoids unsafe embedded pickers while continuing to support Agent Native Desktop and native folder access in Chrome, Edge, and other Chromium browsers. +Local folder selection avoids unsafe embedded pickers, remembers picker attempts that never returned so they cannot cause a crash loop, and continues to support Agent Native Desktop and native folder access in Chrome, Edge, and other Chromium browsers. From 49b0a883e1167c72425611767179bf89b329e3b5 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:26:05 -0400 Subject: [PATCH 56/76] fix(content): mirror Files views in sidebar --- .../_content-database-personal-view.ts | 46 +++++++++- .../actions/_files-system-properties.ts | 44 ++++++---- .../content/actions/content-files.db.test.ts | 87 +++++++++++++++---- .../editor/database/DatabaseView.tsx | 4 +- .../editor/database/sidebar.test.tsx | 62 ++++++++++++- .../components/editor/database/sidebar.tsx | 2 +- .../sidebar/DocumentSidebar.layout.test.ts | 5 +- .../components/sidebar/DocumentSidebar.tsx | 73 ++++++++++------ ...tart-unfiltered-sidebar-rows-exactly-fo.md | 6 ++ templates/content/shared/api.ts | 2 + 10 files changed, 264 insertions(+), 67 deletions(-) create mode 100644 templates/content/changelog/2026-07-19-files-databases-now-start-unfiltered-sidebar-rows-exactly-fo.md diff --git a/templates/content/actions/_content-database-personal-view.ts b/templates/content/actions/_content-database-personal-view.ts index 91b1a02883..f825b31719 100644 --- a/templates/content/actions/_content-database-personal-view.ts +++ b/templates/content/actions/_content-database-personal-view.ts @@ -4,9 +4,12 @@ import { and, eq, isNull } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; +import { CONTENT_DATABASE_PERSONAL_VIEW_OVERRIDES_VERSION } from "../shared/api.js"; import { resolveContentSpaceAccess } from "./_content-space-access.js"; +import { filesParentPropertyId } from "./_files-system-properties.js"; -export const PERSONAL_DATABASE_VIEW_OVERRIDES_VERSION = 1; +export const PERSONAL_DATABASE_VIEW_OVERRIDES_VERSION = + CONTENT_DATABASE_PERSONAL_VIEW_OVERRIDES_VERSION; export const personalDatabaseViewSettingKey = (databaseId: string) => `content-database-personal-view:${databaseId}`; @@ -39,8 +42,7 @@ export const filterSchema = z.object({ parentFilterGroupId: z.string().optional(), }); -export const personalViewOverridesSchema = z.object({ - version: z.literal(PERSONAL_DATABASE_VIEW_OVERRIDES_VERSION), +const personalViewOverridesFields = { activeViewId: z.string().optional(), views: z.array( z.object({ @@ -50,6 +52,16 @@ export const personalViewOverridesSchema = z.object({ filterMode: z.enum(["and", "or"]).default("and"), }), ), +}; + +export const personalViewOverridesSchema = z.object({ + version: z.literal(PERSONAL_DATABASE_VIEW_OVERRIDES_VERSION), + ...personalViewOverridesFields, +}); + +const legacyPersonalViewOverridesSchema = z.object({ + version: z.literal(1), + ...personalViewOverridesFields, }); export async function assertContentDatabaseViewerAccess(databaseId: string) { @@ -82,5 +94,31 @@ export async function readPersonalDatabaseViewOverrides( personalDatabaseViewSettingKey(databaseId), ); const parsed = personalViewOverridesSchema.safeParse(stored); - return parsed.success ? parsed.data : null; + if (parsed.success) return parsed.data; + + const legacy = legacyPersonalViewOverridesSchema.safeParse(stored); + if (!legacy.success) return null; + const [database] = await getDb() + .select({ systemRole: schema.contentDatabases.systemRole }) + .from(schema.contentDatabases) + .where(eq(schema.contentDatabases.id, databaseId)); + const legacyParentKey = filesParentPropertyId(databaseId); + return { + ...legacy.data, + version: PERSONAL_DATABASE_VIEW_OVERRIDES_VERSION, + views: legacy.data.views.map((view) => ({ + ...view, + filters: + database?.systemRole === "files" + ? view.filters.filter( + (filter) => + !( + filter.key === legacyParentKey && + filter.operator === "is_empty" && + filter.value === "" + ), + ) + : view.filters, + })), + }; } diff --git a/templates/content/actions/_files-system-properties.ts b/templates/content/actions/_files-system-properties.ts index 4dd6a18de0..594ac9da22 100644 --- a/templates/content/actions/_files-system-properties.ts +++ b/templates/content/actions/_files-system-properties.ts @@ -1,7 +1,7 @@ import { createHash } from "node:crypto"; import { accessFilter } from "@agent-native/core/sharing"; -import { and, eq, inArray, isNull, or } from "drizzle-orm"; +import { and, eq, inArray, isNull, lt, or } from "drizzle-orm"; import { getDb, schema } from "../server/db/index.js"; import type { @@ -29,6 +29,8 @@ type Document = Pick< "id" | "spaceId" | "parentId" | "ownerEmail" | "orgId" >; +const FILES_SYSTEM_PROPERTIES_VERSION = 2; + const SYSTEM_PROPERTY_SPECS = [ { role: "files_parent" as const, @@ -64,7 +66,7 @@ export function filesParentPropertyId(databaseId: string) { return systemPropertyId(databaseId, "files_parent"); } -function defaultFilesRootFilter(databaseId: string) { +function legacyFilesRootFilter(databaseId: string) { return { key: filesParentPropertyId(databaseId), label: "Parent", @@ -74,32 +76,36 @@ function defaultFilesRootFilter(databaseId: string) { } export function defaultFilesDatabaseViewConfig( - databaseId: string, + _databaseId: string, ): ContentDatabaseViewConfig { - const config = defaultDatabaseViewConfig("table"); - const filters = [defaultFilesRootFilter(databaseId)]; - return { - ...config, - filters, - views: config.views.map((view) => ({ ...view, filters })), - }; + return defaultDatabaseViewConfig("table"); } export function migrateFilesDatabaseViewConfig( config: ContentDatabaseViewConfig, databaseId: string, + options: { removeLegacyRootFilter?: boolean } = {}, ): ContentDatabaseViewConfig { const legacyKindPropertyId = legacyFilesKindPropertyId(databaseId); + const legacyRootFilter = legacyFilesRootFilter(databaseId); const migrateFilters = ( filters: ContentDatabaseViewConfig["filters"], ): ContentDatabaseViewConfig["filters"] => filters.flatMap((filter) => { + if ( + options.removeLegacyRootFilter && + filter.key === legacyRootFilter.key && + filter.operator === legacyRootFilter.operator && + filter.value === legacyRootFilter.value + ) { + return []; + } if (filter.key !== legacyKindPropertyId) return [filter]; if ( filter.operator === "does_not_equal" && filter.value === "database_row" ) { - return [defaultFilesRootFilter(databaseId)]; + return []; } return []; }); @@ -211,7 +217,11 @@ export async function ensureFilesSystemPropertyDefinitions(args: { const parsedStored = parseDatabaseViewConfig(args.database.viewConfigJson); const normalizedStored = serializeDatabaseViewConfig(parsedStored); const migratedStored = serializeDatabaseViewConfig( - migrateFilesDatabaseViewConfig(parsedStored, args.database.id), + migrateFilesDatabaseViewConfig(parsedStored, args.database.id, { + removeLegacyRootFilter: + args.database.filesSystemPropertiesSeeded < + FILES_SYSTEM_PROPERTIES_VERSION, + }), ); const untouchedLegacyDefaults = new Set([ serializeDatabaseViewConfig(defaultDatabaseViewConfig("sidebar")), @@ -228,7 +238,8 @@ export async function ensureFilesSystemPropertyDefinitions(args: { missingDefinitions.length === 0 && !adoptedLegacySource && viewConfigJson === args.database.viewConfigJson && - args.database.filesSystemPropertiesSeeded === 1 + args.database.filesSystemPropertiesSeeded === + FILES_SYSTEM_PROPERTIES_VERSION ) { return; } @@ -236,7 +247,7 @@ export async function ensureFilesSystemPropertyDefinitions(args: { .update(schema.contentDatabases) .set({ viewConfigJson, - filesSystemPropertiesSeeded: 1, + filesSystemPropertiesSeeded: FILES_SYSTEM_PROPERTIES_VERSION, updatedAt: now, }) .where(eq(schema.contentDatabases.id, args.database.id)); @@ -250,7 +261,10 @@ export async function repairFilesSystemPropertyDefinitions() { .where( and( eq(schema.contentDatabases.systemRole, "files"), - eq(schema.contentDatabases.filesSystemPropertiesSeeded, 0), + lt( + schema.contentDatabases.filesSystemPropertiesSeeded, + FILES_SYSTEM_PROPERTIES_VERSION, + ), isNull(schema.contentDatabases.deletedAt), ), ); diff --git a/templates/content/actions/content-files.db.test.ts b/templates/content/actions/content-files.db.test.ts index 7366642b75..a7ff01f5d8 100644 --- a/templates/content/actions/content-files.db.test.ts +++ b/templates/content/actions/content-files.db.test.ts @@ -114,6 +114,71 @@ async function getFilesDatabase(spaceId: string) { } describe("Content Files membership reconciliation", () => { + it("removes the retired Parent default from legacy personal Files views", async () => { + const { putUserSetting, deleteUserSetting } = + await import("@agent-native/core/settings"); + const { + personalDatabaseViewSettingKey, + PERSONAL_DATABASE_VIEW_OVERRIDES_VERSION, + } = await import("./_content-database-personal-view.js"); + const { filesParentPropertyId } = + await import("./_files-system-properties.js"); + const filesDatabase = await getFilesDatabase(personalContentSpaceId(OWNER)); + const settingKey = personalDatabaseViewSettingKey(filesDatabase.id); + await putUserSetting(OWNER, settingKey, { + version: 1, + activeViewId: "default", + views: [ + { + id: "default", + sorts: [{ key: "name", label: "Name", direction: "asc" }], + filters: [ + { + key: filesParentPropertyId(filesDatabase.id), + label: "Parent", + operator: "is_empty", + value: "", + }, + { + key: "name", + label: "Name", + operator: "contains", + value: "today", + }, + ], + filterMode: "and", + }, + ], + }); + try { + const result = await runWithRequestContext({ userEmail: OWNER }, () => + getContentDatabasePersonalViewAction.run( + { + databaseId: filesDatabase.id, + }, + { userEmail: OWNER } as any, + ), + ); + expect(result.overrides).toMatchObject({ + version: PERSONAL_DATABASE_VIEW_OVERRIDES_VERSION, + views: [ + { + sorts: [{ key: "name", direction: "asc" }], + filters: [ + { + key: "name", + operator: "contains", + value: "today", + }, + ], + }, + ], + }); + } finally { + await deleteUserSetting(OWNER, settingKey); + } + }); + it("exposes stable, derived Parent and Source properties", async () => { const spaceId = personalContentSpaceId(OWNER); const filesDatabase = await getFilesDatabase(spaceId); @@ -213,13 +278,7 @@ describe("Content Files membership reconciliation", () => { expect(definitions.has("files_kind")).toBe(false); expect(definitions.get("files_parent")).toMatchObject({ editable: false }); expect(definitions.get("files_source")).toMatchObject({ editable: false }); - expect(response.database.viewConfig.views[0]?.filters).toEqual([ - expect.objectContaining({ - key: definitions.get("files_parent")?.definition.id, - operator: "is_empty", - value: "", - }), - ]); + expect(response.database.viewConfig.views[0]?.filters).toEqual([]); const child = response.items.find( (item) => item.document.id === "system-property-child", )!; @@ -633,8 +692,8 @@ describe("Content Files membership reconciliation", () => { .select() .from(schema.contentDatabases) .where(eq(schema.contentDatabases.id, filesDatabase.id)); - expect(repaired.filesSystemPropertiesSeeded).toBe(1); - expect(repaired.viewConfigJson).toContain('"operator":"is_empty"'); + expect(repaired.filesSystemPropertiesSeeded).toBe(2); + expect(repaired.viewConfigJson).not.toContain('"operator":"is_empty"'); expect(repaired.viewConfigJson).not.toContain("database_row"); await getDb() @@ -693,15 +752,7 @@ describe("Content Files membership reconciliation", () => { .from(schema.contentDatabases) .where(eq(schema.contentDatabases.id, filesDatabase.id)); const parsed = JSON.parse(migrated.viewConfigJson); - expect(parsed.views[0].filters).toEqual([ - { - key: filesParentPropertyId(filesDatabase.id), - label: "Parent", - operator: "is_empty", - value: "", - }, - savedTitleFilter, - ]); + expect(parsed.views[0].filters).toEqual([savedTitleFilter]); } finally { await getDb() .update(schema.contentDatabases) diff --git a/templates/content/app/components/editor/database/DatabaseView.tsx b/templates/content/app/components/editor/database/DatabaseView.tsx index ea8d82472a..4d85792f6f 100644 --- a/templates/content/app/components/editor/database/DatabaseView.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.tsx @@ -52,6 +52,7 @@ import { TooltipTrigger, } from "@agent-native/toolkit/ui/tooltip"; import { + CONTENT_DATABASE_PERSONAL_VIEW_OVERRIDES_VERSION, type BuilderCmsModelSummary, type ContentDatabaseItem, type ContentDatabaseResponse, @@ -348,7 +349,8 @@ const DATABASE_OPEN_PAGES_IN: ContentDatabaseOpenPagesIn[] = [ ]; const DATABASE_FILTER_MODES: DatabaseFilterMode[] = ["and", "or"]; const DATABASE_ADVANCED_FILTER_GROUP_ID = "advanced"; -export const PERSONAL_DATABASE_VIEW_OVERRIDES_VERSION = 1; +export const PERSONAL_DATABASE_VIEW_OVERRIDES_VERSION = + CONTENT_DATABASE_PERSONAL_VIEW_OVERRIDES_VERSION; export const BUILDER_SOURCE_CONTINUATION_STALL_MS = 5_000; export const BUILDER_SOURCE_CONTINUATION_MAX_BACKOFF_MS = 30_000; diff --git a/templates/content/app/components/editor/database/sidebar.test.tsx b/templates/content/app/components/editor/database/sidebar.test.tsx index 56910f061e..8e31b36024 100644 --- a/templates/content/app/components/editor/database/sidebar.test.tsx +++ b/templates/content/app/components/editor/database/sidebar.test.tsx @@ -93,7 +93,7 @@ describe("DatabaseSidebarView", () => { expect(markup).toContain("font-semibold"); }); - it("renders parented Files pages beneath filtered roots", () => { + it("renders parented Files pages beneath supplied hierarchy roots", () => { const rootItem = item("parent", "Page one"); const childItem = item("child", "Page two", "parent"); const grandchildItem = item("grandchild", "Page three", "child"); @@ -152,6 +152,66 @@ describe("DatabaseSidebarView", () => { expect(markup).toContain('aria-current="page"'); }); + it("does not reinsert descendants excluded by a Files filter", () => { + const parent = item("parent", "Parent"); + const child = item("child", "Child", "parent"); + const matchingSibling = item("matching", "Matching"); + const data = { + database: { + viewConfig: { + version: 1, + activeViewId: "default", + views: [ + { + id: "default", + name: "Table", + type: "table", + filters: [ + { + key: "name", + label: "Name", + operator: "contains", + value: "ing", + }, + ], + sorts: [], + filterMode: "and", + }, + ], + }, + }, + items: [parent, child, matchingSibling], + properties: [ + { + definition: { id: "parent", systemRole: "files_parent" }, + }, + ], + } as unknown as ContentDatabaseResponse; + + const markup = renderToStaticMarkup( + + + + + , + ); + + expect(markup).toContain("Matching"); + expect(markup).not.toContain(">Parent<"); + expect(markup).not.toContain(">Child<"); + }); + it("lets a saved database view render workspace roots inside its groups", () => { const groups = [ { diff --git a/templates/content/app/components/editor/database/sidebar.tsx b/templates/content/app/components/editor/database/sidebar.tsx index e56926c8d0..6f88fb3b14 100644 --- a/templates/content/app/components/editor/database/sidebar.tsx +++ b/templates/content/app/components/editor/database/sidebar.tsx @@ -171,7 +171,7 @@ export function ContentFilesSidebarView({ const hierarchyItems = data?.properties.some( (property) => property.definition.systemRole === "files_parent", ) - ? data.items + ? items : undefined; return (
diff --git a/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts b/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts index b9ee091268..1798d45ad6 100644 --- a/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts +++ b/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts @@ -312,10 +312,13 @@ describe("document sidebar layout", () => { expect(sidebar).toContain("const favoriteRowWidth ="); expect(sidebar).toContain("{showFavorites && ("); + expect(sidebar).toContain('toggleSection("favorites")'); + expect(sidebar).toContain("!collapsedSections.favorites &&"); + expect(sidebar).toContain("aria-expanded={!collapsedSections.favorites}"); expect(sidebar).toContain('"mb-2 min-w-0 px-2"'); expect(sidebar).toContain("> | null | undefined, ): CollapsedSectionsState { return { + favorites: value?.favorites ?? false, "local-files": value?.["local-files"] ?? false, "shared-copies": value?.["shared-copies"] ?? false, private: value?.private ?? false, @@ -293,7 +297,9 @@ function WorkspaceFilesSection({ updateFilesPersonalView.mutate({ databaseId: space.filesDatabaseId, overrides: { - version: current?.version ?? 1, + version: + current?.version ?? + CONTENT_DATABASE_PERSONAL_VIEW_OVERRIDES_VERSION, activeViewId: viewId, views: current?.views ?? [], }, @@ -1383,7 +1389,9 @@ export function DocumentSidebar({ updateWorkspaceCatalogPersonalView.mutate({ databaseId: workspaceCatalogDatabaseId, overrides: { - version: current?.version ?? 1, + version: + current?.version ?? + CONTENT_DATABASE_PERSONAL_VIEW_OVERRIDES_VERSION, activeViewId: viewId, views: current?.views ?? [], }, @@ -1728,34 +1736,47 @@ export function DocumentSidebar({ {/* Favorites */} {showFavorites && (
-
+
- {favorites.map((doc) => ( - { - navigateToDocument(doc.id); - onNavigate?.(); - }} - onCreateChildPage={() => void handleCreatePage(doc.id)} - onCreateChildDatabase={() => - void handleCreateDatabase(doc.id) - } - onRemoveFavorite={() => - handleToggleFavorite(doc.id, false) - } - onDelete={() => void handleDelete(doc.id)} - /> - ))} + + {!collapsedSections.favorites && + favorites.map((doc) => ( + { + navigateToDocument(doc.id); + onNavigate?.(); + }} + onCreateChildPage={() => void handleCreatePage(doc.id)} + onCreateChildDatabase={() => + void handleCreateDatabase(doc.id) + } + onRemoveFavorite={() => + handleToggleFavorite(doc.id, false) + } + onDelete={() => void handleDelete(doc.id)} + /> + ))}
)} diff --git a/templates/content/changelog/2026-07-19-files-databases-now-start-unfiltered-sidebar-rows-exactly-fo.md b/templates/content/changelog/2026-07-19-files-databases-now-start-unfiltered-sidebar-rows-exactly-fo.md new file mode 100644 index 0000000000..569a8c6bfb --- /dev/null +++ b/templates/content/changelog/2026-07-19-files-databases-now-start-unfiltered-sidebar-rows-exactly-fo.md @@ -0,0 +1,6 @@ +--- +type: improved +date: 2026-07-19 +--- + +Files databases now start unfiltered, sidebar rows exactly follow the active filter and sort, and Favorites can be collapsed. diff --git a/templates/content/shared/api.ts b/templates/content/shared/api.ts index 73b41dacd8..f80f795e47 100644 --- a/templates/content/shared/api.ts +++ b/templates/content/shared/api.ts @@ -341,6 +341,8 @@ export interface ContentDatabaseViewConfig { columnWidths: Record; } +export const CONTENT_DATABASE_PERSONAL_VIEW_OVERRIDES_VERSION = 2; + export interface ContentDatabasePersonalViewOverrides { version: number; activeViewId?: string; From 02d9cf2157065eaf6066605e44ccff96b86342bc Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Sun, 19 Jul 2026 17:07:04 -0400 Subject: [PATCH 57/76] improve(content): finish workspace navigation polish --- .../editor/DocumentEditor.layout.test.ts | 63 ++++++ .../app/components/editor/DocumentEditor.tsx | 129 +++++++++++- .../app/components/editor/DocumentToolbar.tsx | 151 +++++++++++++- .../sidebar/DocumentSidebar.layout.test.ts | 16 +- .../components/sidebar/DocumentSidebar.tsx | 125 +++++++----- .../content/app/routes/_app.favorites.tsx | 190 ++++++++++++++++++ ...-a-table-and-compact-breadcrumbs-make-w.md | 6 + 7 files changed, 617 insertions(+), 63 deletions(-) create mode 100644 templates/content/app/routes/_app.favorites.tsx create mode 100644 templates/content/changelog/2026-07-19-favorites-now-open-as-a-table-and-compact-breadcrumbs-make-w.md diff --git a/templates/content/app/components/editor/DocumentEditor.layout.test.ts b/templates/content/app/components/editor/DocumentEditor.layout.test.ts index d2eed85ac6..2e54531805 100644 --- a/templates/content/app/components/editor/DocumentEditor.layout.test.ts +++ b/templates/content/app/components/editor/DocumentEditor.layout.test.ts @@ -5,10 +5,12 @@ import { describe, expect, it } from "vitest"; import { databaseMembershipDatabaseTitle, documentEditorBreadcrumbItems, + documentEditorBreadcrumbNavigationItems, documentEditorDefaultIconKind, documentEditorDatabaseRegionClassName, documentEditorTitleRegionClassName, } from "./DocumentEditor"; +import { compactToolbarBreadcrumbItems } from "./DocumentToolbar"; describe("document editor layout", () => { it("keeps prose titles on the reading column", () => { @@ -436,4 +438,65 @@ describe("document editor layout", () => { ).map((item) => item.title), ).toEqual(["Personal", "Draft"]); }); + + it("keeps the workspace and last two levels visible in deep breadcrumbs", () => { + expect( + compactToolbarBreadcrumbItems([ + { id: "files", title: "Personal" }, + { id: "one", title: "Page 1" }, + { id: "two", title: "Page 2" }, + { id: "draft", title: "Draft" }, + ]).map((item) => item.title), + ).toEqual(["Personal", "…", "Page 2", "Draft"]); + }); + + it("offers workspace and same-level page choices from breadcrumbs", () => { + const items = documentEditorBreadcrumbNavigationItems( + [ + { id: "personal-files", title: "Personal" }, + { id: "draft", title: "Draft" }, + ], + [ + { + id: "draft", + parentId: null, + title: "Draft", + icon: null, + position: 0, + databaseMembership: { + databaseId: "personal", + databaseDocumentId: "personal-files", + databaseTitle: "Personal", + position: 0, + }, + }, + { + id: "notes", + parentId: null, + title: "Notes", + icon: null, + position: 1, + databaseMembership: { + databaseId: "personal", + databaseDocumentId: "personal-files", + databaseTitle: "Personal", + position: 1, + }, + }, + ], + [ + { filesDocumentId: "personal-files", name: "Personal" }, + { filesDocumentId: "team-files", name: "Team" }, + ], + ); + + expect(items[0].menuItems?.map((item) => item.title)).toEqual([ + "Personal", + "Team", + ]); + expect(items[1].menuItems?.map((item) => item.title)).toEqual([ + "Draft", + "Notes", + ]); + }); }); diff --git a/templates/content/app/components/editor/DocumentEditor.tsx b/templates/content/app/components/editor/DocumentEditor.tsx index c3b0ffb26d..fd07d9ca7b 100644 --- a/templates/content/app/components/editor/DocumentEditor.tsx +++ b/templates/content/app/components/editor/DocumentEditor.tsx @@ -8,6 +8,7 @@ import { type CollabUser, } from "@agent-native/core/client/collab"; import { + setClientAppState, useAvatarUrl, useDbSync, useSession, @@ -38,6 +39,11 @@ import { contentBlockRegistry, createContentBlockRenderContext, } from "@/blocks/contentBlockRegistry"; +import { + createContentSpaceSelectionQueue, + SELECTED_CONTENT_SPACE_STORAGE_KEY, + selectContentSpace, +} from "@/components/sidebar/select-content-space"; import { Button } from "@/components/ui/button"; import { Sheet, @@ -51,6 +57,10 @@ import { useDeleteContentDatabase, useProcessBuilderBodyHydration, } from "@/hooks/use-content-database"; +import { + useContentSpaces, + type ContentSpaceSummary, +} from "@/hooks/use-content-spaces"; import { isDocumentUpdateConflict, patchDocumentCaches, @@ -83,7 +93,7 @@ import { DocumentBlockFields } from "./DocumentBlockFields"; import { DocumentDatabase } from "./DocumentDatabase"; import { DocumentEditorSkeleton } from "./DocumentEditorSkeleton"; import { DocumentInfoPanel } from "./DocumentInfoPanel"; -import { DocumentToolbar } from "./DocumentToolbar"; +import { DocumentToolbar, type ToolbarBreadcrumbItem } from "./DocumentToolbar"; import { EmojiPicker } from "./EmojiPicker"; import { NotionConflictBanner } from "./NotionConflictBanner"; import { @@ -315,6 +325,68 @@ export function documentEditorBreadcrumbItems( ]; } +export function documentEditorBreadcrumbNavigationItems( + items: ToolbarBreadcrumbItem[], + documents: Pick< + Document, + | "id" + | "parentId" + | "title" + | "icon" + | "position" + | "databaseMembership" + | "source" + >[], + spaces: Pick[], +) { + const documentById = new Map(documents.map((item) => [item.id, item])); + const workspaceDocumentIds = new Set( + spaces.map((space) => space.filesDocumentId), + ); + + return items.map((item) => { + if (item.id && workspaceDocumentIds.has(item.id)) { + return { + ...item, + menuItems: spaces.map((space) => ({ + id: space.filesDocumentId, + title: space.name, + icon: null, + })), + }; + } + + const current = item.id ? documentById.get(item.id) : null; + if (!current) return item; + const membershipDocumentId = + current.databaseMembership?.databaseDocumentId ?? null; + const siblings = documents + .filter((candidate) => { + if (candidate.source?.kind === "folder") return false; + if (candidate.parentId !== current.parentId) return false; + if (current.parentId) return true; + return ( + candidate.databaseMembership?.databaseDocumentId === + membershipDocumentId + ); + }) + .sort( + (left, right) => + left.position - right.position || + left.title.localeCompare(right.title), + ); + if (siblings.length < 2) return item; + return { + ...item, + menuItems: siblings.map((sibling) => ({ + id: sibling.id, + title: sibling.title, + icon: sibling.icon, + })), + }; + }); +} + function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) { const t = useT(); const updateDocument = useUpdateDocument(); @@ -338,6 +410,13 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) { const navigate = useNavigate(); const documentsQuery = useDocuments(); const documents: Document[] = documentsQuery.data ?? []; + const contentSpacesQuery = useContentSpaces(); + const contentSpaces = contentSpacesQuery.data?.spaces ?? []; + const workspaceSelectionQueueRef = useRef(createContentSpaceSelectionQueue()); + const [, setStoredSpaceId] = useLocalStorage( + SELECTED_CONTENT_SPACE_STORAGE_KEY, + null, + ); // Shared with DocumentToolbar via the same localStorage key — both read it. const [autoSync] = useLocalStorage(`notion-auto-sync:${documentId}`, false); const isLocalFileDocument = document.source?.mode === "local-files"; @@ -1301,8 +1380,51 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) { }); const toolbarBreadcrumbItems = useMemo( - () => documentEditorBreadcrumbItems(document, documents), - [document, documents], + () => + documentEditorBreadcrumbNavigationItems( + documentEditorBreadcrumbItems(document, documents), + documents, + contentSpaces, + ), + [contentSpaces, document, documents], + ); + + const handleOpenToolbarBreadcrumb = useCallback( + (targetId: string) => { + const targetDocument = documents.find((item) => item.id === targetId); + const filesDocumentId = + targetDocument?.databaseMembership?.databaseDocumentId ?? targetId; + const space = contentSpaces.find( + (candidate) => candidate.filesDocumentId === filesDocumentId, + ); + if (!space) { + navigate(`/page/${targetId}`, { flushSync: true }); + return; + } + void workspaceSelectionQueueRef + .current(() => + selectContentSpace({ + space, + syncApplicationState: (selected) => + setClientAppState( + "content-space", + { + spaceId: selected.id, + name: selected.name, + kind: selected.kind, + filesDatabaseId: selected.filesDatabaseId, + }, + { requestSource: "content-breadcrumb" }, + ), + persistSelection: setStoredSpaceId, + openFiles: () => navigate(`/page/${targetId}`, { flushSync: true }), + }), + ) + .catch((error) => { + toast.error(error instanceof Error ? error.message : String(error)); + }); + }, + [contentSpaces, documents, navigate, setStoredSpaceId], ); const commentsSidebar = ( @@ -1427,6 +1549,7 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) { utilityPanel={utilityPanel} onUtilityPanelChange={setUtilityPanel} showCommentsControl={editorCanEdit && !isLocalFileDocument} + onOpenBreadcrumbItem={handleOpenToolbarBreadcrumb} /> {!isLocalFileDocument ? ( diff --git a/templates/content/app/components/editor/DocumentToolbar.tsx b/templates/content/app/components/editor/DocumentToolbar.tsx index fb64b2c4a6..244f2a57ae 100644 --- a/templates/content/app/components/editor/DocumentToolbar.tsx +++ b/templates/content/app/components/editor/DocumentToolbar.tsx @@ -11,6 +11,7 @@ import { IconArrowBarDown, IconArrowBarUp, IconAlertTriangle, + IconCheck, IconCopy, IconDownload, IconDotsVertical, @@ -33,7 +34,13 @@ import { IconTrash, } from "@tabler/icons-react"; import { useQueryClient } from "@tanstack/react-query"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useRef, + useState, + type ReactNode, +} from "react"; import { useLocation, useNavigate } from "react-router"; import { toast } from "sonner"; @@ -201,19 +208,20 @@ function ToolbarBreadcrumb({ untitledLabel, onOpen, }: { - items: { id?: string; title: string; icon?: string | null }[]; + items: ToolbarBreadcrumbItem[]; currentDocumentId: string; ariaLabel: string; untitledLabel: string; onOpen: (id: string) => void; }) { + const visibleItems = compactToolbarBreadcrumbItems(items); return (
+
+ ); +} diff --git a/templates/content/changelog/2026-07-19-favorites-now-open-as-a-table-and-compact-breadcrumbs-make-w.md b/templates/content/changelog/2026-07-19-favorites-now-open-as-a-table-and-compact-breadcrumbs-make-w.md new file mode 100644 index 0000000000..56440b73a7 --- /dev/null +++ b/templates/content/changelog/2026-07-19-favorites-now-open-as-a-table-and-compact-breadcrumbs-make-w.md @@ -0,0 +1,6 @@ +--- +type: improved +date: 2026-07-19 +--- + +Favorites now open as a table, and compact breadcrumbs make workspace and sibling navigation easier. From 4fd69051fa669f52afda2fe62150d0f1ef248407 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Sun, 19 Jul 2026 17:11:17 -0400 Subject: [PATCH 58/76] fix(content): satisfy navigation catalog guards --- .../app/components/editor/DocumentEditor.tsx | 4 ++-- templates/content/app/i18n-data.ts | 18 ++++++++++++++++++ .../content/app/routes/_app.favorites.tsx | 2 +- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/templates/content/app/components/editor/DocumentEditor.tsx b/templates/content/app/components/editor/DocumentEditor.tsx index fd07d9ca7b..c31d4aaf40 100644 --- a/templates/content/app/components/editor/DocumentEditor.tsx +++ b/templates/content/app/components/editor/DocumentEditor.tsx @@ -336,8 +336,8 @@ export function documentEditorBreadcrumbNavigationItems( | "position" | "databaseMembership" | "source" - >[], - spaces: Pick[], + >[], // i18n-ignore type expression + spaces: Pick[], // i18n-ignore type expression ) { const documentById = new Map(documents.map((item) => [item.id, item])); const workspaceDocumentIds = new Set( diff --git a/templates/content/app/i18n-data.ts b/templates/content/app/i18n-data.ts index c9e0f6fe23..7315d9f03f 100644 --- a/templates/content/app/i18n-data.ts +++ b/templates/content/app/i18n-data.ts @@ -3937,6 +3937,8 @@ const rawLiteralLocaleMessages: Partial> = { const localFilesMessagesByLocale = { "zh-CN": { + interruptedPicker: + "上次文件夹选择器未能完成,因此已在此浏览器配置文件中停用。请使用 Agent Native Desktop、Chrome、Edge 或其他 Chromium 浏览器。", unsupportedElectron: "此处无法使用本地文件夹同步。请使用 Agent Native Desktop、Chrome、Edge 或其他 Chromium 浏览器。", unsupportedBrowser: @@ -3983,6 +3985,8 @@ const localFilesMessagesByLocale = { removing: "正在移除...", }, "es-ES": { + interruptedPicker: + "El selector de carpetas no terminó la última vez, por lo que se ha desactivado en este perfil del navegador. Usa Agent Native Desktop, Chrome, Edge u otro navegador Chromium.", unsupportedElectron: "La sincronización de carpetas locales no está disponible aquí. Usa Agent Native Desktop, Chrome, Edge u otro navegador Chromium.", unsupportedBrowser: @@ -4031,6 +4035,8 @@ const localFilesMessagesByLocale = { removing: "Quitando...", }, "fr-FR": { + interruptedPicker: + "Le sélecteur de dossiers ne s’est pas terminé la dernière fois. Il a donc été désactivé dans ce profil de navigateur. Utilisez Agent Native Desktop, Chrome, Edge ou un autre navigateur Chromium.", unsupportedElectron: "La synchronisation des dossiers locaux n’est pas disponible ici. Utilisez Agent Native Desktop, Chrome, Edge ou un autre navigateur Chromium.", unsupportedBrowser: @@ -4080,6 +4086,8 @@ const localFilesMessagesByLocale = { removing: "Suppression...", }, "de-DE": { + interruptedPicker: + "Die Ordnerauswahl wurde beim letzten Mal nicht abgeschlossen und ist daher in diesem Browserprofil deaktiviert. Verwende Agent Native Desktop, Chrome, Edge oder einen anderen Chromium-Browser.", unsupportedElectron: "Die lokale Ordnersynchronisierung ist hier nicht verfügbar. Verwende Agent Native Desktop, Chrome, Edge oder einen anderen Chromium-Browser.", unsupportedBrowser: @@ -4127,6 +4135,8 @@ const localFilesMessagesByLocale = { removing: "Wird entfernt...", }, "ja-JP": { + interruptedPicker: + "前回フォルダーピッカーが完了しなかったため、このブラウザプロフィールでは無効になっています。Agent Native Desktop、Chrome、Edge、または別の Chromium ブラウザを使用してください。", unsupportedElectron: "ここではローカルフォルダー同期を利用できません。Agent Native Desktop、Chrome、Edge、またはその他の Chromium ブラウザーを使用してください。", unsupportedBrowser: @@ -4175,6 +4185,8 @@ const localFilesMessagesByLocale = { removing: "削除中...", }, "ko-KR": { + interruptedPicker: + "지난번에 폴더 선택기가 완료되지 않아 이 브라우저 프로필에서 비활성화되었습니다. Agent Native Desktop, Chrome, Edge 또는 다른 Chromium 브라우저를 사용하세요.", unsupportedElectron: "여기서는 로컬 폴더 동기화를 사용할 수 없습니다. Agent Native Desktop, Chrome, Edge 또는 다른 Chromium 브라우저를 사용하세요.", unsupportedBrowser: @@ -4223,6 +4235,8 @@ const localFilesMessagesByLocale = { removing: "제거 중...", }, "pt-BR": { + interruptedPicker: + "O seletor de pastas não foi concluído da última vez, por isso foi desativado neste perfil do navegador. Use o Agent Native Desktop, Chrome, Edge ou outro navegador Chromium.", unsupportedElectron: "A sincronização de pastas locais não está disponível aqui. Use o Agent Native Desktop, Chrome, Edge ou outro navegador Chromium.", unsupportedBrowser: @@ -4271,6 +4285,8 @@ const localFilesMessagesByLocale = { removing: "Removendo...", }, "hi-IN": { + interruptedPicker: + "पिछली बार फ़ोल्डर पिकर पूरा नहीं हुआ था, इसलिए इसे इस ब्राउज़र प्रोफ़ाइल में बंद कर दिया गया है। Agent Native Desktop, Chrome, Edge या किसी अन्य Chromium ब्राउज़र का उपयोग करें।", unsupportedElectron: "यहाँ स्थानीय फ़ोल्डर सिंक उपलब्ध नहीं है। Agent Native Desktop, Chrome, Edge या किसी अन्य Chromium ब्राउज़र का उपयोग करें।", unsupportedBrowser: @@ -4318,6 +4334,8 @@ const localFilesMessagesByLocale = { removing: "हटाया जा रहा है...", }, "ar-SA": { + interruptedPicker: + "لم يكتمل منتقي المجلدات في المرة السابقة، لذلك تم تعطيله في ملف تعريف المتصفح هذا. استخدم Agent Native Desktop أو Chrome أو Edge أو متصفح Chromium آخر.", unsupportedElectron: "مزامنة المجلدات المحلية غير متاحة هنا. استخدم Agent Native Desktop أو Chrome أو Edge أو متصفح Chromium آخر.", unsupportedBrowser: diff --git a/templates/content/app/routes/_app.favorites.tsx b/templates/content/app/routes/_app.favorites.tsx index 4f2a235f7f..013a0675ed 100644 --- a/templates/content/app/routes/_app.favorites.tsx +++ b/templates/content/app/routes/_app.favorites.tsx @@ -128,7 +128,7 @@ export default function FavoritesRoute() {
) : favorites.length === 0 ? (
- Favorite a page to keep it close at hand. + {t("database.noRowsMatchThisView")}
) : (
From 9f34284f8e7f0ecf26986278528d820c0917b732 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Sun, 19 Jul 2026 17:57:11 -0400 Subject: [PATCH 59/76] feat(content): make favorites a global database --- .../actions/_content-document-access.ts | 21 ++ .../content/actions/_content-favorites.ts | 91 +++++++++ templates/content/actions/_content-spaces.ts | 78 +++++++- templates/content/actions/_database-utils.ts | 102 ++++++++-- .../content/actions/content-spaces.db.test.ts | 174 +++++++++++++++- .../content/actions/delete-database-items.ts | 27 ++- templates/content/actions/delete-document.ts | 39 +++- templates/content/actions/get-document.ts | 13 +- .../actions/import-content-source.db.test.ts | 80 +++++++- .../content/actions/import-content-source.ts | 34 +++- .../content/actions/list-content-spaces.ts | 18 +- templates/content/actions/list-documents.ts | 12 +- .../content/actions/set-document-property.ts | 60 ++++-- .../actions/share-local-file-document.ts | 15 ++ .../actions/sync-local-folder-source.ts | 14 ++ templates/content/actions/update-document.ts | 62 +++++- .../editor/database/DatabaseView.tsx | 111 ++++++++--- .../sidebar/DocumentSidebar.layout.test.ts | 14 +- .../components/sidebar/DocumentSidebar.tsx | 12 +- .../sidebar/document-sidebar-sections.ts | 2 +- .../content/app/hooks/use-content-spaces.ts | 2 + templates/content/app/hooks/use-documents.ts | 4 +- .../content/app/routes/_app.favorites.tsx | 188 ++---------------- ...-a-table-and-compact-breadcrumbs-make-w.md | 2 +- 24 files changed, 897 insertions(+), 278 deletions(-) create mode 100644 templates/content/actions/_content-document-access.ts create mode 100644 templates/content/actions/_content-favorites.ts diff --git a/templates/content/actions/_content-document-access.ts b/templates/content/actions/_content-document-access.ts new file mode 100644 index 0000000000..d32b072290 --- /dev/null +++ b/templates/content/actions/_content-document-access.ts @@ -0,0 +1,21 @@ +import { getRequestUserEmail } from "@agent-native/core/server/request-context"; +import { resolveAccess } from "@agent-native/core/sharing"; + +import { listContentOrganizationMemberships } from "./_content-space-access.js"; + +export async function resolveContentDocumentAccess(documentId: string) { + const current = await resolveAccess("document", documentId); + if (current) return current; + + const userEmail = getRequestUserEmail(); + if (!userEmail) return null; + const memberships = await listContentOrganizationMemberships(userEmail); + for (const membership of memberships) { + const access = await resolveAccess("document", documentId, { + userEmail, + orgId: membership.orgId, + }); + if (access) return access; + } + return null; +} diff --git a/templates/content/actions/_content-favorites.ts b/templates/content/actions/_content-favorites.ts new file mode 100644 index 0000000000..055d4e2bb8 --- /dev/null +++ b/templates/content/actions/_content-favorites.ts @@ -0,0 +1,91 @@ +import { createHash } from "node:crypto"; + +import { and, eq, inArray, sql } from "drizzle-orm"; + +import { schema } from "../server/db/index.js"; +import { normalizeContentSpaceEmail } from "./_content-space-access.js"; +import { + personalContentSpaceId, + systemIdsForContentSpace, +} from "./_content-spaces.js"; + +type Db = any; + +export function favoritesSystemIds(userEmail: string) { + return systemIdsForContentSpace( + personalContentSpaceId(normalizeContentSpaceEmail(userEmail)), + "favorites", + ); +} + +export async function favoriteDocumentIds( + db: Db, + userEmail: string, + documentIds: string[], +) { + if (documentIds.length === 0) return new Set(); + const favoritesDatabaseId = favoritesSystemIds(userEmail).databaseId; + const rows = await db + .select({ documentId: schema.contentDatabaseItems.documentId }) + .from(schema.contentDatabaseItems) + .where( + and( + eq(schema.contentDatabaseItems.databaseId, favoritesDatabaseId), + inArray(schema.contentDatabaseItems.documentId, documentIds), + ), + ); + return new Set(rows.map((row: { documentId: string }) => row.documentId)); +} + +export async function setFavoriteMembership(args: { + db: Db; + userEmail: string; + documentId: string; + favorite: boolean; + now: string; +}) { + const email = normalizeContentSpaceEmail(args.userEmail); + const favoritesDatabaseId = favoritesSystemIds(email).databaseId; + const [existing] = await args.db + .select({ id: schema.contentDatabaseItems.id }) + .from(schema.contentDatabaseItems) + .where( + and( + eq(schema.contentDatabaseItems.databaseId, favoritesDatabaseId), + eq(schema.contentDatabaseItems.documentId, args.documentId), + ), + ); + + if (!args.favorite) { + if (existing) { + await args.db + .delete(schema.contentDatabaseItems) + .where(eq(schema.contentDatabaseItems.id, existing.id)); + } + return false; + } + if (existing) return true; + + const [position] = await args.db + .select({ max: sql`COALESCE(MAX(position), -1)` }) + .from(schema.contentDatabaseItems) + .where(eq(schema.contentDatabaseItems.databaseId, favoritesDatabaseId)); + const id = `content_database_item_${createHash("sha256") + .update(`${favoritesDatabaseId}:${args.documentId}`) + .digest("hex") + .slice(0, 32)}`; + await args.db + .insert(schema.contentDatabaseItems) + .values({ + id, + ownerEmail: email, + orgId: null, + databaseId: favoritesDatabaseId, + documentId: args.documentId, + position: Number(position?.max ?? -1) + 1, + createdAt: args.now, + updatedAt: args.now, + }) + .onConflictDoNothing(); + return true; +} diff --git a/templates/content/actions/_content-spaces.ts b/templates/content/actions/_content-spaces.ts index 7d9e2366d8..2535f21093 100644 --- a/templates/content/actions/_content-spaces.ts +++ b/templates/content/actions/_content-spaces.ts @@ -29,6 +29,8 @@ export type ProvisionedContentSpaces = { personalSpaceId: string; personalFilesDatabaseId: string; catalogDatabaseId: string; + favoritesDatabaseId: string; + favoritesDocumentId: string; spaceIds: string[]; created: { spaces: number; @@ -69,7 +71,7 @@ export function userContentSpaceId(email: string, workspaceId: string) { export function systemIdsForContentSpace( scope: string, - role: "files" | "workspaces", + role: "files" | "workspaces" | "favorites", ) { return { databaseId: opaqueId(`content_database_${role}`, scope), @@ -107,7 +109,7 @@ async function ensureSystemDatabase(args: { ownerEmail: string; orgId: string | null; title: string; - role: "files" | "workspaces"; + role: "files" | "workspaces" | "favorites"; visibility: "private" | "org"; now: string; created: ProvisionedContentSpaces["created"]; @@ -249,6 +251,10 @@ export async function provisionContentSpaces( .databaseId, catalogDatabaseId: systemIdsForContentSpace(personalSpaceId, "workspaces") .databaseId, + favoritesDatabaseId: systemIdsForContentSpace(personalSpaceId, "favorites") + .databaseId, + favoritesDocumentId: systemIdsForContentSpace(personalSpaceId, "favorites") + .documentId, spaceIds: [], created: { spaces: 0, databases: 0, documents: 0, catalogItems: 0 }, }; @@ -302,6 +308,26 @@ export async function provisionContentSpaces( now, created: result.created, }); + const [existingFavorites] = await tx + .select({ id: schema.contentDatabases.id }) + .from(schema.contentDatabases) + .where( + and( + eq(schema.contentDatabases.spaceId, personalSpaceId), + eq(schema.contentDatabases.systemRole, "favorites"), + ), + ); + const favorites = await ensureSystemDatabase({ + db: tx, + spaceId: personalSpaceId, + ownerEmail: email, + orgId: null, + title: "Favorites", + role: "favorites", + visibility: "private", + now, + created: result.created, + }); const spaces = [ { @@ -364,6 +390,54 @@ export async function provisionContentSpaces( .where(eq(schema.contentSpaces.id, space.id)); } } + if (!existingFavorites) { + const accessibleSpaceIds = spaces.map((space) => space.id); + const legacyFavorites: Array = + await tx + .select() + .from(schema.documents) + .where( + and( + eq(schema.documents.isFavorite, 1), + inArray(schema.documents.spaceId, accessibleSpaceIds), + ), + ); + const roleByOrgId = new Map( + memberships.map((membership) => [membership.orgId, membership.role]), + ); + const visibleLegacyFavorites = legacyFavorites.filter((document) => { + if (!document.orgId) { + return normalizeContentSpaceEmail(document.ownerEmail) === email; + } + return ( + normalizeContentSpaceEmail(document.ownerEmail) === email || + document.visibility === "org" || + document.visibility === "public" || + roleByOrgId.get(document.orgId) === "owner" || + roleByOrgId.get(document.orgId) === "admin" + ); + }); + if (visibleLegacyFavorites.length > 0) { + await tx + .insert(schema.contentDatabaseItems) + .values( + visibleLegacyFavorites.map((document, position) => ({ + id: opaqueId( + "content_database_item", + `${favorites.id}:${document.id}`, + ), + ownerEmail: email, + orgId: null, + databaseId: favorites.id, + documentId: document.id, + position, + createdAt: now, + updatedAt: now, + })), + ) + .onConflictDoNothing(); + } + } const accessibleIds = new Set(spaces.map((space) => space.id)); for (const [index, space] of spaces.entries()) { if (!accessibleIds.has(space.id)) continue; diff --git a/templates/content/actions/_database-utils.ts b/templates/content/actions/_database-utils.ts index bf0712cdbf..e4b2ed0904 100644 --- a/templates/content/actions/_database-utils.ts +++ b/templates/content/actions/_database-utils.ts @@ -2,12 +2,17 @@ import { getRequestOrgId, getRequestUserEmail, } from "@agent-native/core/server/request-context"; -import { ROLE_RANK, type ShareRole } from "@agent-native/core/sharing"; +import { + accessFilter, + ROLE_RANK, + type ShareRole, +} from "@agent-native/core/sharing"; import { and, asc, eq, inArray, isNull, or, sql } from "drizzle-orm"; import { getDb, schema } from "../server/db/index.js"; import { getDocumentContextPath } from "../server/lib/document-context.js"; import { + documentDiscoveryFilter, parseDocumentFavorite, parseDocumentHideFromSearch, } from "../server/lib/documents.js"; @@ -16,6 +21,8 @@ import type { ContentDatabaseMembership, ContentDatabaseResponse, } from "../shared/api.js"; +import { favoriteDocumentIds } from "./_content-favorites.js"; +import { listContentOrganizationMemberships } from "./_content-space-access.js"; import { getAllContentDatabaseSourceSnapshots } from "./_database-source-utils.js"; import { applyFederatedOverlayValues, @@ -235,6 +242,7 @@ function serializeDocument( doc: DocumentListRow, membership?: DatabaseMembershipRow, shareRole?: ShareRole, + isFavorite?: boolean, ) { const isOwner = doc.ownerEmail.trim().toLowerCase() === @@ -250,7 +258,7 @@ function serializeDocument( description: doc.description, icon: doc.icon, position: doc.position, - isFavorite: parseDocumentFavorite(doc.isFavorite), + isFavorite: isFavorite ?? parseDocumentFavorite(doc.isFavorite), hideFromSearch: parseDocumentHideFromSearch(doc.hideFromSearch), visibility: doc.visibility, accessRole, @@ -291,6 +299,46 @@ export async function getContentDatabaseResponse( // shared one a viewer is opening) must not mutate schema. const { limit, offset } = normalizeContentDatabasePageOptions(options); + const userEmail = getRequestUserEmail(); + const activeOrgId = getRequestOrgId(); + const authorizedOrgIds = + database.systemRole === "favorites" && userEmail + ? [ + ...new Set([ + ...(await listContentOrganizationMemberships(userEmail)).map( + (membership) => membership.orgId, + ), + ...(activeOrgId ? [activeOrgId] : []), + ]), + ] + : []; + const favoritesVisibleDocumentIds = + database.systemRole === "favorites" && userEmail + ? ( + await db + .select({ id: schema.documents.id }) + .from(schema.documents) + .where( + and( + or( + accessFilter(schema.documents, schema.documentShares, { + userEmail, + }), + ...authorizedOrgIds.map((orgId) => + accessFilter(schema.documents, schema.documentShares, { + userEmail, + orgId, + }), + ), + ), + documentDiscoveryFilter({ + userEmail, + orgIds: authorizedOrgIds, + }), + ), + ) + ).map((document) => document.id) + : null; const organizationFilesItemFilter = database.systemRole === "files" && database.orgId ? sql`exists ( @@ -304,6 +352,14 @@ export async function getContentDatabaseResponse( const visibleItemFilter = and( eq(schema.contentDatabaseItems.databaseId, databaseId), organizationFilesItemFilter, + favoritesVisibleDocumentIds + ? favoritesVisibleDocumentIds.length > 0 + ? inArray( + schema.contentDatabaseItems.documentId, + favoritesVisibleDocumentIds, + ) + : sql`1 = 0` + : undefined, ); const [itemCount] = await db .select({ count: sql`COUNT(*)` }) @@ -332,23 +388,37 @@ export async function getContentDatabaseResponse( schema.documents.id, items.map((item) => item.documentId), ), - database.systemRole === "files" && database.orgId - ? and( - eq(schema.documents.orgId, database.orgId), - or( - eq(schema.documents.visibility, "org"), - eq(schema.documents.visibility, "public"), - ), - or( - eq(schema.documents.hideFromSearch, 0), - isNull(schema.documents.hideFromSearch), - ), - ) - : eq(schema.documents.ownerEmail, database.ownerEmail), + database.systemRole === "favorites" + ? favoritesVisibleDocumentIds?.length + ? inArray(schema.documents.id, favoritesVisibleDocumentIds) + : sql`1 = 0` + : database.systemRole === "files" && database.orgId + ? and( + eq(schema.documents.orgId, database.orgId), + or( + eq(schema.documents.visibility, "org"), + eq(schema.documents.visibility, "public"), + ), + or( + eq(schema.documents.hideFromSearch, 0), + isNull(schema.documents.hideFromSearch), + ), + ) + : eq(schema.documents.ownerEmail, database.ownerEmail), ), ) : []; const documentById = new Map(documents.map((doc) => [doc.id, doc])); + const favorites = + database.systemRole === "favorites" + ? new Set(documents.map((document) => document.id)) + : userEmail + ? await favoriteDocumentIds( + db, + userEmail, + documents.map((document) => document.id), + ) + : new Set(); const shareRoleByDocumentId = new Map(); if (documents.length > 0) { const principalClauses: NonNullable>[] = []; @@ -464,6 +534,7 @@ export async function getContentDatabaseResponse( bodyHydrationQueueId: bodyHydrationQueued ? item.id : null, }, shareRoleByDocumentId.get(document.id), + favorites.has(document.id), ), position: item.position, bodyHydration: serializeBodyHydration(item, { @@ -629,6 +700,7 @@ export async function getDatabaseItemByDocumentId( .orderBy( sql`CASE WHEN ${schema.contentDatabaseSourceRows.sourceId} IS NOT NULL THEN 0 ELSE 1 END`, sql`CASE WHEN ${schema.contentDatabases.systemRole} IS NULL THEN 0 ELSE 1 END`, + sql`CASE WHEN ${schema.contentDatabases.systemRole} = 'files' THEN 0 ELSE 1 END`, asc(schema.contentDatabases.id), ); return row ?? null; diff --git a/templates/content/actions/content-spaces.db.test.ts b/templates/content/actions/content-spaces.db.test.ts index 14973c939a..dcf2042e8c 100644 --- a/templates/content/actions/content-spaces.db.test.ts +++ b/templates/content/actions/content-spaces.db.test.ts @@ -24,6 +24,9 @@ let ensureContentSpacesAction: typeof import("./ensure-content-spaces.js").defau let createContentSpaceAction: typeof import("./create-content-space.js").default; let listDocumentsAction: typeof import("./list-documents.js").default; let getDocumentAction: typeof import("./get-document.js").default; +let getContentDatabaseAction: typeof import("./get-content-database.js").default; +let updateDocumentAction: typeof import("./update-document.js").default; +let setDocumentPropertyAction: typeof import("./set-document-property.js").default; let deleteContentDatabaseAction: typeof import("./delete-content-database.js").default; let deleteDocumentAction: typeof import("./delete-document.js").default; let deleteDatabaseItemsAction: typeof import("./delete-database-items.js").default; @@ -53,6 +56,11 @@ beforeAll(async () => { .default; listDocumentsAction = (await import("./list-documents.js")).default; getDocumentAction = (await import("./get-document.js")).default; + getContentDatabaseAction = (await import("./get-content-database.js")) + .default; + updateDocumentAction = (await import("./update-document.js")).default; + setDocumentPropertyAction = (await import("./set-document-property.js")) + .default; deleteContentDatabaseAction = (await import("./delete-content-database.js")) .default; deleteDocumentAction = (await import("./delete-document.js")).default; @@ -269,7 +277,7 @@ describe("Content space provisioning", () => { expect(["Alpha", "Beta"]).toContain(space.name); }); - it("is idempotent, opaque, and creates exactly one Files and Workspaces database", async () => { + it("is idempotent, opaque, and creates exactly one Files, Workspaces, and Favorites database", async () => { const first = await runWithRequestContext({ userEmail: OWNER }, () => provisionContentSpaces(getDb(), OWNER), ); @@ -297,6 +305,9 @@ describe("Content space provisioning", () => { expect( databases.filter((database: any) => database.systemRole === "workspaces"), ).toHaveLength(1); + expect( + databases.filter((database: any) => database.systemRole === "favorites"), + ).toHaveLength(1); const files = databases.find( (database: any) => database.systemRole === "files", ); @@ -322,6 +333,8 @@ describe("Content space provisioning", () => { expect(filesItems).toHaveLength(0); await runWithRequestContext({ userEmail: OWNER }, async () => { await expect(listContentSpacesAction.run({})).resolves.toMatchObject({ + favoritesDatabaseId: first.favoritesDatabaseId, + favoritesDocumentId: first.favoritesDocumentId, spaces: expect.arrayContaining([ expect.objectContaining({ id: first.personalSpaceId, @@ -426,6 +439,96 @@ describe("Content space provisioning", () => { ]); }); + it("uses Favorites membership as a per-user state without replacing Files membership", async () => { + const provisioned = await runWithRequestContext( + { userEmail: WORKSPACE_OWNER }, + () => provisionContentSpaces(getDb(), WORKSPACE_OWNER), + ); + const now = new Date().toISOString(); + await getDb().insert(schema.documents).values({ + id: "favorite-membership-page", + ownerEmail: WORKSPACE_OWNER, + orgId: null, + spaceId: provisioned.personalSpaceId, + title: "Membership page", + visibility: "private", + createdAt: now, + updatedAt: now, + }); + await runWithRequestContext({ userEmail: WORKSPACE_OWNER }, () => + ensureContentSpacesAction.run({}), + ); + + const favorite = await runWithRequestContext( + { userEmail: WORKSPACE_OWNER }, + () => + updateDocumentAction.run({ + id: "favorite-membership-page", + isFavorite: true, + reuseLabels: [], + }), + ); + expect(favorite.isFavorite).toBe(true); + const memberships = await getDb() + .select({ databaseId: schema.contentDatabaseItems.databaseId }) + .from(schema.contentDatabaseItems) + .where( + eq(schema.contentDatabaseItems.documentId, "favorite-membership-page"), + ); + expect(memberships.map((row) => row.databaseId)).toEqual( + expect.arrayContaining([ + provisioned.personalFilesDatabaseId, + provisioned.favoritesDatabaseId, + ]), + ); + + const unfavorite = await runWithRequestContext( + { userEmail: WORKSPACE_OWNER }, + () => + updateDocumentAction.run({ + id: "favorite-membership-page", + isFavorite: false, + reuseLabels: [], + }), + ); + expect(unfavorite.isFavorite).toBe(false); + const remainingMemberships = await getDb() + .select({ databaseId: schema.contentDatabaseItems.databaseId }) + .from(schema.contentDatabaseItems) + .where( + eq(schema.contentDatabaseItems.documentId, "favorite-membership-page"), + ); + expect(remainingMemberships.map((row) => row.databaseId)).toContain( + provisioned.personalFilesDatabaseId, + ); + expect(remainingMemberships.map((row) => row.databaseId)).not.toContain( + provisioned.favoritesDatabaseId, + ); + + await runWithRequestContext({ userEmail: WORKSPACE_OWNER }, () => + updateDocumentAction.run({ + id: "favorite-membership-page", + isFavorite: true, + reuseLabels: [], + }), + ); + const removed = await runWithRequestContext( + { userEmail: WORKSPACE_OWNER }, + () => + deleteDocumentAction.run({ + id: "favorite-membership-page", + databaseDocumentId: provisioned.favoritesDocumentId, + }), + ); + expect(removed).toEqual({ success: true, deleted: 0, removed: 1 }); + await expect( + getDb() + .select({ id: schema.documents.id }) + .from(schema.documents) + .where(eq(schema.documents.id, "favorite-membership-page")), + ).resolves.toHaveLength(1); + }); + it("preserves the Content workspace name when the organization is renamed", async () => { const orgId = "org-renamed"; const spaceId = organizationContentSpaceId(orgId); @@ -567,6 +670,7 @@ describe("Content space provisioning", () => { }); it("lists favorites across every authorized organization without admitting private or unrelated rows", async () => { + const favoritesMember = "favorites-member@example.com"; await addOrganization("org-favorites-a", "Favorites A"); await addOrganization("org-favorites-b", "Favorites B"); await addOrganization("org-favorites-other", "Favorites Other"); @@ -578,8 +682,8 @@ describe("Content space provisioning", () => { OWNER, "owner", ); - await addMember("member-favorites-a", "org-favorites-a", MEMBER); - await addMember("member-favorites-b", "org-favorites-b", MEMBER); + await addMember("member-favorites-a", "org-favorites-a", favoritesMember); + await addMember("member-favorites-b", "org-favorites-b", favoritesMember); await runWithRequestContext({ userEmail: OWNER }, () => provisionContentSpaces(getDb(), OWNER), ); @@ -633,8 +737,13 @@ describe("Content space provisioning", () => { }, ]); + const memberProvisioned = await runWithRequestContext( + { userEmail: favoritesMember, orgId: "org-favorites-a" }, + () => provisionContentSpaces(getDb(), favoritesMember), + ); + const result = await runWithRequestContext( - { userEmail: MEMBER, orgId: "org-favorites-a" }, + { userEmail: favoritesMember, orgId: "org-favorites-a" }, () => listDocumentsAction.run({}), ); const ids = result.documents.map((document) => document.id); @@ -643,6 +752,63 @@ describe("Content space provisioning", () => { ); expect(ids).not.toContain("private-org-a"); expect(ids).not.toContain("favorite-unrelated"); + const favorites = await runWithRequestContext( + { userEmail: favoritesMember, orgId: "org-favorites-a" }, + () => + getContentDatabaseAction.run({ + databaseId: memberProvisioned.favoritesDatabaseId, + }), + ); + expect(favorites).toMatchObject({ + database: { systemRole: "favorites" }, + items: expect.arrayContaining([ + expect.objectContaining({ + document: expect.objectContaining({ id: "favorite-org-a" }), + }), + expect.objectContaining({ + document: expect.objectContaining({ id: "favorite-org-b" }), + }), + ]), + }); + expect( + "items" in favorites + ? favorites.items.map((item) => item.document.id) + : [], + ).not.toEqual( + expect.arrayContaining(["private-org-a", "favorite-unrelated"]), + ); + + await getDb().insert(schema.documentPropertyDefinitions).values({ + id: "favorites-personal-note", + ownerEmail: favoritesMember, + orgId: null, + databaseId: memberProvisioned.favoritesDatabaseId, + name: "Personal note", + type: "text", + position: 10, + createdAt: now, + updatedAt: now, + }); + const propertyUpdate = await runWithRequestContext( + { userEmail: favoritesMember, orgId: "org-favorites-a" }, + () => + setDocumentPropertyAction.run({ + documentId: "favorite-org-b", + propertyId: "favorites-personal-note", + value: "Cross-workspace metadata", + }), + ); + expect(propertyUpdate).toMatchObject({ + databaseId: memberProvisioned.favoritesDatabaseId, + properties: expect.arrayContaining([ + expect.objectContaining({ + definition: expect.objectContaining({ + id: "favorites-personal-note", + }), + value: "Cross-workspace metadata", + }), + ]), + }); }); it("denies an unrelated authenticated user from a personal space", async () => { diff --git a/templates/content/actions/delete-database-items.ts b/templates/content/actions/delete-database-items.ts index 00fe8ab5c3..d1c9248af3 100644 --- a/templates/content/actions/delete-database-items.ts +++ b/templates/content/actions/delete-database-items.ts @@ -1,8 +1,9 @@ import { defineAction } from "@agent-native/core"; import { writeAppState } from "@agent-native/core/application-state"; import { assertAccess } from "@agent-native/core/sharing"; +import { inArray } from "drizzle-orm"; -import { getDb } from "../server/db/index.js"; +import { getDb, schema } from "../server/db/index.js"; import { assertNotWorkspaceCatalogDocuments } from "./_content-space-catalog-guards.js"; import { databaseRowBatchSchema, @@ -21,6 +22,30 @@ export default defineAction({ const { database, rows } = await resolveDatabaseRowsForBatch(args); await assertAccess("document", database.documentId, "editor"); + if (database.systemRole === "favorites") { + const removedItemIds = rows.map((row) => row.item.id); + const removedDocumentIds = rows.map((row) => row.document.id); + const now = new Date().toISOString(); + await db.transaction(async (tx) => { + await tx + .delete(schema.contentDatabaseItems) + .where(inArray(schema.contentDatabaseItems.id, removedItemIds)); + await renumberDatabaseRows( + tx as unknown as ReturnType, + database, + now, + ); + }); + await writeAppState("refresh-signal", { ts: Date.now() }); + return { + ...(await getContentDatabaseResponse(database.id)), + deletedItemIds: removedItemIds, + deletedDocumentIds: [], + deletedCount: 0, + removedDocumentIds, + removedCount: removedItemIds.length, + }; + } for (const row of rows) { await assertAccess("document", row.document.id, "admin"); } diff --git a/templates/content/actions/delete-document.ts b/templates/content/actions/delete-document.ts index 49fb697e2d..727464bf19 100644 --- a/templates/content/actions/delete-document.ts +++ b/templates/content/actions/delete-document.ts @@ -357,15 +357,50 @@ export default defineAction({ description: "Delete a document and all its children recursively.", schema: z.object({ id: z.string().optional().describe("Document ID (required)"), + databaseDocumentId: z + .string() + .optional() + .describe("Database page the deletion was initiated from"), }), run: async (args) => { const id = args.id; if (!id) throw new Error("--id is required"); + const db = getDb(); + if (args.databaseDocumentId) { + const [contextDatabase] = await db + .select() + .from(schema.contentDatabases) + .where( + and( + eq(schema.contentDatabases.documentId, args.databaseDocumentId), + eq(schema.contentDatabases.systemRole, "favorites"), + ), + ); + if (contextDatabase) { + await assertAccess("document", contextDatabase.documentId, "editor"); + const [membership] = await db + .select({ id: schema.contentDatabaseItems.id }) + .from(schema.contentDatabaseItems) + .where( + and( + eq(schema.contentDatabaseItems.databaseId, contextDatabase.id), + eq(schema.contentDatabaseItems.documentId, id), + ), + ); + if (!membership) { + throw new Error("Document is not part of Favorites"); + } + await db + .delete(schema.contentDatabaseItems) + .where(eq(schema.contentDatabaseItems.id, membership.id)); + await writeAppState("refresh-signal", { ts: Date.now() }); + return { success: true, deleted: 0, removed: 1 }; + } + } + const access = await assertAccess("document", id, "admin"); const existing = access.resource; - - const db = getDb(); const [systemDatabase] = await db .select({ systemRole: schema.contentDatabases.systemRole }) .from(schema.contentDatabases) diff --git a/templates/content/actions/get-document.ts b/templates/content/actions/get-document.ts index 8c01285ed4..77f6895c78 100644 --- a/templates/content/actions/get-document.ts +++ b/templates/content/actions/get-document.ts @@ -1,14 +1,13 @@ import { defineAction } from "@agent-native/core"; import { buildDeepLink } from "@agent-native/core/server"; +import { getRequestUserEmail } from "@agent-native/core/server/request-context"; import { resolveAccess } from "@agent-native/core/sharing"; import { eq } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; -import { - parseDocumentFavorite, - parseDocumentHideFromSearch, -} from "../server/lib/documents.js"; +import { parseDocumentHideFromSearch } from "../server/lib/documents.js"; +import { favoriteDocumentIds } from "./_content-favorites.js"; import { resolveContentSpaceAccess } from "./_content-space-access.js"; import { getDatabaseByDocumentId, @@ -79,6 +78,10 @@ export default defineAction({ const doc = access.resource; const database = await getDatabaseByDocumentId(doc.id); const databaseMembership = await getDatabaseItemByDocumentId(doc.id); + const userEmail = getRequestUserEmail(); + const favoriteIds = userEmail + ? await favoriteDocumentIds(getDb(), userEmail, [doc.id]) + : new Set(); return { id: doc.id, @@ -93,7 +96,7 @@ export default defineAction({ description: doc.description, icon: doc.icon, position: doc.position, - isFavorite: parseDocumentFavorite(doc.isFavorite), + isFavorite: favoriteIds.has(doc.id), hideFromSearch: parseDocumentHideFromSearch(doc.hideFromSearch), visibility: doc.visibility, source: serializeDocumentSource(doc), diff --git a/templates/content/actions/import-content-source.db.test.ts b/templates/content/actions/import-content-source.db.test.ts index 869f6e1da7..02bf700cd1 100644 --- a/templates/content/actions/import-content-source.db.test.ts +++ b/templates/content/actions/import-content-source.db.test.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import { getDbExec } from "@agent-native/core/db"; import { runWithRequestContext } from "@agent-native/core/server"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { serializeContentSourceDocument } from "../shared/content-source.js"; @@ -64,6 +64,21 @@ function sourceWithDescription(description: string) { }); } +function sourceWithFavorite(isFavorite: boolean) { + return serializeContentSourceDocument({ + id: "doc_favorite_roundtrip", + parentId: null, + title: "Favorite round-trip", + description: "", + content: "Body", + icon: null, + position: 0, + isFavorite, + hideFromSearch: false, + visibility: "private", + }); +} + describe("import-content-source descriptions", () => { it("requires editor access before importing into an organization space", async () => { await getDbExec().execute({ @@ -167,4 +182,67 @@ describe("import-content-source descriptions", () => { expect.objectContaining({ id: "doc_description_roundtrip", path }), ]); }); + + it("uses Favorites membership rather than the legacy document flag", async () => { + const path = "content/favorite-round-trip--doc_favorite_roundtrip.mdx"; + const provisioned = await runWithRequestContext({ userEmail: OWNER }, () => + provisionContentSpaces(getDb(), OWNER), + ); + + await runWithRequestContext({ userEmail: OWNER }, () => + importContentSourceAction.run({ + files: { [path]: sourceWithFavorite(true) }, + dryRun: false, + }), + ); + await expect( + getDb() + .select({ id: schema.contentDatabaseItems.id }) + .from(schema.contentDatabaseItems) + .where( + and( + eq( + schema.contentDatabaseItems.databaseId, + provisioned.favoritesDatabaseId, + ), + eq( + schema.contentDatabaseItems.documentId, + "doc_favorite_roundtrip", + ), + ), + ), + ).resolves.toHaveLength(1); + + await getDb() + .update(schema.documents) + .set({ isFavorite: 0 }) + .where(eq(schema.documents.id, "doc_favorite_roundtrip")); + const removed = await runWithRequestContext({ userEmail: OWNER }, () => + importContentSourceAction.run({ + files: { [path]: sourceWithFavorite(false) }, + dryRun: false, + }), + ); + + expect(removed.updated).toEqual([ + expect.objectContaining({ id: "doc_favorite_roundtrip", path }), + ]); + await expect( + getDb() + .select({ id: schema.contentDatabaseItems.id }) + .from(schema.contentDatabaseItems) + .where( + and( + eq( + schema.contentDatabaseItems.databaseId, + provisioned.favoritesDatabaseId, + ), + eq( + schema.contentDatabaseItems.documentId, + "doc_favorite_roundtrip", + ), + ), + ), + ).resolves.toEqual([]); + }); }); diff --git a/templates/content/actions/import-content-source.ts b/templates/content/actions/import-content-source.ts index ffd53ad42a..747a49a1d9 100644 --- a/templates/content/actions/import-content-source.ts +++ b/templates/content/actions/import-content-source.ts @@ -15,6 +15,10 @@ import { parseContentSourceFile, type ParsedContentSourceFile, } from "../shared/content-source.js"; +import { + favoriteDocumentIds, + setFavoriteMembership, +} from "./_content-favorites.js"; import { ensureDocumentsFilesMembership } from "./_content-files.js"; import { resolveContentSpaceAccess } from "./_content-space-access.js"; import { @@ -231,6 +235,16 @@ export default defineAction({ .filter((document) => document.sourcePath) .map((document) => [document.sourcePath!, document]), ); + const existingFavoriteIds = await favoriteDocumentIds( + db, + currentUserEmail, + [ + ...new Set([ + ...parsed.flatMap((file) => (file.id ? [file.id] : [])), + ...existingLocalDocs.map((document) => document.id), + ]), + ], + ); const seenIds = new Set(); const duplicateIds = new Set(); @@ -311,7 +325,7 @@ export default defineAction({ file.icon !== undefined && file.icon !== existing.icon; const favoriteChanged = file.isFavorite !== undefined && - boolToInt(file.isFavorite) !== (existing.isFavorite ?? 0); + file.isFavorite !== existingFavoriteIds.has(id); const discoverabilityChanged = file.hideFromSearch !== undefined && boolToInt(file.hideFromSearch) !== (existing.hideFromSearch ?? 0); @@ -363,6 +377,15 @@ export default defineAction({ .update(schema.documents) .set(updates) .where(eq(schema.documents.id, id)); + if (favoriteChanged) { + await setFavoriteMembership({ + db, + userEmail: currentUserEmail, + documentId: id, + favorite: file.isFavorite === true, + now, + }); + } } updated.push({ id, path: file.path, title: file.title }); @@ -389,6 +412,15 @@ export default defineAction({ createdAt: now, updatedAt: now, }); + if (file.isFavorite) { + await setFavoriteMembership({ + db, + userEmail: currentUserEmail, + documentId: id, + favorite: true, + now, + }); + } } catch (err) { errors.push({ path: file.path, diff --git a/templates/content/actions/list-content-spaces.ts b/templates/content/actions/list-content-spaces.ts index 254560b454..36aa496985 100644 --- a/templates/content/actions/list-content-spaces.ts +++ b/templates/content/actions/list-content-spaces.ts @@ -26,6 +26,7 @@ export default defineAction({ const db = getDb(); const personalSpaceId = personalContentSpaceId(email); const catalogIds = systemIdsForContentSpace(personalSpaceId, "workspaces"); + const favoritesIds = systemIdsForContentSpace(personalSpaceId, "favorites"); const memberships = await listContentOrganizationMemberships(email); const roleByOrgId = new Map( memberships.map((membership) => [ @@ -66,14 +67,15 @@ export default defineAction({ ), ); const filesDatabaseIds = rows.map((row) => row.space.filesDatabaseId); - const filesDatabases = filesDatabaseIds.length + const databaseIds = [...filesDatabaseIds, favoritesIds.databaseId]; + const filesDatabases = databaseIds.length ? await db .select({ id: schema.contentDatabases.id, documentId: schema.contentDatabases.documentId, }) .from(schema.contentDatabases) - .where(inArray(schema.contentDatabases.id, filesDatabaseIds)) + .where(inArray(schema.contentDatabases.id, databaseIds)) : []; const filesDocumentIdByDatabaseId = new Map( filesDatabases.map((database) => [database.id, database.documentId]), @@ -117,6 +119,16 @@ export default defineAction({ catalogDocumentId: row.mapping.documentId, }); } - return { catalogDatabaseId: catalogIds.databaseId, spaces }; + return { + catalogDatabaseId: catalogIds.databaseId, + favoritesDatabaseId: filesDocumentIdByDatabaseId.has( + favoritesIds.databaseId, + ) + ? favoritesIds.databaseId + : null, + favoritesDocumentId: + filesDocumentIdByDatabaseId.get(favoritesIds.databaseId) ?? null, + spaces, + }; }, }); diff --git a/templates/content/actions/list-documents.ts b/templates/content/actions/list-documents.ts index 2e2a96f6d6..91f651698f 100644 --- a/templates/content/actions/list-documents.ts +++ b/templates/content/actions/list-documents.ts @@ -14,9 +14,9 @@ import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; import { documentDiscoveryFilter, - parseDocumentFavorite, parseDocumentHideFromSearch, } from "../server/lib/documents.js"; +import { favoriteDocumentIds } from "./_content-favorites.js"; import { listContentOrganizationMemberships } from "./_content-space-access.js"; import { serializeDatabaseMembership } from "./_database-utils.js"; import { serializeDocumentSource } from "./_document-source.js"; @@ -128,6 +128,13 @@ export default defineAction({ } >(); const softDeletedDocumentIds = new Set(); + const favoriteIds = userEmail + ? await favoriteDocumentIds( + db, + userEmail, + documents.map((document) => document.id), + ) + : new Set(); if (documents.length > 0) { const visibleDocumentIds = documents.map((d) => d.id); @@ -194,6 +201,7 @@ export default defineAction({ ) .orderBy( sql`CASE WHEN ${schema.contentDatabases.systemRole} IS NULL THEN 0 ELSE 1 END`, + sql`CASE WHEN ${schema.contentDatabases.systemRole} = 'files' THEN 0 ELSE 1 END`, asc(schema.contentDatabases.id), ), db @@ -315,7 +323,7 @@ export default defineAction({ contentLength: Number(d.contentLength) || 0, icon: d.icon, position: d.position, - isFavorite: parseDocumentFavorite(d.isFavorite), + isFavorite: favoriteIds.has(d.id), hideFromSearch: parseDocumentHideFromSearch(d.hideFromSearch), notionPageId: notionPageIdByDocumentId.get(d.id) ?? null, notionPageUrl: notionPageIdByDocumentId.has(d.id) diff --git a/templates/content/actions/set-document-property.ts b/templates/content/actions/set-document-property.ts index f90e3fdb09..7a698109e7 100644 --- a/templates/content/actions/set-document-property.ts +++ b/templates/content/actions/set-document-property.ts @@ -13,11 +13,12 @@ import { parsePropertyOptions, type DocumentPropertyType, } from "../shared/properties.js"; +import { resolveContentDocumentAccess } from "./_content-document-access.js"; import { - listPropertiesForDocument, + getDatabaseById, + listPropertiesForDatabaseDocuments, nanoid, normalizedValueJson, - resolvePropertyDatabaseForDocument, writeBlockFieldContent, writePrimaryBlocksContent, } from "./_property-utils.js"; @@ -30,26 +31,31 @@ export default defineAction({ value: z.unknown().describe("Value for the property type"), }), run: async ({ documentId, propertyId, value }) => { - const access = await assertAccess("document", documentId, "editor"); - const document = access.resource; const db = getDb(); - const database = await resolvePropertyDatabaseForDocument(document); - if (!database) throw new Error("Document is not part of a database."); - const [definition] = await db .select() .from(schema.documentPropertyDefinitions) + .where(eq(schema.documentPropertyDefinitions.id, propertyId)); + if (!definition) throw new Error(`Property "${propertyId}" not found`); + if (!definition.databaseId) { + throw new Error(`Property "${propertyId}" is not attached to a database`); + } + const database = await getDatabaseById(definition.databaseId); + if (!database) throw new Error("Document database not found."); + await assertAccess("document", database.documentId, "editor"); + const access = await resolveContentDocumentAccess(documentId); + if (!access) throw new Error(`Document "${documentId}" not found`); + const document = access.resource; + const [membership] = await db + .select({ id: schema.contentDatabaseItems.id }) + .from(schema.contentDatabaseItems) .where( and( - eq(schema.documentPropertyDefinitions.id, propertyId), - eq( - schema.documentPropertyDefinitions.ownerEmail, - document.ownerEmail, - ), - eq(schema.documentPropertyDefinitions.databaseId, database.id), + eq(schema.contentDatabaseItems.databaseId, database.id), + eq(schema.contentDatabaseItems.documentId, documentId), ), ); - if (!definition) throw new Error(`Property "${propertyId}" not found`); + if (!membership) throw new Error("Document is not part of this database."); if (definition.systemRole) { throw new Error("System properties are derived and cannot be edited."); } @@ -65,6 +71,7 @@ export default defineAction({ // primary "Content" field writes to the document body; additional Blocks // fields write to their own independent store. if (isBlocksPropertyType(type)) { + await assertAccess("document", documentId, "editor"); const normalized = normalizePropertyValue(type, value); const content = typeof normalized === "string" ? normalized : ""; const target = blocksStorageTarget( @@ -76,7 +83,7 @@ export default defineAction({ await writeBlockFieldContent({ documentId, propertyId, - ownerEmail: document.ownerEmail, + ownerEmail: database.ownerEmail, content, now, }); @@ -85,11 +92,17 @@ export default defineAction({ return { documentId, databaseId: database.id, - properties: await listPropertiesForDocument({ - ...document, - content: target === "document_body" ? content : document.content, - updatedAt: now, - }), + properties: + ( + await listPropertiesForDatabaseDocuments(database.id, [ + { + ...document, + content: + target === "document_body" ? content : document.content, + updatedAt: now, + }, + ]) + ).get(documentId) ?? [], }; } @@ -112,7 +125,7 @@ export default defineAction({ } else { await db.insert(schema.documentPropertyValues).values({ id: nanoid(), - ownerEmail: document.ownerEmail, + ownerEmail: database.ownerEmail, documentId, propertyId, valueJson, @@ -126,7 +139,10 @@ export default defineAction({ return { documentId, databaseId: database.id, - properties: await listPropertiesForDocument(document), + properties: + (await listPropertiesForDatabaseDocuments(database.id, [document])).get( + documentId, + ) ?? [], }; }, }); diff --git a/templates/content/actions/share-local-file-document.ts b/templates/content/actions/share-local-file-document.ts index d86595b38e..ff20b94481 100644 --- a/templates/content/actions/share-local-file-document.ts +++ b/templates/content/actions/share-local-file-document.ts @@ -13,6 +13,7 @@ import { parseDocumentFavorite, parseDocumentHideFromSearch, } from "../server/lib/documents.js"; +import { setFavoriteMembership } from "./_content-favorites.js"; import { ensureDocumentFilesMembership } from "./_content-files.js"; import { organizationContentSpaceId, @@ -136,6 +137,13 @@ export default defineAction({ .where(eq(schema.documents.id, existing.id)); await ensureDocumentFilesMembership(db, existing.id, now); + await setFavoriteMembership({ + db, + userEmail, + documentId: existing.id, + favorite: localDocument.isFavorite, + now, + }); const [row] = await db .select() @@ -182,6 +190,13 @@ export default defineAction({ updatedAt: now, }); await ensureDocumentFilesMembership(db, documentId, now); + await setFavoriteMembership({ + db, + userEmail, + documentId, + favorite: localDocument.isFavorite, + now, + }); }, ); diff --git a/templates/content/actions/sync-local-folder-source.ts b/templates/content/actions/sync-local-folder-source.ts index f4db522dbc..ce000309d8 100644 --- a/templates/content/actions/sync-local-folder-source.ts +++ b/templates/content/actions/sync-local-folder-source.ts @@ -14,8 +14,10 @@ import { isContentSourcePath, parseContentSourceFile, } from "../shared/content-source.js"; +import { setFavoriteMembership } from "./_content-favorites.js"; import { ensureDocumentsFilesMembership } from "./_content-files.js"; import { resolveContentSpaceAccess } from "./_content-space-access.js"; +import { provisionContentSpaces } from "./_content-spaces.js"; import { LOCAL_FOLDER_SOURCE_TYPE } from "./_local-folder-source.js"; const MAX_SOURCE_FILES = 500; @@ -175,6 +177,7 @@ export default defineAction({ throw new Error(`Local folder source "${sourceId}" not found`); } await resolveContentSpaceAccess(target.database.spaceId, "editor"); + if (!dryRun) await provisionContentSpaces(db, userEmail); const targetSpaceId = target.database.spaceId; const parsed = entries.map(([path, value]) => @@ -572,6 +575,17 @@ export default defineAction({ } } + for (const plan of plans) { + if (plan.conflict || plan.existing || !plan.file.isFavorite) continue; + await setFavoriteMembership({ + db: tx, + userEmail, + documentId: plan.id, + favorite: true, + now, + }); + } + const materializedIds = plans .filter((plan) => !plan.conflict) .map((plan) => plan.id); diff --git a/templates/content/actions/update-document.ts b/templates/content/actions/update-document.ts index 6151a856b4..63e222aafd 100644 --- a/templates/content/actions/update-document.ts +++ b/templates/content/actions/update-document.ts @@ -1,6 +1,7 @@ import { defineAction } from "@agent-native/core"; import { writeAppState } from "@agent-native/core/application-state"; import { agentTouchDocument } from "@agent-native/core/collab"; +import { getRequestUserEmail } from "@agent-native/core/server/request-context"; import { assertAccess } from "@agent-native/core/sharing"; import { getGenerationCreativeContext, @@ -20,6 +21,12 @@ import { import type { DocumentUpdateResponse } from "../shared/api.js"; import { BUILDER_CMS_BODY_CONTENT_KEY } from "./_builder-cms-source-adapter.js"; import { reconcileInlineDatabasesForDocument } from "./_content-database-lifecycle.js"; +import { resolveContentDocumentAccess } from "./_content-document-access.js"; +import { + favoriteDocumentIds, + setFavoriteMembership, +} from "./_content-favorites.js"; +import { provisionContentSpaces } from "./_content-spaces.js"; import { serializeDocumentSource } from "./_document-source.js"; // Not (yet) part of the shared API surface — kept local to avoid touching @@ -137,6 +144,10 @@ function canManageRole(role: string) { return role === "owner" || role === "admin"; } +function canEditRole(role: string) { + return role === "owner" || role === "admin" || role === "editor"; +} + function builderBodyWithoutImageSourceComponentMarkers( content: string | null | undefined, ) { @@ -304,11 +315,30 @@ export default defineAction({ const isAgentCaller = ctx?.caller === "tool" || ctx?.caller === "mcp" || ctx?.caller === "a2a"; - const access = await assertAccess("document", id, "editor"); + const favoriteOnly = + args.isFavorite !== undefined && + args.title === undefined && + args.content === undefined && + args.description === undefined && + args.icon === undefined; + const access = favoriteOnly + ? await resolveContentDocumentAccess(id) + : await assertAccess("document", id, "editor"); + if (!access) throw new Error(`Document "${id}" not found`); const existing = access.resource; const ownerEmail = existing.ownerEmail as string; const db = getDb(); + const requestUserEmail = getRequestUserEmail(); + if (args.isFavorite !== undefined && !requestUserEmail) { + throw new Error("no authenticated user"); + } + if (args.isFavorite !== undefined) { + await provisionContentSpaces(db, requestUserEmail as string); + } + const currentFavorite = requestUserEmail + ? (await favoriteDocumentIds(db, requestUserEmail, [id])).has(id) + : parseDocumentFavorite(existing.isFavorite); // Strip leading H1 that duplicates the title let content = args.content; @@ -383,11 +413,12 @@ export default defineAction({ content !== undefined && content !== existing.content; const iconChanged = args.icon !== undefined && args.icon !== existing.icon; const favoriteChanged = - args.isFavorite !== undefined && - (args.isFavorite ? 1 : 0) !== (existing.isFavorite ?? 0); + args.isFavorite !== undefined && args.isFavorite !== currentFavorite; const descriptionChanged = args.description !== undefined && args.description.trim() !== existing.description; + const documentFieldsChanged = + titleChanged || contentChanged || iconChanged || descriptionChanged; const anyChange = titleChanged || contentChanged || @@ -452,8 +483,6 @@ export default defineAction({ updates.description = args.description.trim(); if (contentChanged) updates.content = content; if (iconChanged) updates.icon = args.icon; - if (favoriteChanged) updates.isFavorite = args.isFavorite ? 1 : 0; - let contentCasConflict = false; await db.transaction(async (tx) => { if (useContentCas) { @@ -471,13 +500,23 @@ export default defineAction({ contentCasConflict = true; return; } - } else { + } else if (documentFieldsChanged) { await tx .update(schema.documents) .set(updates) .where(eq(schema.documents.id, id)); } + if (favoriteChanged) { + await setFavoriteMembership({ + db: tx, + userEmail: requestUserEmail as string, + documentId: id, + favorite: args.isFavorite as boolean, + now: updates.updatedAt as string, + }); + } + if (titleChanged && args.title !== undefined) { const [database] = await tx .select({ @@ -553,11 +592,11 @@ export default defineAction({ description: current.description, icon: current.icon, position: current.position, - isFavorite: parseDocumentFavorite(current.isFavorite), + isFavorite: currentFavorite, hideFromSearch: parseDocumentHideFromSearch(current.hideFromSearch), visibility: current.visibility, accessRole: access.role, - canEdit: true, + canEdit: canEditRole(access.role), canManage: canManageRole(access.role), createdAt: current.createdAt, updatedAt: current.updatedAt, @@ -620,6 +659,9 @@ export default defineAction({ .select() .from(schema.documents) .where(eq(schema.documents.id, id)); + const finalFavorite = requestUserEmail + ? (await favoriteDocumentIds(db, requestUserEmail, [id])).has(id) + : parseDocumentFavorite(doc.isFavorite); await writeAppState("refresh-signal", { ts: Date.now() }); @@ -632,11 +674,11 @@ export default defineAction({ description: doc.description, icon: doc.icon, position: doc.position, - isFavorite: parseDocumentFavorite(doc.isFavorite), + isFavorite: finalFavorite, hideFromSearch: parseDocumentHideFromSearch(doc.hideFromSearch), visibility: doc.visibility, accessRole: access.role, - canEdit: true, + canEdit: canEditRole(access.role), canManage: canManageRole(access.role), createdAt: doc.createdAt, updatedAt: doc.updatedAt, diff --git a/templates/content/app/components/editor/database/DatabaseView.tsx b/templates/content/app/components/editor/database/DatabaseView.tsx index 4d85792f6f..db636f121a 100644 --- a/templates/content/app/components/editor/database/DatabaseView.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.tsx @@ -130,6 +130,7 @@ import { IconPencil, IconRefresh, IconSearch, + IconStarOff, IconTable, IconTimeline, IconTrash, @@ -3990,6 +3991,7 @@ function DatabaseItemPreview({ onOpenPage: () => void; }) { const queryClient = useQueryClient(); + const contentSpaces = useContentSpaces(); const updateDocument = useUpdateDocument(); const deleteDocument = useDeleteDocument(); const duplicateItem = useDuplicateDatabaseItem(databaseDocumentId); @@ -4058,6 +4060,8 @@ function DatabaseItemPreview({ const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); const [openingFullPage, setOpeningFullPage] = useState(false); const titleInputRef = useRef(null); + const removesFavoriteMembership = + contentSpaces.data?.favoritesDocumentId === databaseDocumentId; // The peek's primary title+body save runs through a flush-on-release controller // so a pending debounced edit is PERSISTED — not dropped — when the row @@ -4662,7 +4666,10 @@ function DatabaseItemPreview({ } try { - await deleteDocument.mutateAsync({ id: item.document.id }); + await deleteDocument.mutateAsync({ + id: item.document.id, + databaseDocumentId, + }); await queryClient.invalidateQueries({ queryKey: [ "action", @@ -4747,7 +4754,7 @@ function DatabaseItemPreview({ )} {openingFullPage ? dbText("opening") : dbText("openPage")} - {canEdit || canManage ? ( + {canEdit || canManage || removesFavoriteMembership ? ( ) : null} - {canEdit && canManage ? : null} - {canManage ? ( + {canEdit && (canManage || removesFavoriteMembership) ? ( + + ) : null} + {removesFavoriteMembership ? ( + { + event.preventDefault(); + setActionsMenuOpen(false); + void deletePreviewRow(); + }} + > + + Remove from Favorites + + ) : canManage ? ( { @@ -4934,7 +4954,10 @@ function DatabaseItemPreview({
)} - + {dbText("deleteRow2")} @@ -5063,6 +5086,7 @@ function DatabaseTableView({ onOpenPage: (item: ContentDatabaseItem) => void; }) { const queryClient = useQueryClient(); + const contentSpaces = useContentSpaces(); const moveItem = useMoveDatabaseItem(databaseDocumentId); const duplicateItems = useDuplicateDatabaseItems(databaseDocumentId); const setProperty = useSetDocumentProperty(databaseDocumentId); @@ -5083,6 +5107,8 @@ function DatabaseTableView({ const selectableCount = items.length; const selectedIdSet = new Set(selectedItemIds); const selectedItems = databaseSelectedItems(items, selectedItemIds); + const removesFavoriteMembership = + contentSpaces.data?.favoritesDocumentId === databaseDocumentId; const bulkEditableProperties = databaseBulkEditableProperties(properties); const groups = databaseVisibleGroups( databaseViewItemGroups(items, groupableProperties, groupByPropertyId), @@ -5432,11 +5458,18 @@ function DatabaseTableView({ deleteItems.isPending } deleteDisabled={deleteItems.isPending} + removesFavoriteMembership={removesFavoriteMembership} updateDisabled={setProperty.isPending} onClearSelection={onClearSelection} onSetPropertyValue={setSelectedPropertyValue} onDuplicateSelected={() => void duplicateSelectedRows()} - onDeleteSelected={() => setConfirmDeleteSelectedOpen(true)} + onDeleteSelected={() => { + if (removesFavoriteMembership) { + void deleteSelectedRows(); + return; + } + setConfirmDeleteSelectedOpen(true); + }} /> ) : null}
@@ -14889,6 +14922,7 @@ function DatabaseSelectionBar({ selectedItems, duplicateDisabled, deleteDisabled, + removesFavoriteMembership, updateDisabled, onClearSelection, onSetPropertyValue, @@ -14901,6 +14935,7 @@ function DatabaseSelectionBar({ selectedItems: ContentDatabaseItem[]; duplicateDisabled: boolean; deleteDisabled: boolean; + removesFavoriteMembership: boolean; updateDisabled: boolean; onClearSelection: () => void; onSetPropertyValue: ( @@ -14940,12 +14975,20 @@ function DatabaseSelectionBar({ type="button" variant="ghost" size="sm" - className="h-7 gap-1.5 px-2 text-xs text-destructive hover:bg-destructive/10 hover:text-destructive" + className={cn( + "h-7 gap-1.5 px-2 text-xs", + !removesFavoriteMembership && + "text-destructive hover:bg-destructive/10 hover:text-destructive", + )} disabled={deleteDisabled} onClick={onDeleteSelected} > - - Delete + {removesFavoriteMembership ? ( + + ) : ( + + )} + {removesFavoriteMembership ? "Remove" : "Delete"} ) : null} @@ -18024,11 +18067,14 @@ export function RowActionsCell({ onOpenPage: () => void; }) { const queryClient = useQueryClient(); + const contentSpaces = useContentSpaces(); const deleteDocument = useDeleteDocument(); const duplicateItem = useDuplicateDatabaseItem(databaseDocumentId); const [menuOpen, setMenuOpen] = useState(false); const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); const title = item.document.title || "Untitled"; + const removesFavoriteMembership = + contentSpaces.data?.favoritesDocumentId === databaseDocumentId; async function duplicateRow() { setMenuOpen(false); @@ -18047,7 +18093,10 @@ export function RowActionsCell({ async function deleteRow() { const previewMoved = onDeletedPreviewItem?.(item) ?? false; try { - await deleteDocument.mutateAsync({ id: item.document.id }); + await deleteDocument.mutateAsync({ + id: item.document.id, + databaseDocumentId, + }); await queryClient.invalidateQueries({ queryKey: [ "action", @@ -18101,21 +18150,37 @@ export function RowActionsCell({ {dbText("duplicateRow")} - { - event.preventDefault(); - setMenuOpen(false); - setConfirmDeleteOpen(true); - }} - > - - {dbText("deleteRow")} - + {removesFavoriteMembership ? ( + { + event.preventDefault(); + setMenuOpen(false); + void deleteRow(); + }} + > + + Remove from Favorites + + ) : ( + { + event.preventDefault(); + setMenuOpen(false); + setConfirmDeleteOpen(true); + }} + > + + {dbText("deleteRow")} + + )} - + {dbText("deleteRow2")} diff --git a/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts b/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts index 1d8a88b8c4..b8d0169415 100644 --- a/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts +++ b/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts @@ -316,7 +316,8 @@ describe("document sidebar layout", () => { expect(sidebar).toContain("!collapsedSections.favorites &&"); expect(sidebar).toContain("aria-expanded={!collapsedSections.favorites}"); expect(sidebar).toContain('"mb-2 min-w-0 px-2"'); - expect(sidebar).toContain('to="/favorites"'); + expect(sidebar).toContain("favoritesDocumentId"); + expect(sidebar).toContain("`/page/${favoritesDocumentId}`"); expect(sidebar).toContain("handleOpenFavorite(doc)"); expect(sidebar).toContain(" { expect(sidebar).not.toContain("!localFileMode && favorites.length > 0"); }); - it("renders Favorites as a real table route", () => { + it("routes Favorites into its provisioned full database page", () => { const route = readSidebarSource("../../routes/_app.favorites.tsx"); - expect(route).toContain("useDocuments()"); - expect(route).toContain("document.isFavorite"); - expect(route).toContain(""); - expect(route).toContain("selectContentSpace"); - expect(route).toContain('requestSource: "content-favorites"'); + expect(route).toContain("useContentSpaces()"); + expect(route).toContain("favoritesDocumentId"); + expect(route).toContain(" {t("sidebar.favorites")} diff --git a/templates/content/app/components/sidebar/document-sidebar-sections.ts b/templates/content/app/components/sidebar/document-sidebar-sections.ts index 78bc8387fe..c35a6099be 100644 --- a/templates/content/app/components/sidebar/document-sidebar-sections.ts +++ b/templates/content/app/components/sidebar/document-sidebar-sections.ts @@ -150,6 +150,6 @@ export function getDocumentSidebarSections( localSourceDocuments, databaseDocuments, favorites, - showFavorites: favorites.length > 0, + showFavorites: !localFileMode || favorites.length > 0, }; } diff --git a/templates/content/app/hooks/use-content-spaces.ts b/templates/content/app/hooks/use-content-spaces.ts index b24ba86db4..db1304ab63 100644 --- a/templates/content/app/hooks/use-content-spaces.ts +++ b/templates/content/app/hooks/use-content-spaces.ts @@ -18,6 +18,8 @@ export type ContentSpaceSummary = { export type ListContentSpacesResponse = { catalogDatabaseId: string; + favoritesDatabaseId: string | null; + favoritesDocumentId: string | null; spaces: ContentSpaceSummary[]; }; diff --git a/templates/content/app/hooks/use-documents.ts b/templates/content/app/hooks/use-documents.ts index e7c3a2b958..8db5dc0b87 100644 --- a/templates/content/app/hooks/use-documents.ts +++ b/templates/content/app/hooks/use-documents.ts @@ -398,8 +398,8 @@ export function useUpdateDocument() { export function useDeleteDocument() { const queryClient = useQueryClient(); return useActionMutation< - { success: boolean; deleted: number }, - { id: string } + { success: boolean; deleted: number; removed?: number }, + { id: string; databaseDocumentId?: string } >("delete-document", { onSuccess: (_data, variables) => { queryClient.invalidateQueries({ diff --git a/templates/content/app/routes/_app.favorites.tsx b/templates/content/app/routes/_app.favorites.tsx index 013a0675ed..2f5489f224 100644 --- a/templates/content/app/routes/_app.favorites.tsx +++ b/templates/content/app/routes/_app.favorites.tsx @@ -1,190 +1,30 @@ -import { setClientAppState } from "@agent-native/core/client/hooks"; -import { useT } from "@agent-native/core/client/i18n"; -import type { Document } from "@shared/api"; -import { IconFileText, IconTable } from "@tabler/icons-react"; -import { useRef } from "react"; -import { useNavigate } from "react-router"; -import { toast } from "sonner"; +import { Navigate } from "react-router"; import { QueryErrorState } from "@/components/QueryErrorState"; -import { - createContentSpaceSelectionQueue, - SELECTED_CONTENT_SPACE_STORAGE_KEY, - selectContentSpace, -} from "@/components/sidebar/select-content-space"; import { Skeleton } from "@/components/ui/skeleton"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; import { useContentSpaces } from "@/hooks/use-content-spaces"; -import { useDocuments } from "@/hooks/use-documents"; -import { useLocalStorage } from "@/hooks/use-local-storage"; - -function sourceLabel(document: Document) { - if (document.source?.rootName) return document.source.rootName; - if (document.source?.kind === "file") return "Local folder"; - return "Content"; -} - -function editedDate(updatedAt: string) { - const value = new Date(updatedAt); - if (!Number.isFinite(value.getTime())) return ""; - return value.toLocaleDateString(undefined, { - month: "short", - day: "numeric", - year: - value.getFullYear() === new Date().getFullYear() ? undefined : "numeric", - }); -} export default function FavoritesRoute() { - const t = useT(); - const navigate = useNavigate(); - const documentsQuery = useDocuments(); const contentSpacesQuery = useContentSpaces(); - const selectionQueueRef = useRef(createContentSpaceSelectionQueue()); - const [, setStoredSpaceId] = useLocalStorage( - SELECTED_CONTENT_SPACE_STORAGE_KEY, - null, - ); - const favorites = (documentsQuery.data ?? []).filter( - (document) => document.isFavorite && document.source?.kind !== "folder", - ); - const spaces = contentSpacesQuery.data?.spaces ?? []; - - function workspaceFor(document: Document) { - return spaces.find( - (space) => - space.filesDocumentId === - document.databaseMembership?.databaseDocumentId, - ); - } - function openFavorite(document: Document) { - const space = workspaceFor(document); - if (!space) { - navigate(`/page/${document.id}`); - return; - } - void selectionQueueRef - .current(() => - selectContentSpace({ - space, - syncApplicationState: (selected) => - setClientAppState( - "content-space", - { - spaceId: selected.id, - name: selected.name, - kind: selected.kind, - filesDatabaseId: selected.filesDatabaseId, - }, - { requestSource: "content-favorites" }, - ), - persistSelection: setStoredSpaceId, - openFiles: () => navigate(`/page/${document.id}`), - }), - ) - .catch((error) => { - toast.error(error instanceof Error ? error.message : String(error)); - }); - } - - if (documentsQuery.isError || contentSpacesQuery.isError) { + if (contentSpacesQuery.isError) { return ( { - void documentsQuery.refetch(); - void contentSpacesQuery.refetch(); - }} - retrying={documentsQuery.isFetching || contentSpacesQuery.isFetching} + onRetry={() => void contentSpacesQuery.refetch()} + retrying={contentSpacesQuery.isFetching} /> ); } - return ( -
-
-
-

- {t("sidebar.favorites")} -

-
- - Table -
-
- - {documentsQuery.isLoading || contentSpacesQuery.isLoading ? ( -
- - - -
- ) : favorites.length === 0 ? ( -
- {t("database.noRowsMatchThisView")} -
- ) : ( -
-
- - - Name - Workspace - Source - Edited - - - - {favorites.map((document) => { - const workspace = workspaceFor(document); - const title = document.title || t("sidebar.untitled"); - return ( - openFavorite(document)} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - openFavorite(document); - } - }} - > - - - {document.icon ? ( - {document.icon} - ) : ( - - )} - {title} - - - - {workspace?.name ?? "—"} - - - {sourceLabel(document)} - - - {editedDate(document.updatedAt)} - - - ); - })} - -
-
- )} + const documentId = contentSpacesQuery.data?.favoritesDocumentId; + if (!documentId) { + return ( +
+ +
-
- ); + ); + } + + return ; } diff --git a/templates/content/changelog/2026-07-19-favorites-now-open-as-a-table-and-compact-breadcrumbs-make-w.md b/templates/content/changelog/2026-07-19-favorites-now-open-as-a-table-and-compact-breadcrumbs-make-w.md index 56440b73a7..a13fcb5483 100644 --- a/templates/content/changelog/2026-07-19-favorites-now-open-as-a-table-and-compact-breadcrumbs-make-w.md +++ b/templates/content/changelog/2026-07-19-favorites-now-open-as-a-table-and-compact-breadcrumbs-make-w.md @@ -3,4 +3,4 @@ type: improved date: 2026-07-19 --- -Favorites now open as a table, and compact breadcrumbs make workspace and sibling navigation easier. +Favorites is now a full, filterable database with per-user membership, and compact breadcrumbs make workspace and sibling navigation easier. From 0f4557e9b8694de3be8e454b859f2ee4333cbc76 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Sun, 19 Jul 2026 18:09:55 -0400 Subject: [PATCH 60/76] fix(content): refresh favorites membership cache --- .../content/app/hooks/use-documents.test.ts | 55 +++++++++++++++++++ templates/content/app/hooks/use-documents.ts | 39 +++++++++++-- 2 files changed, 90 insertions(+), 4 deletions(-) diff --git a/templates/content/app/hooks/use-documents.test.ts b/templates/content/app/hooks/use-documents.test.ts index ef5dc6dde3..5972ed5778 100644 --- a/templates/content/app/hooks/use-documents.test.ts +++ b/templates/content/app/hooks/use-documents.test.ts @@ -187,6 +187,61 @@ describe("optimistic document favorites", () => { expect(database.items[0].document.isFavorite).toBe(false); }); + it("removes unfavorited pages from a cached Favorites database", () => { + const database = { + database: { systemRole: "favorites" }, + items: [ + { + id: "item-a", + databaseId: "favorites", + position: 0, + document: { ...doc("a", null), isFavorite: true }, + properties: [], + }, + { + id: "item-b", + databaseId: "favorites", + position: 1, + document: { ...doc("b", null), isFavorite: true }, + properties: [], + }, + ], + pagination: { + offset: 0, + limit: 50, + totalItems: 2, + returnedItems: 2, + hasMore: false, + }, + } as any; + + const updated = setDocumentFavoriteInDatabaseCache(database, "a", false)!; + expect(updated.items.map((item) => item.document.id)).toEqual(["b"]); + expect(updated.pagination).toMatchObject({ + totalItems: 1, + returnedItems: 1, + }); + expect(database.items).toHaveLength(2); + }); + + it("leaves a cached Favorites database unchanged until a newly added row refetches", () => { + const database = { + database: { systemRole: "favorites" }, + items: [], + pagination: { + offset: 0, + limit: 50, + totalItems: 0, + returnedItems: 0, + hasMore: false, + }, + } as any; + + expect(setDocumentFavoriteInDatabaseCache(database, "a", true)).toBe( + database, + ); + }); + it("restores exact cache snapshots after a failed optimistic update", () => { const queryClient = new QueryClient(); const documentKey = documentQueryKey("a"); diff --git a/templates/content/app/hooks/use-documents.ts b/templates/content/app/hooks/use-documents.ts index 8db5dc0b87..923d499b06 100644 --- a/templates/content/app/hooks/use-documents.ts +++ b/templates/content/app/hooks/use-documents.ts @@ -21,7 +21,10 @@ import { databaseItemBodyHydrationIsPending } from "@/components/editor/body-hyd import { isEffectivelyEmptyDocumentContent } from "@/components/editor/body-hydration"; import type { DocumentUpdateConflictResponse } from "../../actions/update-document"; -import { useRestoreContentDatabase } from "./use-content-database"; +import { + removeOptimisticItemFromContentDatabase, + useRestoreContentDatabase, +} from "./use-content-database"; export type { DocumentUpdateConflictResponse }; @@ -129,9 +132,23 @@ export function setDocumentFavoriteInDatabaseCache( documentId: string, isFavorite: boolean, ): ContentDatabaseResponse | undefined { + if (current?.database?.systemRole === "favorites" && !isFavorite) { + return removeOptimisticItemFromContentDatabase(current, documentId); + } return patchDocumentInDatabaseCache(current, documentId, { isFavorite }); } +function patchDocumentWithFavoriteMembershipInDatabaseCache( + current: ContentDatabaseResponse | undefined, + documentId: string, + patch: Partial, +): ContentDatabaseResponse | undefined { + const patched = patchDocumentInDatabaseCache(current, documentId, patch); + return patch.isFavorite === undefined + ? patched + : setDocumentFavoriteInDatabaseCache(patched, documentId, patch.isFavorite); +} + export function patchDocumentCaches( queryClient: Pick, documentId: string, @@ -145,7 +162,12 @@ export function patchDocumentCaches( ); queryClient.setQueriesData( { queryKey: ["action", "get-content-database"] }, - (current) => patchDocumentInDatabaseCache(current, documentId, patch), + (current) => + patchDocumentWithFavoriteMembershipInDatabaseCache( + current, + documentId, + patch, + ), ); } @@ -334,7 +356,7 @@ export function useUpdateDocument() { queryClient.setQueriesData( { queryKey: ["action", "get-content-database"] }, (current) => - patchDocumentInDatabaseCache( + patchDocumentWithFavoriteMembershipInDatabaseCache( current, variables.id, serverDocument, @@ -359,7 +381,11 @@ export function useUpdateDocument() { queryClient.setQueriesData( { queryKey: ["action", "get-content-database"] }, (current) => - patchDocumentInDatabaseCache(current, variables.id, data), + patchDocumentWithFavoriteMembershipInDatabaseCache( + current, + variables.id, + data, + ), ); queryClient.invalidateQueries({ queryKey: ["action", "get-document", { id: variables.id }], @@ -367,6 +393,11 @@ export function useUpdateDocument() { queryClient.invalidateQueries({ queryKey: ["action", "list-documents"], }); + if (variables.isFavorite !== undefined) { + queryClient.invalidateQueries({ + queryKey: ["action", "get-content-database"], + }); + } if (data.softDeletedDatabaseIds.length > 0) { const databaseIds = data.softDeletedDatabaseIds; From c24897da1156e25a74f08e80af642944537f5132 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Sun, 19 Jul 2026 18:18:01 -0400 Subject: [PATCH 61/76] fix(content): localize favorites row actions --- .../components/editor/database/DatabaseView.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/templates/content/app/components/editor/database/DatabaseView.tsx b/templates/content/app/components/editor/database/DatabaseView.tsx index db636f121a..94846834dc 100644 --- a/templates/content/app/components/editor/database/DatabaseView.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.tsx @@ -385,6 +385,7 @@ export function previewDraftMissingCasRecovery(args: { } type DatabaseMessageKey = keyof (typeof messagesByLocale)["en-US"]["database"]; +type SidebarMessageKey = keyof (typeof messagesByLocale)["en-US"]["sidebar"]; export function dbText( key: DatabaseMessageKey, @@ -405,6 +406,15 @@ export function dbText( ); } +function sidebarText(key: SidebarMessageKey): string { + const locale = + typeof document === "undefined" ? "en-US" : document.documentElement.lang; + const messages = + messagesByLocale[locale as keyof typeof messagesByLocale] ?? + messagesByLocale["en-US"]; + return messages.sidebar[key] ?? messagesByLocale["en-US"].sidebar[key]; +} + export type CreateDatabaseRowHandler = ( title?: string, ) => Promise; @@ -4799,7 +4809,7 @@ function DatabaseItemPreview({ }} > - Remove from Favorites + {sidebarText("removeFromFavorites")} ) : canManage ? ( - Remove from Favorites + {sidebarText("removeFromFavorites")} ) : ( Date: Sun, 19 Jul 2026 18:46:12 -0400 Subject: [PATCH 62/76] fix(content): refine sidebar expansion behavior --- .../content/actions/_content-sidebar-state.ts | 24 +++ .../actions/content-sidebar-state.test.ts | 26 +++ .../content/actions/content-spaces.db.test.ts | 5 + .../actions/get-content-sidebar-state.ts | 23 +++ .../content/actions/list-content-spaces.ts | 1 + .../content/actions/set-document-property.ts | 7 +- .../actions/update-content-sidebar-state.ts | 27 ++++ .../editor/DocumentEditor.layout.test.ts | 94 +++++++++++ .../app/components/editor/DocumentEditor.tsx | 94 ++++++++++- .../app/components/editor/DocumentToolbar.tsx | 20 ++- .../editor/database/sidebar.test.tsx | 66 +++++++- .../components/editor/database/sidebar.tsx | 66 +++++--- .../sidebar/DocumentSidebar.layout.test.ts | 12 +- .../components/sidebar/DocumentSidebar.tsx | 152 ++++++++++++++++-- .../content/app/hooks/use-content-spaces.ts | 1 + .../content/app/hooks/use-documents.test.ts | 46 ++++++ templates/content/app/hooks/use-documents.ts | 50 +++--- ...w-open-only-when-expanded-remember-thei.md | 6 + ...now-stays-stable-on-hover-uses-folder-i.md | 6 + templates/content/parity/matrix.md | 56 +++---- templates/content/parity/matrix.ts | 8 +- 21 files changed, 679 insertions(+), 111 deletions(-) create mode 100644 templates/content/actions/_content-sidebar-state.ts create mode 100644 templates/content/actions/content-sidebar-state.test.ts create mode 100644 templates/content/actions/get-content-sidebar-state.ts create mode 100644 templates/content/actions/update-content-sidebar-state.ts create mode 100644 templates/content/changelog/2026-07-19-sidebar-page-trees-now-open-only-when-expanded-remember-thei.md create mode 100644 templates/content/changelog/2026-07-19-workspace-navigation-now-stays-stable-on-hover-uses-folder-i.md diff --git a/templates/content/actions/_content-sidebar-state.ts b/templates/content/actions/_content-sidebar-state.ts new file mode 100644 index 0000000000..7ec19b1359 --- /dev/null +++ b/templates/content/actions/_content-sidebar-state.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; + +export const CONTENT_SIDEBAR_STATE_VERSION = 1; +export const CONTENT_SIDEBAR_STATE_SETTING_KEY = "content-sidebar-state"; + +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), +}); + +export type ContentSidebarState = z.infer; + +export function normalizeContentSidebarState(value: unknown) { + const parsed = contentSidebarStateSchema.safeParse(value); + if (!parsed.success) return null; + return { + ...parsed.data, + expandedWorkspaceIds: [...new Set(parsed.data.expandedWorkspaceIds)], + expandedDocumentIds: [...new Set(parsed.data.expandedDocumentIds)], + }; +} diff --git a/templates/content/actions/content-sidebar-state.test.ts b/templates/content/actions/content-sidebar-state.test.ts new file mode 100644 index 0000000000..38f10160d1 --- /dev/null +++ b/templates/content/actions/content-sidebar-state.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; + +import { + CONTENT_SIDEBAR_STATE_VERSION, + normalizeContentSidebarState, +} from "./_content-sidebar-state"; + +describe("normalizeContentSidebarState", () => { + it("deduplicates persisted expansion ids", () => { + expect( + normalizeContentSidebarState({ + version: CONTENT_SIDEBAR_STATE_VERSION, + expandedWorkspaceIds: ["personal", "personal"], + expandedDocumentIds: ["parent", "parent", "child"], + }), + ).toEqual({ + version: CONTENT_SIDEBAR_STATE_VERSION, + expandedWorkspaceIds: ["personal"], + expandedDocumentIds: ["parent", "child"], + }); + }); + + it("ignores unknown or stale stored values", () => { + expect(normalizeContentSidebarState({ version: 0 })).toBeNull(); + }); +}); diff --git a/templates/content/actions/content-spaces.db.test.ts b/templates/content/actions/content-spaces.db.test.ts index dcf2042e8c..9a1062678e 100644 --- a/templates/content/actions/content-spaces.db.test.ts +++ b/templates/content/actions/content-spaces.db.test.ts @@ -305,6 +305,9 @@ describe("Content space provisioning", () => { expect( databases.filter((database: any) => database.systemRole === "workspaces"), ).toHaveLength(1); + const workspaces = databases.find( + (database: any) => database.systemRole === "workspaces", + ); expect( databases.filter((database: any) => database.systemRole === "favorites"), ).toHaveLength(1); @@ -333,6 +336,8 @@ describe("Content space provisioning", () => { expect(filesItems).toHaveLength(0); await runWithRequestContext({ userEmail: OWNER }, async () => { await expect(listContentSpacesAction.run({})).resolves.toMatchObject({ + catalogDatabaseId: workspaces.id, + catalogDocumentId: workspaces.documentId, favoritesDatabaseId: first.favoritesDatabaseId, favoritesDocumentId: first.favoritesDocumentId, spaces: expect.arrayContaining([ diff --git a/templates/content/actions/get-content-sidebar-state.ts b/templates/content/actions/get-content-sidebar-state.ts new file mode 100644 index 0000000000..58703ae102 --- /dev/null +++ b/templates/content/actions/get-content-sidebar-state.ts @@ -0,0 +1,23 @@ +import { defineAction } from "@agent-native/core/action"; +import { getUserSetting } from "@agent-native/core/settings"; +import { z } from "zod"; + +import { + CONTENT_SIDEBAR_STATE_SETTING_KEY, + normalizeContentSidebarState, +} from "./_content-sidebar-state.js"; + +export default defineAction({ + description: "Read the current user's Content sidebar expansion state.", + schema: z.object({}), + http: { method: "GET" }, + agentTool: false, + 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) }; + }, +}); diff --git a/templates/content/actions/list-content-spaces.ts b/templates/content/actions/list-content-spaces.ts index 36aa496985..a9b3cb7993 100644 --- a/templates/content/actions/list-content-spaces.ts +++ b/templates/content/actions/list-content-spaces.ts @@ -121,6 +121,7 @@ export default defineAction({ } return { catalogDatabaseId: catalogIds.databaseId, + catalogDocumentId: catalogIds.documentId, favoritesDatabaseId: filesDocumentIdByDatabaseId.has( favoritesIds.databaseId, ) diff --git a/templates/content/actions/set-document-property.ts b/templates/content/actions/set-document-property.ts index 7a698109e7..e9dc0d1239 100644 --- a/templates/content/actions/set-document-property.ts +++ b/templates/content/actions/set-document-property.ts @@ -43,6 +43,9 @@ export default defineAction({ const database = await getDatabaseById(definition.databaseId); if (!database) throw new Error("Document database not found."); await assertAccess("document", database.documentId, "editor"); + if (definition.systemRole) { + throw new Error("System properties are derived and cannot be edited."); + } const access = await resolveContentDocumentAccess(documentId); if (!access) throw new Error(`Document "${documentId}" not found`); const document = access.resource; @@ -56,10 +59,6 @@ export default defineAction({ ), ); if (!membership) throw new Error("Document is not part of this database."); - if (definition.systemRole) { - throw new Error("System properties are derived and cannot be edited."); - } - const type = definition.type as DocumentPropertyType; if (isComputedPropertyType(type)) { throw new Error("Computed properties cannot be edited."); diff --git a/templates/content/actions/update-content-sidebar-state.ts b/templates/content/actions/update-content-sidebar-state.ts new file mode 100644 index 0000000000..ed8d25603d --- /dev/null +++ b/templates/content/actions/update-content-sidebar-state.ts @@ -0,0 +1,27 @@ +import { defineAction } from "@agent-native/core/action"; +import { putUserSetting } from "@agent-native/core/settings"; + +import { + CONTENT_SIDEBAR_STATE_SETTING_KEY, + contentSidebarStateSchema, +} from "./_content-sidebar-state.js"; + +export default defineAction({ + description: "Persist the current user's Content sidebar expansion state.", + schema: contentSidebarStateSchema, + 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( + ctx.userEmail, + CONTENT_SIDEBAR_STATE_SETTING_KEY, + normalized, + ); + return { state: normalized }; + }, +}); diff --git a/templates/content/app/components/editor/DocumentEditor.layout.test.ts b/templates/content/app/components/editor/DocumentEditor.layout.test.ts index 2e54531805..3d5ffd12aa 100644 --- a/templates/content/app/components/editor/DocumentEditor.layout.test.ts +++ b/templates/content/app/components/editor/DocumentEditor.layout.test.ts @@ -9,10 +9,66 @@ import { documentEditorDefaultIconKind, documentEditorDatabaseRegionClassName, documentEditorTitleRegionClassName, + metadataUpdatesWithPendingTitle, + titleMatchConfirmsSave, } from "./DocumentEditor"; import { compactToolbarBreadcrumbItems } from "./DocumentToolbar"; describe("document editor layout", () => { + it("flushes a pending title with an icon update", () => { + expect( + metadataUpdatesWithPendingTitle( + { icon: "🌱" }, + "Renamed page", + "Untitled", + ), + ).toEqual({ icon: "🌱", title: "Renamed page" }); + expect( + metadataUpdatesWithPendingTitle( + { icon: "🌱" }, + "Renamed page", + "Renamed page", + ), + ).toEqual({ icon: "🌱" }); + }); + + it("stages title edits synchronously for adjacent metadata actions", () => { + const source = readFileSync( + new URL("./DocumentEditor.tsx", import.meta.url), + { encoding: "utf8" }, + ); + const handlerStart = source.indexOf( + "const handleTitleChange = useCallback", + ); + const refUpdate = source.indexOf( + "localTitleRef.current = newTitle", + handlerStart, + ); + const stateUpdate = source.indexOf("setLocalTitle(newTitle)", handlerStart); + + expect(refUpdate).toBeGreaterThan(handlerStart); + expect(refUpdate).toBeLessThan(stateUpdate); + }); + + it("does not mistake an optimistic title cache patch for a confirmed save", () => { + expect( + titleMatchConfirmsSave({ + serverTitle: "Renamed page", + localTitle: "Renamed page", + lastSavedTitle: "Untitled", + pendingTitle: "Renamed page", + }), + ).toBe(false); + expect( + titleMatchConfirmsSave({ + serverTitle: "Renamed page", + localTitle: "Renamed page", + lastSavedTitle: "Untitled", + pendingTitle: null, + }), + ).toBe(true); + }); + it("keeps prose titles on the reading column", () => { expect(documentEditorTitleRegionClassName(false)).toContain("max-w-3xl"); expect(documentEditorTitleRegionClassName(false)).toContain("pb-8"); @@ -494,9 +550,47 @@ describe("document editor layout", () => { "Personal", "Team", ]); + expect(items[0].iconKind).toBe("folder"); + expect(items[0].menuItems?.map((item) => item.iconKind)).toEqual([ + "folder", + "folder", + ]); expect(items[1].menuItems?.map((item) => item.title)).toEqual([ "Draft", "Notes", ]); }); + + it("links a top-level Files database back to Workspaces", () => { + const items = documentEditorBreadcrumbNavigationItems( + [{ id: "personal-files", title: "Personal" }], + [], + [{ filesDocumentId: "personal-files", name: "Personal" }], + { + currentDocumentId: "personal-files", + currentParentId: null, + currentDatabaseSystemRole: "files", + catalogDocumentId: "workspaces-document", + workspacesTitle: "Workspaces", + }, + ); + + expect(items.map((item) => item.title)).toEqual(["Workspaces", "Personal"]); + expect(items.map((item) => item.id)).toEqual([ + "workspaces-document", + "personal-files", + ]); + expect(items.map((item) => item.iconKind)).toEqual(["folder", "folder"]); + }); + + it("keeps hover-open breadcrumb menus non-modal and uses folder icons", () => { + const source = readFileSync( + new URL("./DocumentToolbar.tsx", import.meta.url), + { encoding: "utf8" }, + ); + + expect(source).toContain("( + updates: T, + currentTitle: string, + savedTitle: string, +): T & { title?: string } { + if (updates.title !== undefined || currentTitle === savedTitle) + return updates; + return { ...updates, title: currentTitle }; +} + +export function titleMatchConfirmsSave(args: { + serverTitle: string; + localTitle: string; + lastSavedTitle: string; + pendingTitle: string | null; +}) { + if (args.serverTitle !== args.localTitle) return false; + return !( + args.pendingTitle === args.localTitle && + args.localTitle !== args.lastSavedTitle + ); +} + function adoptConfirmedSaveWatermarks({ saved, savedAt, @@ -338,20 +368,29 @@ export function documentEditorBreadcrumbNavigationItems( | "source" >[], // i18n-ignore type expression spaces: Pick[], // i18n-ignore type expression -) { + context?: { + currentDocumentId: string; + currentParentId: string | null; + currentDatabaseSystemRole: string | null; + catalogDocumentId: string | null; + workspacesTitle: string; + }, +): ToolbarBreadcrumbItem[] { const documentById = new Map(documents.map((item) => [item.id, item])); const workspaceDocumentIds = new Set( spaces.map((space) => space.filesDocumentId), ); - return items.map((item) => { + const navigationItems = items.map((item) => { if (item.id && workspaceDocumentIds.has(item.id)) { return { ...item, + iconKind: "folder", menuItems: spaces.map((space) => ({ id: space.filesDocumentId, title: space.name, icon: null, + iconKind: "folder", })), }; } @@ -385,6 +424,22 @@ export function documentEditorBreadcrumbNavigationItems( })), }; }); + + if ( + context?.catalogDocumentId && + context.currentParentId === null && + context.currentDatabaseSystemRole === "files" && + workspaceDocumentIds.has(context.currentDocumentId) + ) { + const workspacesItem: ToolbarBreadcrumbItem = { + id: context.catalogDocumentId, + title: context.workspacesTitle, + iconKind: "folder", + }; + return [workspacesItem, ...navigationItems]; + } + + return navigationItems; } function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) { @@ -731,7 +786,12 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) { useEffect(() => { if (!document || !isInitializedRef.current) return; if (isLinkedLocalSourceDocument) return; - const titleMatchesLocal = document.title === localTitle; + const titleMatchesLocal = titleMatchConfirmsSave({ + serverTitle: document.title, + localTitle, + lastSavedTitle: lastSavedTitleRef.current.title, + pendingTitle: pendingDocumentSaveRef.current?.title ?? null, + }); const contentMatchesLocal = document.content === localContent; if (titleMatchesLocal) { @@ -1236,6 +1296,7 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) { const handleTitleChange = useCallback( (newTitle: string) => { if (!editorCanEdit) return; + localTitleRef.current = newTitle; setLocalTitle(newTitle); patchDocumentCaches(queryClient, documentId, { title: newTitle }); debouncedSave(newTitle, localContentRef.current); @@ -1385,8 +1446,21 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) { documentEditorBreadcrumbItems(document, documents), documents, contentSpaces, + { + currentDocumentId: document.id, + currentParentId: document.parentId, + currentDatabaseSystemRole: document.database?.systemRole ?? null, + catalogDocumentId: contentSpacesQuery.data?.catalogDocumentId ?? null, + workspacesTitle: t("sidebar.workspaces"), + }, ), - [contentSpaces, document, documents], + [ + contentSpaces, + contentSpacesQuery.data?.catalogDocumentId, + document, + documents, + t, + ], ); const handleOpenToolbarBreadcrumb = useCallback( @@ -1590,9 +1664,13 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) { } onSelect={(emoji) => { void (async () => { - const saved = await persistDocumentUpdates({ - icon: emoji, - }); + const updates = metadataUpdatesWithPendingTitle( + { icon: emoji }, + localTitleRef.current, + lastSavedTitleRef.current.title, + ); + const saved = + await persistDocumentUpdates(updates); // Icon-only save: never CAS-guarded server-side // (no content in this call), so this can't come // back as a conflict — narrow defensively anyway @@ -1605,7 +1683,7 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) { saved?.updatedAt ?? new Date().toISOString(), title: localTitleRef.current, content: localContentRef.current, - updates: { icon: emoji }, + updates, lastSavedTitleRef, lastSavedContentRef, }); diff --git a/templates/content/app/components/editor/DocumentToolbar.tsx b/templates/content/app/components/editor/DocumentToolbar.tsx index 244f2a57ae..a1ddb7482e 100644 --- a/templates/content/app/components/editor/DocumentToolbar.tsx +++ b/templates/content/app/components/editor/DocumentToolbar.tsx @@ -18,6 +18,7 @@ import { IconExternalLink, IconFileTypeHtml, IconFileTypePdf, + IconFolder, IconLinkOff, IconLoader2, IconMarkdown, @@ -227,6 +228,8 @@ function ToolbarBreadcrumb({ <> {item.icon ? ( {item.icon} + ) : item.iconKind === "folder" ? ( + ) : null} {label} @@ -280,10 +283,12 @@ export interface ToolbarBreadcrumbItem { id?: string; title: string; icon?: string | null; + iconKind?: "folder"; menuItems?: Array<{ id: string; title: string; icon?: string | null; + iconKind?: "folder"; }>; } @@ -297,7 +302,16 @@ export function compactToolbarBreadcrumbItems( { title: "…", menuItems: hidden.flatMap((item) => - item.id ? [{ id: item.id, title: item.title, icon: item.icon }] : [], + item.id + ? [ + { + id: item.id, + title: item.title, + icon: item.icon, + iconKind: item.iconKind, + }, + ] + : [], ), }, ...items.slice(-2), @@ -336,7 +350,7 @@ function ToolbarBreadcrumbMenu({ } return ( - +
)} - {dbText("deleteRow2")} + + {canDeleteWorkspace ? "Delete workspace?" : dbText("deleteRow2")} + - “{previewTitle}” and any sub-pages will be permanently - deleted. This cannot be undone. + {canDeleteWorkspace ? ( + <> + “{previewTitle}” and every page and database + inside it will be permanently deleted. This cannot be undone. + + ) : ( + <> + “{previewTitle}” and any sub-pages will be + permanently deleted. This cannot be undone. + + )} @@ -5125,6 +5155,8 @@ function DatabaseTableView({ const selectedItems = databaseSelectedItems(items, selectedItemIds); const removesFavoriteMembership = contentSpaces.data?.favoritesDocumentId === databaseDocumentId; + const isWorkspaceCatalog = + contentSpaces.data?.catalogDocumentId === databaseDocumentId; const bulkEditableProperties = databaseBulkEditableProperties(properties); const groups = databaseVisibleGroups( databaseViewItemGroups(items, groupableProperties, groupByPropertyId), @@ -5589,6 +5621,7 @@ function DatabaseTableView({ properties={properties} columnWidths={columnWidths} databaseDocumentId={databaseDocumentId} + workspaceCatalog={isWorkspaceCatalog} canEdit={canEdit} selectedIdSet={selectedIdSet} wrapCells={wrapCells} @@ -5617,6 +5650,7 @@ function DatabaseTableView({ key={item.id} item={item} databaseDocumentId={databaseDocumentId} + workspaceCatalog={isWorkspaceCatalog} properties={properties} columnWidths={columnWidths} canEdit={canEdit} @@ -17786,6 +17820,7 @@ function DatabaseGroupedTableSection({ properties, columnWidths, databaseDocumentId, + workspaceCatalog, canEdit, selectedIdSet, wrapCells, @@ -17807,6 +17842,7 @@ function DatabaseGroupedTableSection({ properties: DocumentProperty[]; columnWidths: Record; databaseDocumentId: string; + workspaceCatalog: boolean; canEdit: boolean; selectedIdSet: Set; wrapCells: boolean; @@ -17844,6 +17880,7 @@ function DatabaseGroupedTableSection({ key={`${group.id}-${item.id}`} item={item} databaseDocumentId={databaseDocumentId} + workspaceCatalog={workspaceCatalog} properties={properties} columnWidths={columnWidths} canEdit={canEdit} @@ -17910,6 +17947,7 @@ function DatabaseTableRow({ properties, columnWidths, databaseDocumentId, + workspaceCatalog, canEdit, rowIndex, canReorder, @@ -17935,6 +17973,7 @@ function DatabaseTableRow({ properties: ContentDatabaseItem["properties"]; columnWidths: Record; databaseDocumentId: string; + workspaceCatalog: boolean; canEdit: boolean; rowIndex: number; canReorder: boolean; @@ -17977,6 +18016,7 @@ function DatabaseTableRow({ space.catalogDocumentId === item.document.id, + ); + const isWorkspaceCatalog = + contentSpaces.data?.catalogDocumentId === databaseDocumentId; + const canDeleteWorkspace = + isWorkspaceCatalog && workspaceSpace?.kind === "user"; async function duplicateRow() { setMenuOpen(false); @@ -18160,17 +18207,21 @@ export function RowActionsCell({ {dbText("openPage")} - { - event.preventDefault(); - void duplicateRow(); - }} - > - - {dbText("duplicateRow")} - - + {!isWorkspaceCatalog ? ( + { + event.preventDefault(); + void duplicateRow(); + }} + > + + {dbText("duplicateRow")} + + ) : null} + {!isWorkspaceCatalog || canDeleteWorkspace ? ( + + ) : null} {removesFavoriteMembership ? ( { @@ -18182,7 +18233,7 @@ export function RowActionsCell({ {sidebarText("removeFromFavorites")} - ) : ( + ) : !isWorkspaceCatalog || canDeleteWorkspace ? ( { @@ -18192,22 +18243,37 @@ export function RowActionsCell({ }} > - {dbText("deleteRow")} + {canDeleteWorkspace ? "Delete workspace" : dbText("deleteRow")} - )} + ) : null} - {dbText("deleteRow2")} + + {canDeleteWorkspace ? "Delete workspace?" : dbText("deleteRow2")} + - “{title}” and any sub-pages will be permanently - deleted. This cannot be undone. + {canDeleteWorkspace ? ( + <> + “{title}” and every page and database inside it + will be permanently deleted. This cannot be undone. + + ) : ( + <> + “{title}” and any sub-pages will be permanently + deleted. This cannot be undone. + + )} @@ -18229,6 +18295,7 @@ export function RowActionsCell({ function RowNameCell({ item, databaseDocumentId, + workspaceCatalog, canEdit, canDragRow, selected, @@ -18242,6 +18309,7 @@ function RowNameCell({ }: { item: ContentDatabaseItem; databaseDocumentId: string; + workspaceCatalog: boolean; canEdit: boolean; canDragRow: boolean; selected: boolean; @@ -18334,6 +18402,7 @@ function RowNameCell({ document={item.document} className="size-4 text-sm" fallbackClassName="size-4" + fallback={workspaceCatalog ? "folder" : "page"} /> {canEdit && editingTitle ? ( { expect(source).toContain("label={newRowLabel}"); expect(source).toContain("propertyValues:"); expect(source).toContain("propertyValueOverrides"); + expect(source).toContain('fallback={workspaceCatalog ? "folder" : "page"}'); + expect(source).toContain('workspaceSpace?.kind === "user"'); + expect(source).toContain('"Delete workspace?"'); + expect(source).toContain("every page and database inside it"); expect(source).toContain("const isCreatingDatabaseItem ="); expect(source.match(/isCreating=\{isCreatingDatabaseItem/g)).toHaveLength( 6, diff --git a/templates/content/app/hooks/use-documents.ts b/templates/content/app/hooks/use-documents.ts index 27cbec6060..9d387b2643 100644 --- a/templates/content/app/hooks/use-documents.ts +++ b/templates/content/app/hooks/use-documents.ts @@ -528,6 +528,9 @@ export function useDeleteDocument() { queryClient.invalidateQueries({ queryKey: ["action", "get-content-database"], }); + queryClient.invalidateQueries({ + queryKey: ["action", "list-content-spaces"], + }); queryClient.invalidateQueries({ queryKey: ["action", "list-trashed-content-databases"], }); diff --git a/templates/content/changelog/2026-07-19-workspace-rows-now-use-folder-icons-and-deleting-a-user-crea.md b/templates/content/changelog/2026-07-19-workspace-rows-now-use-folder-icons-and-deleting-a-user-crea.md new file mode 100644 index 0000000000..3f13691d6c --- /dev/null +++ b/templates/content/changelog/2026-07-19-workspace-rows-now-use-folder-icons-and-deleting-a-user-crea.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-07-19 +--- + +Workspace rows now use folder icons, and deleting a user-created workspace removes it cleanly instead of failing. From 23909400bb3cc7cab13a58201023b3bcb0f6e599 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Sun, 19 Jul 2026 21:28:13 -0400 Subject: [PATCH 65/76] test(content): cover workspace deletion parity --- templates/content/parity/matrix.md | 56 +++++++++++++++--------------- templates/content/parity/matrix.ts | 5 +-- 2 files changed, 31 insertions(+), 30 deletions(-) diff --git a/templates/content/parity/matrix.md b/templates/content/parity/matrix.md index b1be9ed728..321812dd03 100644 --- a/templates/content/parity/matrix.md +++ b/templates/content/parity/matrix.md @@ -2,31 +2,31 @@ 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`, `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` | `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, and delete database rows | action-backed | `add-database-item`, `delete-database-items`, `delete-document`, `duplicate-database-items`, `duplicate-database-item`, `move-database-item`, `set-document-property` | `app/components/editor/DocumentDatabase.tsx`, `app/components/editor/database/DatabaseView.tsx` | Database row backing documents and row ordering are created, duplicated, moved, edited, and deleted. | - | - | P0 | covered | `actions/database-row-batch-actions.db.test.ts`, `parity/__tests__/database-row-batch-reliability.test.ts` | `database-bulk-row-reliability` | - | -| 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.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-documents`, `move-document`, `search-documents`, `update-document` | `app/components/sidebar/DocumentSidebar.tsx`, `app/components/sidebar/DocumentTreeItem.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 | - | - | - | -| 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`, `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`, `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.spaces-and-files-catalog | workspace | Provision and navigate Content spaces through Files and Workspaces with personal expansion state | action-backed | `backfill-content-files`, `create-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, their canonical Files databases, the personal Workspaces catalog, and each user's sidebar expansion state are stored and reconciled in SQL. | - | - | 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`, `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` | `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, and delete database rows | action-backed | `add-database-item`, `delete-database-items`, `delete-document`, `duplicate-database-items`, `duplicate-database-item`, `move-database-item`, `set-document-property` | `app/components/editor/DocumentDatabase.tsx`, `app/components/editor/database/DatabaseView.tsx` | Database row backing documents and row ordering are created, duplicated, moved, edited, and deleted. | - | - | P0 | covered | `actions/database-row-batch-actions.db.test.ts`, `parity/__tests__/database-row-batch-reliability.test.ts` | `database-bulk-row-reliability` | - | +| 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.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-documents`, `move-document`, `search-documents`, `update-document` | `app/components/sidebar/DocumentSidebar.tsx`, `app/components/sidebar/DocumentTreeItem.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 | - | - | - | +| 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`, `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`, `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.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 01f7145e87..8423933bf0 100644 --- a/templates/content/parity/matrix.ts +++ b/templates/content/parity/matrix.ts @@ -40,19 +40,20 @@ export const parityMatrix: ParityRow[] = [ id: "workspace.spaces-and-files-catalog", surface: "workspace", label: - "Provision and navigate Content spaces through Files and Workspaces with personal expansion state", + "Provision, navigate, and delete Content spaces through Files and Workspaces with personal expansion state", uiEntrypoints: [ "app/components/sidebar/DocumentSidebar.tsx", "app/hooks/use-content-spaces.ts", ], durableEffect: - "Personal and organization spaces, their canonical Files databases, the personal Workspaces catalog, and each user's sidebar expansion state are stored and reconciled in SQL.", + "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.", uiImplementation: "The app sidebar calls the shared space actions and renders the selected Files database through a saved sidebar view.", status: "action-backed", actions: [ "backfill-content-files", "create-content-space", + "delete-content-space", "ensure-content-spaces", "get-content-sidebar-state", "list-content-spaces", From 37f3dea9ac639c4828b57cb4a4baa65af82f7729 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Sun, 19 Jul 2026 21:30:46 -0400 Subject: [PATCH 66/76] refactor(content): share workspace delete action --- .../content/actions/content-spaces.db.test.ts | 5 +-- templates/content/actions/delete-document.ts | 26 +---------- .../editor/database/DatabaseView.tsx | 43 +++++++++++++------ .../DatabaseView.workspaces.layout.test.ts | 1 + .../content/app/hooks/use-content-spaces.ts | 22 ++++++++++ 5 files changed, 56 insertions(+), 41 deletions(-) diff --git a/templates/content/actions/content-spaces.db.test.ts b/templates/content/actions/content-spaces.db.test.ts index 85aa176fba..5f268a6c2f 100644 --- a/templates/content/actions/content-spaces.db.test.ts +++ b/templates/content/actions/content-spaces.db.test.ts @@ -259,10 +259,7 @@ describe("Content space provisioning", () => { itemIds: [first.catalogItemId], }), ).rejects.toThrow("Workspace references cannot be duplicated as pages"); - await deleteDocumentAction.run({ - id: first.catalogDocumentId, - databaseDocumentId: ownerSpaces.catalogDocumentId, - }); + await deleteContentSpaceAction.run({ spaceId: first.spaceId }); }); const deletedWorkspaceRows = await Promise.all([ getDb() diff --git a/templates/content/actions/delete-document.ts b/templates/content/actions/delete-document.ts index c936780df3..727464bf19 100644 --- a/templates/content/actions/delete-document.ts +++ b/templates/content/actions/delete-document.ts @@ -374,33 +374,9 @@ export default defineAction({ .where( and( eq(schema.contentDatabases.documentId, args.databaseDocumentId), - inArray(schema.contentDatabases.systemRole, [ - "favorites", - "workspaces", - ]), + eq(schema.contentDatabases.systemRole, "favorites"), ), ); - if (contextDatabase?.systemRole === "workspaces") { - await assertAccess("document", contextDatabase.documentId, "editor"); - const [mapping] = await db - .select({ spaceId: schema.contentSpaceCatalogItems.spaceId }) - .from(schema.contentSpaceCatalogItems) - .where( - and( - eq( - schema.contentSpaceCatalogItems.catalogDatabaseId, - contextDatabase.id, - ), - eq(schema.contentSpaceCatalogItems.documentId, id), - ), - ); - if (!mapping) throw new Error("Workspace catalog entry not found"); - const { deleteUserContentSpace } = - await import("./_delete-content-space.js"); - const result = await deleteUserContentSpace(db, mapping.spaceId); - await writeAppState("refresh-signal", { ts: Date.now() }); - return { success: true, deleted: result.deletedDocuments }; - } if (contextDatabase) { await assertAccess("document", contextDatabase.documentId, "editor"); const [membership] = await db diff --git a/templates/content/app/components/editor/database/DatabaseView.tsx b/templates/content/app/components/editor/database/DatabaseView.tsx index 02dd806f77..4eb24db3a2 100644 --- a/templates/content/app/components/editor/database/DatabaseView.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.tsx @@ -189,6 +189,7 @@ import { import { useContentSpaces, useCreateContentSpace, + useDeleteContentSpace, } from "@/hooks/use-content-spaces"; import { useConfigureDocumentProperty, @@ -4011,6 +4012,7 @@ function DatabaseItemPreview({ const contentSpaces = useContentSpaces(); const updateDocument = useUpdateDocument(); const deleteDocument = useDeleteDocument(); + const deleteContentSpace = useDeleteContentSpace(); const duplicateItem = useDuplicateDatabaseItem(databaseDocumentId); const { data: document, isLoading } = useDocument(item.document.id); const { data: persistedPreviewDraft } = usePreviewDocumentDraft( @@ -4690,10 +4692,14 @@ function DatabaseItemPreview({ } try { - await deleteDocument.mutateAsync({ - id: item.document.id, - databaseDocumentId, - }); + if (canDeleteWorkspace && workspaceSpace) { + await deleteContentSpace.mutateAsync({ spaceId: workspaceSpace.id }); + } else { + await deleteDocument.mutateAsync({ + id: item.document.id, + databaseDocumentId, + }); + } await queryClient.invalidateQueries({ queryKey: [ "action", @@ -5014,10 +5020,14 @@ function DatabaseItemPreview({ Cancel void deletePreviewRow()} > - {deleteDocument.isPending ? "Deleting..." : "Delete"} + {deleteDocument.isPending || deleteContentSpace.isPending + ? "Deleting..." + : "Delete"} @@ -18130,6 +18140,7 @@ export function RowActionsCell({ const queryClient = useQueryClient(); const contentSpaces = useContentSpaces(); const deleteDocument = useDeleteDocument(); + const deleteContentSpace = useDeleteContentSpace(); const duplicateItem = useDuplicateDatabaseItem(databaseDocumentId); const [menuOpen, setMenuOpen] = useState(false); const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); @@ -18161,10 +18172,14 @@ export function RowActionsCell({ async function deleteRow() { const previewMoved = onDeletedPreviewItem?.(item) ?? false; try { - await deleteDocument.mutateAsync({ - id: item.document.id, - databaseDocumentId, - }); + if (canDeleteWorkspace && workspaceSpace) { + await deleteContentSpace.mutateAsync({ spaceId: workspaceSpace.id }); + } else { + await deleteDocument.mutateAsync({ + id: item.document.id, + databaseDocumentId, + }); + } await queryClient.invalidateQueries({ queryKey: [ "action", @@ -18280,10 +18295,14 @@ export function RowActionsCell({ Cancel void deleteRow()} > - {deleteDocument.isPending ? "Deleting..." : "Delete"} + {deleteDocument.isPending || deleteContentSpace.isPending + ? "Deleting..." + : "Delete"} diff --git a/templates/content/app/components/editor/database/DatabaseView.workspaces.layout.test.ts b/templates/content/app/components/editor/database/DatabaseView.workspaces.layout.test.ts index e37c78f208..aab464a7cb 100644 --- a/templates/content/app/components/editor/database/DatabaseView.workspaces.layout.test.ts +++ b/templates/content/app/components/editor/database/DatabaseView.workspaces.layout.test.ts @@ -20,6 +20,7 @@ describe("Workspaces database lifecycle", () => { expect(source).toContain("propertyValueOverrides"); expect(source).toContain('fallback={workspaceCatalog ? "folder" : "page"}'); expect(source).toContain('workspaceSpace?.kind === "user"'); + expect(source).toContain("deleteContentSpace.mutateAsync"); expect(source).toContain('"Delete workspace?"'); expect(source).toContain("every page and database inside it"); expect(source).toContain("const isCreatingDatabaseItem ="); diff --git a/templates/content/app/hooks/use-content-spaces.ts b/templates/content/app/hooks/use-content-spaces.ts index 7e9157549f..0bd326601e 100644 --- a/templates/content/app/hooks/use-content-spaces.ts +++ b/templates/content/app/hooks/use-content-spaces.ts @@ -76,3 +76,25 @@ export function useCreateContentSpace() { }, }); } + +export function useDeleteContentSpace() { + const queryClient = useQueryClient(); + return useActionMutation< + { success: boolean; spaceId: string; deletedDocuments: number }, + { spaceId: string } + >("delete-content-space", { + onSuccess: async () => { + await Promise.all([ + queryClient.refetchQueries({ + queryKey: ["action", "list-content-spaces"], + }), + queryClient.refetchQueries({ + queryKey: ["action", "get-content-database"], + }), + queryClient.refetchQueries({ + queryKey: ["action", "list-documents"], + }), + ]); + }, + }); +} From 29d64def8f41fe0af289de707bdd2b27cecf43f7 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Sun, 19 Jul 2026 21:53:01 -0400 Subject: [PATCH 67/76] test(content): cover workspace create labels --- .../app/components/editor/DocumentDatabase.layout.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/templates/content/app/components/editor/DocumentDatabase.layout.test.ts b/templates/content/app/components/editor/DocumentDatabase.layout.test.ts index cfe60c120d..72c1abda7b 100644 --- a/templates/content/app/components/editor/DocumentDatabase.layout.test.ts +++ b/templates/content/app/components/editor/DocumentDatabase.layout.test.ts @@ -42,7 +42,9 @@ describe("document database layout", () => { expect(source).toContain("setPreviewTitleFocusDocumentId"); expect(source).toContain("titleInputRef.current?.focus()"); expect(source).toContain("titleInputRef.current?.select()"); - expect(source).toContain('aria-label={dbText("newDatabaseRow")}'); + expect(source).toContain("const newDatabaseRowLabel ="); + expect(source).toContain("newRowLabel={newDatabaseRowLabel}"); + expect(source).toContain("label={newRowLabel}"); }); it("selects the current view name when renaming a database view", () => { @@ -232,7 +234,8 @@ describe("document database layout", () => { expect(source).toContain( "if (totalCount === 0 && !constrained) return null", ); - expect(source).toContain('aria-label={dbText("newDatabaseRow")}'); + expect(source).toContain("newRowLabel={newDatabaseRowLabel}"); + expect(source).toContain("label={newRowLabel}"); expect(source).toContain("hover:bg-muted/35 hover:text-foreground"); }); From ebfb4f69cf284baf81309df7e2d5f93099604c44 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Mon, 20 Jul 2026 06:28:30 -0400 Subject: [PATCH 68/76] fix(content): restore organization and trash navigation --- .../.agents/skills/document-editing/SKILL.md | 10 +- templates/content/AGENTS.md | 5 +- .../content/actions/_database-row-batch.ts | 16 +- templates/content/actions/_database-utils.ts | 9 + .../content-database-lifecycle.db.test.ts | 163 ++++++++++++++++++ .../database-row-batch-actions.db.test.ts | 21 ++- .../actions/delete-content-database.ts | 19 +- .../content/actions/delete-database-items.ts | 5 +- templates/content/actions/delete-document.ts | 137 ++++++++++++++- templates/content/actions/get-document.ts | 2 +- .../content/actions/list-content-databases.ts | 1 + templates/content/actions/list-documents.ts | 1 + .../actions/list-trashed-content-databases.ts | 4 + .../content/actions/list-trashed-documents.ts | 50 ++++++ .../actions/permanently-delete-document.ts | 45 +++++ .../actions/restore-content-database.ts | 28 +-- templates/content/actions/restore-document.ts | 28 +++ templates/content/actions/search-documents.ts | 3 +- templates/content/actions/view-screen.ts | 3 +- .../sidebar/DocumentSidebar.layout.test.ts | 33 +++- .../components/sidebar/DocumentSidebar.tsx | 161 +++++++++++++++-- .../content/app/hooks/use-content-database.ts | 6 + templates/content/app/hooks/use-documents.ts | 57 ++++++ templates/content/app/i18n-data.ts | 15 +- ...eversible-trash-and-the-organization-pi.md | 6 + templates/content/parity/matrix.md | 2 +- templates/content/parity/matrix.ts | 3 + templates/content/server/__tests__/db.spec.ts | 17 ++ templates/content/server/db/schema.ts | 2 + .../content/server/lib/public-documents.ts | 10 +- templates/content/server/plugins/db.ts | 7 + .../document-agent-context.json.get.test.ts | 2 + .../api/document-agent-context.json.get.ts | 4 +- templates/content/shared/api.ts | 10 ++ 34 files changed, 822 insertions(+), 63 deletions(-) create mode 100644 templates/content/actions/list-trashed-documents.ts create mode 100644 templates/content/actions/permanently-delete-document.ts create mode 100644 templates/content/actions/restore-document.ts create mode 100644 templates/content/changelog/2026-07-19-pages-now-move-to-a-reversible-trash-and-the-organization-pi.md diff --git a/templates/content/.agents/skills/document-editing/SKILL.md b/templates/content/.agents/skills/document-editing/SKILL.md index 32673e22b4..8c27aee6e9 100644 --- a/templates/content/.agents/skills/document-editing/SKILL.md +++ b/templates/content/.agents/skills/document-editing/SKILL.md @@ -82,12 +82,20 @@ pnpm action update-document --id abc123 --description "Stable guidance for what ### delete-document -Delete a document and all its children recursively. +Move a document and all its children to Trash. IDs, bodies, hierarchy, and +database membership remain intact so the subtree can be restored. ```bash pnpm action delete-document --id abc123 ``` +Restore the root subtree, or permanently delete it only after it is in Trash: + +```bash +pnpm action restore-document --id abc123 +pnpm action permanently-delete-document --id abc123 +``` + ## Comments Comments are Notion/Google-Docs-style **inline comments**. Selecting text and commenting leaves the passage **highlighted inline** via a ProseMirror decoration overlay — nothing is written into the markdown body, so the document round-trips unchanged. Each thread stores the quoted text plus surrounding context (`anchorPrefix`/`anchorSuffix`) and an approximate `anchorStartOffset`, so the highlight follows the text as the document is edited, disambiguates repeated text, and degrades gracefully (the thread stays in the sidebar) when its text is deleted. diff --git a/templates/content/AGENTS.md b/templates/content/AGENTS.md index f4504eeb61..3f3df4e4e4 100644 --- a/templates/content/AGENTS.md +++ b/templates/content/AGENTS.md @@ -141,6 +141,9 @@ cd templates/content && pnpm action [args] | `get-content-database` | `--databaseId ` or `--documentId ` | Get a database with its description, property/option schema guidance, item pages, and computed ancestry context | | `list-trashed-content-databases` | | List soft-deleted databases visible in the sidebar Trash surface | | `restore-content-database` | `--databaseId ` | Restore a soft-deleted database from the sidebar Trash surface | +| `list-trashed-documents` | | List access-filtered page roots visible in the sidebar Trash surface | +| `restore-document` | `--id ` | Restore a page and the subtree moved to Trash with it | +| `permanently-delete-document` | `--id ` | Permanently destroy a document subtree already in Trash | | `get-content-database-source` | `--databaseId ` or `--documentId ` | Inspect local/no-source or source-backed status, mappings, row identity, freshness, and change sets | | `attach-content-database-source` | `--databaseId ` or `--documentId [--sourceType mock-local\|builder-cms\|local-table\|notion-database] [--sourceName] [--sourceTable] [--relationshipMode items\|details] [--join ]` | Attach a source binding; use `items` to add more Builder rows and `details` to match a read-only local/Notion source onto existing rows | | `list-notion-database-sources` | `[--query ] [--limit <1-100>]` | List Notion data sources visible through the current user's OAuth connection; returns IDs/names only, never tokens | @@ -170,7 +173,7 @@ cd templates/content && pnpm action [args] | `reorder-document-property` | `--documentId --propertyId --targetPropertyId [--position before\|after]` | Reorder a property definition within its database (used to reorder Blocks fields on the page) | | `set-document-discoverability` | `--id --hideFromSearch true\|false [--includeChildren true\|false]` | Hide/show an org-accessible document in Organization/search while keeping link access | | `move-document` | `--id [--parentId] [--position]` | Move or reorder a document in the page tree | -| `delete-document` | `--id ` | Delete with recursive children | +| `delete-document` | `--id ` | Move a document and its recursive children to Trash | Database views follow Notion-style tab labels. When creating or duplicating views in `viewConfig`, use unique default names (`Table 2`, `SEO copy 2`, etc.) diff --git a/templates/content/actions/_database-row-batch.ts b/templates/content/actions/_database-row-batch.ts index aa48562f37..3fff90695a 100644 --- a/templates/content/actions/_database-row-batch.ts +++ b/templates/content/actions/_database-row-batch.ts @@ -185,9 +185,21 @@ export async function renumberDatabaseRows( now: string, ) { const rows = await db - .select() + .select({ + id: schema.contentDatabaseItems.id, + documentId: schema.contentDatabaseItems.documentId, + }) .from(schema.contentDatabaseItems) - .where(eq(schema.contentDatabaseItems.databaseId, database.id)) + .innerJoin( + schema.documents, + eq(schema.documents.id, schema.contentDatabaseItems.documentId), + ) + .where( + and( + eq(schema.contentDatabaseItems.databaseId, database.id), + isNull(schema.documents.trashedAt), + ), + ) .orderBy(asc(schema.contentDatabaseItems.position)); if (rows.length === 0) return; diff --git a/templates/content/actions/_database-utils.ts b/templates/content/actions/_database-utils.ts index e4b2ed0904..5491a49c80 100644 --- a/templates/content/actions/_database-utils.ts +++ b/templates/content/actions/_database-utils.ts @@ -81,6 +81,8 @@ export const contentDatabaseListDocumentSelection = { sourcePath: schema.documents.sourcePath, sourceRootPath: schema.documents.sourceRootPath, sourceUpdatedAt: schema.documents.sourceUpdatedAt, + trashedAt: schema.documents.trashedAt, + trashRootId: schema.documents.trashRootId, visibility: schema.documents.visibility, ownerEmail: schema.documents.ownerEmail, orgId: schema.documents.orgId, @@ -331,6 +333,7 @@ export async function getContentDatabaseResponse( }), ), ), + isNull(schema.documents.trashedAt), documentDiscoveryFilter({ userEmail, orgIds: authorizedOrgIds, @@ -351,6 +354,11 @@ export async function getContentDatabaseResponse( : undefined; const visibleItemFilter = and( eq(schema.contentDatabaseItems.databaseId, databaseId), + sql`exists ( + select 1 from ${schema.documents} + where ${schema.documents.id} = ${schema.contentDatabaseItems.documentId} + and ${schema.documents.trashedAt} is null + )`, organizationFilesItemFilter, favoritesVisibleDocumentIds ? favoritesVisibleDocumentIds.length > 0 @@ -388,6 +396,7 @@ export async function getContentDatabaseResponse( schema.documents.id, items.map((item) => item.documentId), ), + isNull(schema.documents.trashedAt), database.systemRole === "favorites" ? favoritesVisibleDocumentIds?.length ? inArray(schema.documents.id, favoritesVisibleDocumentIds) diff --git a/templates/content/actions/content-database-lifecycle.db.test.ts b/templates/content/actions/content-database-lifecycle.db.test.ts index 0e915436f6..9d07c675d9 100644 --- a/templates/content/actions/content-database-lifecycle.db.test.ts +++ b/templates/content/actions/content-database-lifecycle.db.test.ts @@ -26,6 +26,10 @@ let listTrashedContentDatabasesAction: typeof import("./list-trashed-content-dat let getDocumentAction: typeof import("./get-document.js").default; let listDocumentPropertiesAction: typeof import("./list-document-properties.js").default; let addDatabaseItemAction: typeof import("./add-database-item.js").default; +let deleteDocumentAction: typeof import("./delete-document.js").default; +let restoreDocumentAction: typeof import("./restore-document.js").default; +let permanentlyDeleteDocumentAction: typeof import("./permanently-delete-document.js").default; +let listTrashedDocumentsAction: typeof import("./list-trashed-documents.js").default; const OWNER = "owner@example.com"; const COLLABORATOR = "collaborator@example.com"; @@ -51,6 +55,13 @@ beforeAll(async () => { listDocumentPropertiesAction = (await import("./list-document-properties.js")) .default; addDatabaseItemAction = (await import("./add-database-item.js")).default; + deleteDocumentAction = (await import("./delete-document.js")).default; + restoreDocumentAction = (await import("./restore-document.js")).default; + permanentlyDeleteDocumentAction = ( + await import("./permanently-delete-document.js") + ).default; + listTrashedDocumentsAction = (await import("./list-trashed-documents.js")) + .default; const plugin = (await import("../server/plugins/db.js")).default; await plugin(undefined as any); }, 60000); @@ -164,6 +175,158 @@ async function databaseRow(databaseId: string) { return database; } +describe("document trash lifecycle", () => { + it("round-trips a page subtree without changing ids, bodies, or hierarchy", async () => { + const rootId = await createDocument({ + title: "Trash root", + content: "Root body", + }); + const childId = await createDocument({ + parentId: rootId, + title: "Trash child", + content: "Child body", + }); + + const deleted = await runWithRequestContext({ userEmail: OWNER }, () => + deleteDocumentAction.run({ id: rootId }), + ); + expect(deleted.deleted).toBe(2); + expect(await documentRow(rootId)).toMatchObject({ + content: "Root body", + trashedAt: expect.any(String), + trashRootId: rootId, + }); + expect(await documentRow(childId)).toMatchObject({ + parentId: rootId, + content: "Child body", + trashedAt: expect.any(String), + trashRootId: rootId, + }); + + const active = await runWithRequestContext({ userEmail: OWNER }, () => + listDocumentsAction.run({}), + ); + expect(active.documents.map((document) => document.id)).not.toContain( + rootId, + ); + await expect( + runWithRequestContext({ userEmail: OWNER }, () => + getDocumentAction.run({ id: rootId }), + ), + ).rejects.toMatchObject({ statusCode: 404 }); + + const trash = await runWithRequestContext({ userEmail: OWNER }, () => + listTrashedDocumentsAction.run({}), + ); + expect(trash.documents).toContainEqual( + expect.objectContaining({ documentId: rootId, title: "Trash root" }), + ); + expect( + trash.documents.map((document) => document.documentId), + ).not.toContain(childId); + + const restored = await runWithRequestContext({ userEmail: OWNER }, () => + restoreDocumentAction.run({ id: rootId }), + ); + expect(restored.restored).toBe(2); + expect(await documentRow(rootId)).toMatchObject({ + content: "Root body", + trashedAt: null, + trashRootId: null, + }); + expect(await documentRow(childId)).toMatchObject({ + parentId: rootId, + content: "Child body", + trashedAt: null, + trashRootId: null, + }); + }); + + it("does not restore a child that was trashed separately before its parent", async () => { + const rootId = await createDocument({ title: "Later parent" }); + const childId = await createDocument({ + parentId: rootId, + title: "Earlier child", + }); + + await runWithRequestContext({ userEmail: OWNER }, () => + deleteDocumentAction.run({ id: childId }), + ); + await runWithRequestContext({ userEmail: OWNER }, () => + deleteDocumentAction.run({ id: rootId }), + ); + await runWithRequestContext({ userEmail: OWNER }, () => + restoreDocumentAction.run({ id: rootId }), + ); + + expect(await documentRow(rootId)).toMatchObject({ trashedAt: null }); + expect(await documentRow(childId)).toMatchObject({ + trashedAt: expect.any(String), + trashRootId: childId, + }); + }); + + it("does not restore a database that was already in Trash before its parent", async () => { + const rootId = await createDocument({ + title: "Parent of trashed database", + }); + const databaseDeletedAt = "2026-07-19T12:00:00.000Z"; + const { databaseId, databaseDocumentId } = await createDatabase({ + backingParentId: rootId, + deletedAt: databaseDeletedAt, + }); + + await runWithRequestContext({ userEmail: OWNER }, () => + deleteDocumentAction.run({ id: rootId }), + ); + await runWithRequestContext({ userEmail: OWNER }, () => + restoreDocumentAction.run({ id: rootId }), + ); + + expect(await databaseRow(databaseId)).toMatchObject({ + deletedAt: databaseDeletedAt, + }); + expect(await documentRow(databaseDocumentId)).toMatchObject({ + trashedAt: null, + trashRootId: null, + }); + }); + + it("requires Trash before permanent deletion", async () => { + const documentId = await createDocument({ title: "Purge me" }); + await expect( + runWithRequestContext({ userEmail: OWNER }, () => + permanentlyDeleteDocumentAction.run({ id: documentId }), + ), + ).rejects.toThrow("must be in Trash"); + + await runWithRequestContext({ userEmail: OWNER }, () => + deleteDocumentAction.run({ id: documentId }), + ); + await runWithRequestContext({ userEmail: OWNER }, () => + permanentlyDeleteDocumentAction.run({ id: documentId }), + ); + expect(await documentRow(documentId)).toBeUndefined(); + }); + + it("does not expose another owner's trashed page title", async () => { + const foreignId = await createDocument({ + title: "Sensitive foreign title", + ownerEmail: COLLABORATOR, + }); + await runWithRequestContext({ userEmail: COLLABORATOR }, () => + deleteDocumentAction.run({ id: foreignId }), + ); + + const trash = await runWithRequestContext({ userEmail: OWNER }, () => + listTrashedDocumentsAction.run({}), + ); + expect( + trash.documents.map((document) => document.documentId), + ).not.toContain(foreignId); + }); +}); + describe("inline database lifecycle reconcile", () => { it("does not let a stale empty preview save replace a newer hydrated body", async () => { const documentId = await createDocument({ diff --git a/templates/content/actions/database-row-batch-actions.db.test.ts b/templates/content/actions/database-row-batch-actions.db.test.ts index eb3af80003..655e3f79f2 100644 --- a/templates/content/actions/database-row-batch-actions.db.test.ts +++ b/templates/content/actions/database-row-batch-actions.db.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { runWithRequestContext } from "@agent-native/core/server"; -import { asc, eq, inArray } from "drizzle-orm"; +import { and, asc, eq, inArray, isNull } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; const TEST_DB_PATH = join( @@ -157,7 +157,12 @@ async function orderedRows(databaseId: string) { schema.documents, eq(schema.documents.id, schema.contentDatabaseItems.documentId), ) - .where(eq(schema.contentDatabaseItems.databaseId, databaseId)) + .where( + and( + eq(schema.contentDatabaseItems.databaseId, databaseId), + isNull(schema.documents.trashedAt), + ), + ) .orderBy(asc(schema.contentDatabaseItems.position)); } @@ -355,7 +360,10 @@ describe("database row batch actions", () => { expect(remainingRows.map((row) => row.itemPosition)).toEqual([0, 1]); const deletedDocs = await db - .select({ id: schema.documents.id }) + .select({ + id: schema.documents.id, + trashedAt: schema.documents.trashedAt, + }) .from(schema.documents) .where( inArray(schema.documents.id, [ @@ -364,12 +372,13 @@ describe("database row batch actions", () => { childDocumentId, ]), ); - expect(deletedDocs).toEqual([]); - const deletedValues = await db + expect(deletedDocs).toHaveLength(3); + expect(deletedDocs.every((document) => document.trashedAt)).toBe(true); + const preservedValues = await db .select() .from(schema.documentPropertyValues) .where(eq(schema.documentPropertyValues.documentId, rows[1].documentId)); - expect(deletedValues).toEqual([]); + expect(preservedValues).toHaveLength(1); }); it("rejects unauthorized delete batches before writing", async () => { diff --git a/templates/content/actions/delete-content-database.ts b/templates/content/actions/delete-content-database.ts index 08fe83c5e9..5d2d2040e0 100644 --- a/templates/content/actions/delete-content-database.ts +++ b/templates/content/actions/delete-content-database.ts @@ -1,10 +1,10 @@ import { defineAction } from "@agent-native/core"; import { writeAppState } from "@agent-native/core/application-state"; -import { eq } from "drizzle-orm"; import { z } from "zod"; -import { getDb, schema } from "../server/db/index.js"; +import { getDb } from "../server/db/index.js"; import { assertContentDatabaseLifecycleAccess } from "./_content-database-lifecycle.js"; +import { trashDocumentSubtree } from "./delete-document.js"; export default defineAction({ description: @@ -19,13 +19,14 @@ export default defineAction({ } const db = getDb(); const deletedAt = database.deletedAt ?? new Date().toISOString(); - - if (!database.deletedAt) { - await db - .update(schema.contentDatabases) - .set({ deletedAt, updatedAt: deletedAt }) - .where(eq(schema.contentDatabases.id, databaseId)); - } + await db.transaction((tx) => + trashDocumentSubtree( + tx as unknown as ReturnType, + database.documentId, + database.ownerEmail, + deletedAt, + ), + ); await writeAppState("refresh-signal", { ts: Date.now() }); diff --git a/templates/content/actions/delete-database-items.ts b/templates/content/actions/delete-database-items.ts index d1c9248af3..a059abdd63 100644 --- a/templates/content/actions/delete-database-items.ts +++ b/templates/content/actions/delete-database-items.ts @@ -11,7 +11,7 @@ import { resolveDatabaseRowsForBatch, } from "./_database-row-batch.js"; import { getContentDatabaseResponse } from "./_database-utils.js"; -import { deleteDocumentRecursive } from "./delete-document.js"; +import { trashDocumentSubtree } from "./delete-document.js"; export default defineAction({ description: @@ -57,10 +57,11 @@ export default defineAction({ await db.transaction(async (tx) => { for (const row of rows) { - await deleteDocumentRecursive( + await trashDocumentSubtree( tx as unknown as ReturnType, row.document.id, row.document.ownerEmail, + now, ); } await renumberDatabaseRows( diff --git a/templates/content/actions/delete-document.ts b/templates/content/actions/delete-document.ts index 727464bf19..e37c328500 100644 --- a/templates/content/actions/delete-document.ts +++ b/templates/content/actions/delete-document.ts @@ -1,7 +1,7 @@ import { defineAction } from "@agent-native/core"; import { writeAppState } from "@agent-native/core/application-state"; import { assertAccess } from "@agent-native/core/sharing"; -import { and, eq, inArray } from "drizzle-orm"; +import { and, eq, inArray, isNotNull, isNull } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; @@ -155,6 +155,128 @@ async function collectDocumentSubtreeForDelete( }; } +export async function trashDocumentSubtree( + db: ReturnType, + id: string, + ownerEmail: string, + trashedAt = new Date().toISOString(), +): Promise { + const { documentIds } = await collectDocumentSubtreeForDelete( + db, + id, + ownerEmail, + ); + await assertNotWorkspaceCatalogDocuments(db, documentIds, "deleted"); + + const independentlyTrashedDatabaseDocumentIds = new Set(); + for (const batch of chunks(documentIds, DELETE_BATCH_SIZE)) { + for (const database of await db + .select({ documentId: schema.contentDatabases.documentId }) + .from(schema.contentDatabases) + .where( + and( + inArray(schema.contentDatabases.documentId, batch), + eq(schema.contentDatabases.ownerEmail, ownerEmail), + isNotNull(schema.contentDatabases.deletedAt), + ), + )) { + independentlyTrashedDatabaseDocumentIds.add(database.documentId); + } + } + + const activeDocumentIds: string[] = []; + for (const batch of chunks(documentIds, DELETE_BATCH_SIZE)) { + activeDocumentIds.push( + ...( + await db + .select({ id: schema.documents.id }) + .from(schema.documents) + .where( + and( + inArray(schema.documents.id, batch), + eq(schema.documents.ownerEmail, ownerEmail), + isNull(schema.documents.trashedAt), + ), + ) + ) + .map((document) => document.id) + .filter( + (documentId) => + !independentlyTrashedDatabaseDocumentIds.has(documentId), + ), + ); + } + + for (const batch of chunks(activeDocumentIds, DELETE_BATCH_SIZE)) { + await db + .update(schema.documents) + .set({ trashedAt, trashRootId: id, updatedAt: trashedAt }) + .where( + and( + inArray(schema.documents.id, batch), + eq(schema.documents.ownerEmail, ownerEmail), + isNull(schema.documents.trashedAt), + ), + ); + await db + .update(schema.contentDatabases) + .set({ deletedAt: trashedAt, updatedAt: trashedAt }) + .where( + and( + inArray(schema.contentDatabases.documentId, batch), + eq(schema.contentDatabases.ownerEmail, ownerEmail), + isNull(schema.contentDatabases.deletedAt), + ), + ); + } + + return activeDocumentIds; +} + +export async function restoreDocumentSubtree( + db: ReturnType, + rootId: string, + ownerEmail: string, +): Promise { + const documentIds = ( + await db + .select({ id: schema.documents.id }) + .from(schema.documents) + .where( + and( + eq(schema.documents.trashRootId, rootId), + eq(schema.documents.ownerEmail, ownerEmail), + ), + ) + ).map((document) => document.id); + if (documentIds.length === 0) return []; + + const now = new Date().toISOString(); + for (const batch of chunks(documentIds, DELETE_BATCH_SIZE)) { + await db + .update(schema.documents) + .set({ trashedAt: null, trashRootId: null, updatedAt: now }) + .where( + and( + inArray(schema.documents.id, batch), + eq(schema.documents.ownerEmail, ownerEmail), + eq(schema.documents.trashRootId, rootId), + ), + ); + await db + .update(schema.contentDatabases) + .set({ deletedAt: null, updatedAt: now }) + .where( + and( + inArray(schema.contentDatabases.documentId, batch), + eq(schema.contentDatabases.ownerEmail, ownerEmail), + ), + ); + } + + return documentIds; +} + async function deleteWhereIn( items: T[], run: (batch: T[]) => Promise, @@ -354,7 +476,8 @@ export async function deleteDocumentRecursive( } export default defineAction({ - description: "Delete a document and all its children recursively.", + description: + "Move a document and all its children to Trash. Use permanently-delete-document to destroy an item already in Trash.", schema: z.object({ id: z.string().optional().describe("Document ID (required)"), databaseDocumentId: z @@ -408,10 +531,12 @@ export default defineAction({ if (systemDatabase?.systemRole) { throw new Error("System Content database documents cannot be deleted"); } - const deleted = await deleteDocumentRecursive( - db, - id, - existing.ownerEmail as string, + const deleted = await db.transaction((tx) => + trashDocumentSubtree( + tx as unknown as ReturnType, + id, + existing.ownerEmail as string, + ), ); await writeAppState("refresh-signal", { ts: Date.now() }); diff --git a/templates/content/actions/get-document.ts b/templates/content/actions/get-document.ts index 77f6895c78..9fba8d6776 100644 --- a/templates/content/actions/get-document.ts +++ b/templates/content/actions/get-document.ts @@ -70,7 +70,7 @@ export default defineAction({ statusCode: 404, }); } - if (await isSoftDeletedDatabaseDocument(args.id)) { + if (access.resource.trashedAt || (await isSoftDeletedDatabaseDocument(args.id))) { throw Object.assign(new Error(`Document "${args.id}" not found`), { statusCode: 404, }); diff --git a/templates/content/actions/list-content-databases.ts b/templates/content/actions/list-content-databases.ts index 3c10da97ba..c334928118 100644 --- a/templates/content/actions/list-content-databases.ts +++ b/templates/content/actions/list-content-databases.ts @@ -60,6 +60,7 @@ export default defineAction({ .where( and( accessFilter(schema.documents, schema.documentShares), + isNull(schema.documents.trashedAt), documentDiscoveryFilter(), isNull(schema.contentDatabases.deletedAt), excludedDatabaseIds.size === 1 diff --git a/templates/content/actions/list-documents.ts b/templates/content/actions/list-documents.ts index 91f651698f..05023b0c50 100644 --- a/templates/content/actions/list-documents.ts +++ b/templates/content/actions/list-documents.ts @@ -106,6 +106,7 @@ export default defineAction({ accessFilter(schema.documents, schema.documentShares, context), ), ), + isNull(schema.documents.trashedAt), documentDiscoveryFilter({ userEmail, orgIds: authorizedOrgIds, diff --git a/templates/content/actions/list-trashed-content-databases.ts b/templates/content/actions/list-trashed-content-databases.ts index 1e9c4d9666..3b6dcebb29 100644 --- a/templates/content/actions/list-trashed-content-databases.ts +++ b/templates/content/actions/list-trashed-content-databases.ts @@ -47,6 +47,10 @@ export default defineAction({ .where( and( isNotNull(schema.contentDatabases.deletedAt), + or( + isNull(schema.documents.trashRootId), + eq(schema.documents.trashRootId, schema.documents.id), + ), or( and( isBlockOwned, diff --git a/templates/content/actions/list-trashed-documents.ts b/templates/content/actions/list-trashed-documents.ts new file mode 100644 index 0000000000..f4fd635769 --- /dev/null +++ b/templates/content/actions/list-trashed-documents.ts @@ -0,0 +1,50 @@ +import { defineAction } from "@agent-native/core"; +import { accessFilter } from "@agent-native/core/sharing"; +import { and, desc, eq, isNotNull, isNull } from "drizzle-orm"; +import { z } from "zod"; + +import { getDb, schema } from "../server/db/index.js"; +import type { ListTrashedDocumentsResponse } from "../shared/api.js"; + +export default defineAction({ + description: + "List trashed page roots the current user can manage. Titles are returned only after document access filtering.", + schema: z.object({}), + http: { method: "GET" }, + readOnly: true, + run: async (): Promise => { + const rows = await getDb() + .select({ + documentId: schema.documents.id, + title: schema.documents.title, + trashedAt: schema.documents.trashedAt, + }) + .from(schema.documents) + .leftJoin( + schema.contentDatabases, + eq(schema.contentDatabases.documentId, schema.documents.id), + ) + .where( + and( + isNotNull(schema.documents.trashedAt), + eq(schema.documents.trashRootId, schema.documents.id), + isNull(schema.contentDatabases.id), + accessFilter( + schema.documents, + schema.documentShares, + undefined, + "admin", + ), + ), + ) + .orderBy(desc(schema.documents.trashedAt)); + + return { + documents: rows.map((row) => ({ + documentId: row.documentId, + title: row.title.trim() || "Untitled", + trashedAt: row.trashedAt!, + })), + }; + }, +}); diff --git a/templates/content/actions/permanently-delete-document.ts b/templates/content/actions/permanently-delete-document.ts new file mode 100644 index 0000000000..b3aeb6a940 --- /dev/null +++ b/templates/content/actions/permanently-delete-document.ts @@ -0,0 +1,45 @@ +import { defineAction } from "@agent-native/core"; +import { writeAppState } from "@agent-native/core/application-state"; +import { assertAccess } from "@agent-native/core/sharing"; +import { eq } from "drizzle-orm"; +import { z } from "zod"; + +import { getDb, schema } from "../server/db/index.js"; +import { deleteDocumentRecursive } from "./delete-document.js"; + +export default defineAction({ + description: + "Permanently delete a document subtree that is already in Trash. This cannot be undone.", + schema: z.object({ + id: z.string().describe("Trashed root document ID"), + }), + run: async ({ id }) => { + const access = await assertAccess("document", id, "admin"); + const db = getDb(); + const [state] = await db + .select({ + trashedAt: schema.documents.trashedAt, + databaseDeletedAt: schema.contentDatabases.deletedAt, + }) + .from(schema.documents) + .leftJoin( + schema.contentDatabases, + eq(schema.contentDatabases.documentId, schema.documents.id), + ) + .where(eq(schema.documents.id, id)) + .limit(1); + if (!state?.trashedAt && !state?.databaseDeletedAt) { + throw new Error("Document must be in Trash before permanent deletion"); + } + + const deleted = await db.transaction((tx) => + deleteDocumentRecursive( + tx as unknown as ReturnType, + id, + access.resource.ownerEmail as string, + ), + ); + await writeAppState("refresh-signal", { ts: Date.now() }); + return { success: true, deleted: deleted.length }; + }, +}); diff --git a/templates/content/actions/restore-content-database.ts b/templates/content/actions/restore-content-database.ts index 022c83b742..8652e752d7 100644 --- a/templates/content/actions/restore-content-database.ts +++ b/templates/content/actions/restore-content-database.ts @@ -9,6 +9,7 @@ import { assertContentDatabaseLifecycleAccess, collectInlineDatabaseOwnerBlockIds, } from "./_content-database-lifecycle.js"; +import { restoreDocumentSubtree } from "./delete-document.js"; import pullDocumentAction from "./pull-document.js"; async function shouldClearStaleInlineOwnership(args: { @@ -47,16 +48,23 @@ export default defineAction({ ownerBlockId: ownership.database.ownerBlockId, }); - await db - .update(schema.contentDatabases) - .set({ - deletedAt: null, - updatedAt: now, - ...(clearInlineOwnership - ? { ownerDocumentId: null, ownerBlockId: null } - : {}), - }) - .where(eq(schema.contentDatabases.id, databaseId)); + await db.transaction(async (tx) => { + await restoreDocumentSubtree( + tx as unknown as ReturnType, + ownership.database.documentId, + ownership.database.ownerEmail, + ); + await tx + .update(schema.contentDatabases) + .set({ + deletedAt: null, + updatedAt: now, + ...(clearInlineOwnership + ? { ownerDocumentId: null, ownerBlockId: null } + : {}), + }) + .where(eq(schema.contentDatabases.id, databaseId)); + }); await writeAppState("refresh-signal", { ts: Date.now() }); diff --git a/templates/content/actions/restore-document.ts b/templates/content/actions/restore-document.ts new file mode 100644 index 0000000000..68a8a26a3c --- /dev/null +++ b/templates/content/actions/restore-document.ts @@ -0,0 +1,28 @@ +import { defineAction } from "@agent-native/core"; +import { writeAppState } from "@agent-native/core/application-state"; +import { assertAccess } from "@agent-native/core/sharing"; +import { z } from "zod"; + +import { getDb } from "../server/db/index.js"; +import { restoreDocumentSubtree } from "./delete-document.js"; + +export default defineAction({ + description: "Restore a page subtree from Trash.", + schema: z.object({ + id: z.string().describe("Trashed root document ID"), + }), + run: async ({ id }) => { + const access = await assertAccess("document", id, "admin"); + const restored = await getDb().transaction((tx) => + restoreDocumentSubtree( + tx as unknown as ReturnType, + id, + access.resource.ownerEmail as string, + ), + ); + if (restored.length === 0) throw new Error("Document is not in Trash"); + + await writeAppState("refresh-signal", { ts: Date.now() }); + return { success: true, restored: restored.length, documentId: id }; + }, +}); diff --git a/templates/content/actions/search-documents.ts b/templates/content/actions/search-documents.ts index ce67ed5630..17850c4710 100644 --- a/templates/content/actions/search-documents.ts +++ b/templates/content/actions/search-documents.ts @@ -1,6 +1,6 @@ import { defineAction } from "@agent-native/core"; import { accessFilter } from "@agent-native/core/sharing"; -import { and, sql } from "drizzle-orm"; +import { and, isNull, sql } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; @@ -74,6 +74,7 @@ export default defineAction({ .where( and( accessFilter(schema.documents, schema.documentShares), + isNull(schema.documents.trashedAt), documentDiscoveryFilter(), sql`(${schema.documents.title} LIKE ${pattern} ESCAPE '\\' OR ${schema.documents.description} LIKE ${pattern} ESCAPE '\\' OR ${schema.documents.content} LIKE ${pattern} ESCAPE '\\')`, ), diff --git a/templates/content/actions/view-screen.ts b/templates/content/actions/view-screen.ts index 8ad90e0a87..7679f13f61 100644 --- a/templates/content/actions/view-screen.ts +++ b/templates/content/actions/view-screen.ts @@ -4,7 +4,7 @@ import { readAppStateForCurrentTab, } from "@agent-native/core/application-state"; import { accessFilter, resolveAccess } from "@agent-native/core/sharing"; -import { and, asc, inArray } from "drizzle-orm"; +import { and, asc, inArray, isNull } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; @@ -765,6 +765,7 @@ export default defineAction({ .where( and( accessFilter(schema.documents, schema.documentShares), + isNull(schema.documents.trashedAt), documentDiscoveryFilter(), ), ) diff --git a/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts b/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts index af5d8e8977..e7757a194d 100644 --- a/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts +++ b/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts @@ -194,6 +194,12 @@ describe("document sidebar layout", () => { '"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).not.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).not.toContain( '
', ); - expect(sidebar).not.toContain(""); + expect(sidebar).toContain( + 'import { OrgSwitcher } from "@agent-native/core/client/org";', + ); + expect(sidebar).toContain(""); + expect(sidebar.indexOf("")).toBeLessThan( + sidebar.indexOf(""), + ); + expect(sidebar.indexOf("")).toBeLessThan( + sidebar.indexOf("{/* Footer */}"), + ); expect(sidebar).toContain('t("sidebar.addWorkspace")'); expect(sidebar).toContain('t("sidebar.newWorkspace")'); expect(sidebar).toContain("useCreateContentSpace"); @@ -241,14 +256,20 @@ describe("document sidebar layout", () => { expect(sidebar).toContain(''); }); - it("keeps the trashed inline database lifecycle visible in the sidebar", () => { + it("keeps a unified page and database Trash lifecycle visible in the sidebar", () => { const sidebar = readSidebarSource("./DocumentSidebar.tsx"); const messages = readSidebarSource("../../i18n-data.ts"); expect(sidebar).toContain("useTrashedContentDatabases"); + expect(sidebar).toContain("useTrashedDocuments"); expect(sidebar).toContain("useDeleteContentDatabase"); expect(sidebar).toContain("useRestoreContentDatabase"); expect(sidebar).toContain("const trashItems ="); + expect(sidebar).toContain("const trashedPageItems ="); + expect(sidebar).toContain("const handleRestoreDocument = useCallback"); + expect(sidebar).toContain( + "const handlePermanentDeleteDocument = useCallback", + ); expect(sidebar).toContain("const handleRestoreDatabase = useCallback"); expect(sidebar).toContain( "const handlePermanentDeleteDatabase = useCallback", @@ -259,6 +280,8 @@ describe("document sidebar layout", () => { ); expect(sidebar).toContain("handleRestoreDatabase(database.databaseId)"); expect(sidebar).toContain("handlePermanentDeleteDatabase"); + expect(sidebar).toContain("handleRestoreDocument(document.documentId)"); + expect(sidebar).toContain("handlePermanentDeleteDocument"); expect(sidebar).toContain("database.documentId"); expect(sidebar).toContain("database.canPermanentlyDelete"); expect(sidebar).toContain("deletedDocument?.database"); @@ -270,6 +293,8 @@ describe("document sidebar layout", () => { expect(messages).toContain('trash: "Trash"'); expect(messages).toContain('restoreDatabase: "Restore"'); + expect(messages).toContain('restorePage: "Restore"'); + expect(messages).toContain('trashEmpty: "Trash is empty"'); expect(messages).toContain( 'deleteDatabasePermanentlyQuestion: "Delete database permanently?"', ); @@ -325,6 +350,10 @@ describe("document sidebar layout", () => { expect(sidebar).toContain('toggleSection("favorites")'); expect(sidebar).toContain("!collapsedSections.favorites &&"); expect(sidebar).toContain("aria-expanded={!collapsedSections.favorites}"); + expect(sidebar).toContain(" doc.id === activeDocumentId) : null; const trashItems = trashedDatabases?.databases ?? []; + const trashedPageItems = trashedDocuments?.documents ?? []; const parentByDocumentId = useMemo( () => new Map(documents.map((doc) => [doc.id, doc.parentId])), [documents], @@ -1124,7 +1133,7 @@ export function DocumentSidebar({ const handlePermanentDeleteDatabase = useCallback( async (documentId: string) => { try { - await deleteDocument.mutateAsync({ id: documentId }); + await permanentlyDeleteDocument.mutateAsync({ id: documentId }); queryClient.invalidateQueries({ queryKey: ["action", "list-documents"], }); @@ -1139,7 +1148,37 @@ export function DocumentSidebar({ }); } }, - [deleteDocument, queryClient, t], + [permanentlyDeleteDocument, queryClient, t], + ); + + const handleRestoreDocument = useCallback( + async (documentId: string) => { + try { + await restoreDocument.mutateAsync({ id: documentId }); + toast.success(t("sidebar.pageRestored")); + } catch (err) { + toast.error(t("sidebar.failedRestorePage"), { + description: + err instanceof Error ? err.message : t("empty.genericError"), + }); + } + }, + [restoreDocument, t], + ); + + const handlePermanentDeleteDocument = useCallback( + async (documentId: string) => { + try { + await permanentlyDeleteDocument.mutateAsync({ id: documentId }); + toast.success(t("sidebar.pagePermanentlyDeleted")); + } catch (err) { + toast.error(t("sidebar.failedPermanentDeletePage"), { + description: + err instanceof Error ? err.message : t("empty.genericError"), + }); + } + }, + [permanentlyDeleteDocument, t], ); const handleRemoveLocalFiles = useCallback(async () => { @@ -1423,21 +1462,21 @@ export function DocumentSidebar({ type="button" aria-expanded={expanded} aria-label={`${expanded ? t("sidebar.collapse") : t("sidebar.expand")} ${space.name}`} - className="relative flex size-7 shrink-0 items-center justify-center rounded-md hover:bg-background/60" + className="group/workspace-toggle relative flex size-7 shrink-0 items-center justify-center rounded-md hover:bg-background/60" onClick={() => updateExpandedWorkspaceIds((current) => toggleExpandedWorkspaceIds(current, space.id), ) } > - + {expanded ? ( ) : ( )} - + {expanded ? ( ) : ( @@ -1605,7 +1644,6 @@ export function DocumentSidebar({ ); const renderTrashSection = () => { - if (trashItems.length === 0) return null; const collapsed = collapsedSections.trash; return ( @@ -1613,6 +1651,89 @@ export function DocumentSidebar({ {renderSectionHeader("trash", t("sidebar.trash"))} {!collapsed && (
+ {trashedPageItems.map((document) => { + const title = document.title || t("sidebar.untitled"); + return ( +
+ {title} + + + + + {t("sidebar.restorePage")} + + + + + + + + + + {t("sidebar.deletePermanently")} + + + + + + {t("sidebar.deletePagePermanentlyQuestion")} + + + {t("sidebar.deletePagePermanentlyDescription", { + title, + })} + + + + + {t("comments.cancel")} + + + void handlePermanentDeleteDocument( + document.documentId, + ) + } + > + {t("sidebar.deletePermanently")} + + + + +
+ ); + })} + {trashedPageItems.length === 0 && trashItems.length === 0 ? ( +
+ {t("sidebar.trashEmpty")} +
+ ) : null} {trashItems.map((database) => { const title = database.title || t("editor.untitledDatabase"); return ( @@ -1653,7 +1774,7 @@ export function DocumentSidebar({ { title }, )} className="flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-destructive/10 hover:text-destructive disabled:opacity-50" - disabled={deleteDocument.isPending} + disabled={permanentlyDeleteDocument.isPending} > @@ -1873,19 +1994,27 @@ export function DocumentSidebar({ {/* Favorites */} {showFavorites && (
-
+
+
+ +
+ {/* Footer */}
{isCodeMode ? : null} diff --git a/templates/content/app/hooks/use-content-database.ts b/templates/content/app/hooks/use-content-database.ts index b4afac6bce..cb37c67235 100644 --- a/templates/content/app/hooks/use-content-database.ts +++ b/templates/content/app/hooks/use-content-database.ts @@ -622,6 +622,9 @@ export function useDeleteContentDatabase() { >("delete-content-database", { onSuccess: (data) => { clearDeletedContentDatabaseFromCache(queryClient, data.documentId); + queryClient.invalidateQueries({ + queryKey: ["action", "list-trashed-documents"], + }); }, }); } @@ -650,6 +653,9 @@ export function useRestoreContentDatabase() { queryClient.invalidateQueries({ queryKey: ["action", "list-trashed-content-databases"], }); + queryClient.invalidateQueries({ + queryKey: ["action", "list-trashed-documents"], + }); queryClient.invalidateQueries({ queryKey: ["action", "list-content-databases"], }); diff --git a/templates/content/app/hooks/use-documents.ts b/templates/content/app/hooks/use-documents.ts index 9d387b2643..4f6f0b5c2d 100644 --- a/templates/content/app/hooks/use-documents.ts +++ b/templates/content/app/hooks/use-documents.ts @@ -11,6 +11,7 @@ import type { DocumentUpdateRequest, DocumentUpdateResponse, DocumentMoveRequest, + ListTrashedDocumentsResponse, DocumentTreeNode, } from "@shared/api"; import type { QueryClient } from "@tanstack/react-query"; @@ -534,6 +535,62 @@ export function useDeleteDocument() { queryClient.invalidateQueries({ queryKey: ["action", "list-trashed-content-databases"], }); + queryClient.invalidateQueries({ + queryKey: ["action", "list-trashed-documents"], + }); + }, + }); +} + +export function useTrashedDocuments() { + return useActionQuery( + "list-trashed-documents", + {}, + ); +} + +export function useRestoreDocument() { + const queryClient = useQueryClient(); + return useActionMutation< + { success: boolean; restored: number; documentId: string }, + { id: string } + >("restore-document", { + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["action", "list-documents"], + }); + queryClient.invalidateQueries({ + queryKey: ["action", "get-content-database"], + }); + queryClient.invalidateQueries({ + queryKey: ["action", "list-trashed-documents"], + }); + queryClient.invalidateQueries({ + queryKey: ["action", "list-trashed-content-databases"], + }); + }, + }); +} + +export function usePermanentlyDeleteDocument() { + const queryClient = useQueryClient(); + return useActionMutation< + { success: boolean; deleted: number }, + { id: string } + >("permanently-delete-document", { + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["action", "list-documents"], + }); + queryClient.invalidateQueries({ + queryKey: ["action", "get-content-database"], + }); + queryClient.invalidateQueries({ + queryKey: ["action", "list-trashed-documents"], + }); + queryClient.invalidateQueries({ + queryKey: ["action", "list-trashed-content-databases"], + }); }, }); } diff --git a/templates/content/app/i18n-data.ts b/templates/content/app/i18n-data.ts index 7315d9f03f..4dd18acdaa 100644 --- a/templates/content/app/i18n-data.ts +++ b/templates/content/app/i18n-data.ts @@ -3108,24 +3108,32 @@ const enUS = { database: "Database", databasePermanentlyDeleted: "Database permanently deleted", databaseRestored: "Database restored", + pagePermanentlyDeleted: "Page permanently deleted", + pageRestored: "Page restored", deleteDatabaseNamedPermanently: "Delete {{title}} permanently", deleteDatabasePermanentlyDescription: "This permanently deletes “{{title}}” and its pages. This cannot be undone.", deleteDatabasePermanentlyQuestion: "Delete database permanently?", + deletePageNamedPermanently: "Delete {{title}} permanently", + deletePagePermanentlyDescription: + "This permanently deletes “{{title}}” and its sub-pages. This cannot be undone.", + deletePagePermanentlyQuestion: "Delete page permanently?", deletePermanently: "Delete permanently", failedCreateDatabase: "Failed to create database", failedCreatePage: "Failed to create page", failedCreateWorkspace: "Failed to create workspace", failedDeletePage: "Failed to delete page", failedPermanentDeleteDatabase: "Failed to permanently delete database", + failedPermanentDeletePage: "Failed to permanently delete page", failedMovePage: "Failed to move page", failedUpdateFavorite: "Could not update favorite", removeFromFavorites: "Remove from favorites", failedRemoveLocalFiles: "Failed to remove local files", failedRestoreDatabase: "Failed to restore database", + failedRestorePage: "Failed to restore page", deletePageDescription: - "“{{title}}” and all its sub-pages will be permanently deleted. This cannot be undone.", - deletePageQuestion: "Delete page?", + "“{{title}}” and all its sub-pages will move to Trash. You can restore them later.", + deletePageQuestion: "Move page to Trash?", localFiles: "Local files", localFolder: "Local folder", files: "Files", @@ -3192,10 +3200,13 @@ const enUS = { removeLocalFilesQuestion: "Remove local files from sidebar?", restoreDatabase: "Restore", restoreDatabaseNamed: "Restore {{title}}", + restorePage: "Restore", + restorePageNamed: "Restore {{title}}", saving: "Saving...", sharedCopies: "Shared copies", synced: "Synced", trash: "Trash", + trashEmpty: "Trash is empty", favorites: "Favorites", untitled: "Untitled", workspaces: "Workspaces", diff --git a/templates/content/changelog/2026-07-19-pages-now-move-to-a-reversible-trash-and-the-organization-pi.md b/templates/content/changelog/2026-07-19-pages-now-move-to-a-reversible-trash-and-the-organization-pi.md new file mode 100644 index 0000000000..8382d43b51 --- /dev/null +++ b/templates/content/changelog/2026-07-19-pages-now-move-to-a-reversible-trash-and-the-organization-pi.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-07-19 +--- + +Pages now move to a reversible Trash, and the organization picker is restored in the sidebar. diff --git a/templates/content/parity/matrix.md b/templates/content/parity/matrix.md index 321812dd03..5038b525c8 100644 --- a/templates/content/parity/matrix.md +++ b/templates/content/parity/matrix.md @@ -20,7 +20,7 @@ This generated matrix tracks whether high-value Content UI operations use the sa | 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-documents`, `move-document`, `search-documents`, `update-document` | `app/components/sidebar/DocumentSidebar.tsx`, `app/components/sidebar/DocumentTreeItem.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.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/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 | - | - | - | | 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` | - | diff --git a/templates/content/parity/matrix.ts b/templates/content/parity/matrix.ts index 8423933bf0..5c76b031b2 100644 --- a/templates/content/parity/matrix.ts +++ b/templates/content/parity/matrix.ts @@ -20,8 +20,11 @@ export const parityMatrix: ParityRow[] = [ "clone-creative-context-document", "delete-document", "get-document", + "list-trashed-documents", "list-documents", "move-document", + "permanently-delete-document", + "restore-document", "search-documents", "update-document", ], diff --git a/templates/content/server/__tests__/db.spec.ts b/templates/content/server/__tests__/db.spec.ts index 30d5c206fb..218c6450f8 100644 --- a/templates/content/server/__tests__/db.spec.ts +++ b/templates/content/server/__tests__/db.spec.ts @@ -91,6 +91,23 @@ describe("content database migrations", () => { ); }); + it("adds the document trash lifecycle additively", () => { + const source = readFileSync( + join(__dirname, "..", "plugins", "db.ts"), + "utf8", + ); + + expect(source).toContain( + "ALTER TABLE documents ADD COLUMN IF NOT EXISTS trashed_at TEXT", + ); + expect(source).toContain( + "ALTER TABLE documents ADD COLUMN IF NOT EXISTS trash_root_id TEXT", + ); + expect(source).toContain( + "documents_trash_idx ON documents (owner_email, trashed_at, trash_root_id)", + ); + }); + it("creates Builder MDX sidecar cache table additively", () => { const source = readFileSync( join(__dirname, "..", "plugins", "db.ts"), diff --git a/templates/content/server/db/schema.ts b/templates/content/server/db/schema.ts index 6b8d894d61..0f32c9d1af 100644 --- a/templates/content/server/db/schema.ts +++ b/templates/content/server/db/schema.ts @@ -27,6 +27,8 @@ export const documents = table("documents", { sourcePath: text("source_path"), sourceRootPath: text("source_root_path"), sourceUpdatedAt: text("source_updated_at"), + trashedAt: text("trashed_at"), + trashRootId: text("trash_root_id"), createdAt: text("created_at").notNull().default(now()), updatedAt: text("updated_at").notNull().default(now()), ...ownableColumns(), diff --git a/templates/content/server/lib/public-documents.ts b/templates/content/server/lib/public-documents.ts index 2c72eca723..c81d07703b 100644 --- a/templates/content/server/lib/public-documents.ts +++ b/templates/content/server/lib/public-documents.ts @@ -88,7 +88,7 @@ async function getPublicDocumentForEvent(event: H3Event) { const { getDb } = await import("../db/index.js"); const { documents } = await import("../db/schema.js"); - const { and, eq } = await import("drizzle-orm"); + const { and, eq, isNull } = await import("drizzle-orm"); const [doc] = await getDb() .select({ @@ -100,7 +100,13 @@ async function getPublicDocumentForEvent(event: H3Event) { visibility: documents.visibility, }) .from(documents) - .where(and(eq(documents.id, id), eq(documents.visibility, "public"))) + .where( + and( + eq(documents.id, id), + eq(documents.visibility, "public"), + isNull(documents.trashedAt), + ), + ) .limit(1); return doc ?? null; diff --git a/templates/content/server/plugins/db.ts b/templates/content/server/plugins/db.ts index 375b76d7e8..bf7ac8f8aa 100644 --- a/templates/content/server/plugins/db.ts +++ b/templates/content/server/plugins/db.ts @@ -856,6 +856,13 @@ const runContentMigrations = runMigrations( CREATE UNIQUE INDEX IF NOT EXISTS document_property_definitions_database_system_role_unique ON document_property_definitions (database_id, system_role)`, }, + { + version: 76, + name: "document-trash-lifecycle", + sql: `ALTER TABLE documents ADD COLUMN IF NOT EXISTS trashed_at TEXT; + ALTER TABLE documents ADD COLUMN IF NOT EXISTS trash_root_id TEXT; + CREATE INDEX IF NOT EXISTS documents_trash_idx ON documents (owner_email, trashed_at, trash_root_id)`, + }, ], { table: "content_migrations" }, ); diff --git a/templates/content/server/routes/api/document-agent-context.json.get.test.ts b/templates/content/server/routes/api/document-agent-context.json.get.test.ts index 464bcf7bc8..1589f38321 100644 --- a/templates/content/server/routes/api/document-agent-context.json.get.test.ts +++ b/templates/content/server/routes/api/document-agent-context.json.get.test.ts @@ -23,7 +23,9 @@ vi.mock("@agent-native/core/server", () => ({ })); vi.mock("drizzle-orm", () => ({ + and: (...args: unknown[]) => args, eq: (...args: unknown[]) => args, + isNull: (...args: unknown[]) => args, })); vi.mock("h3", () => ({ diff --git a/templates/content/server/routes/api/document-agent-context.json.get.ts b/templates/content/server/routes/api/document-agent-context.json.get.ts index 56b9f0dfbe..a4d93cfd90 100644 --- a/templates/content/server/routes/api/document-agent-context.json.get.ts +++ b/templates/content/server/routes/api/document-agent-context.json.get.ts @@ -3,7 +3,7 @@ import { getConfiguredAppBasePath, verifyScopedAgentAccessToken, } from "@agent-native/core/server"; -import { eq } from "drizzle-orm"; +import { and, eq, isNull } from "drizzle-orm"; import { defineEventHandler, getQuery, @@ -56,7 +56,7 @@ export default defineEventHandler(async (event) => { createdAt: schema.documents.createdAt, }) .from(schema.documents) - .where(eq(schema.documents.id, id)) + .where(and(eq(schema.documents.id, id), isNull(schema.documents.trashedAt))) .limit(1); if (!document) { diff --git a/templates/content/shared/api.ts b/templates/content/shared/api.ts index f80f795e47..9a0a31840a 100644 --- a/templates/content/shared/api.ts +++ b/templates/content/shared/api.ts @@ -911,6 +911,16 @@ export interface ListTrashedContentDatabasesResponse { databases: TrashedContentDatabaseSummary[]; } +export interface TrashedDocumentSummary { + documentId: string; + title: string; + trashedAt: string; +} + +export interface ListTrashedDocumentsResponse { + documents: TrashedDocumentSummary[]; +} + export interface SuggestSourceJoinKeyRequest { databaseId?: string; documentId?: string; From 3ef24db9a5fe5c961dacd1ddd988c38150bc9ac7 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Mon, 20 Jul 2026 06:32:24 -0400 Subject: [PATCH 69/76] chore: drop released workspace changesets --- .changeset/await-action-success-callbacks.md | 5 ----- .changeset/calm-otters-preview-auth.md | 5 ----- .changeset/fence-incompatible-browser-clients.md | 5 ----- .changeset/honor-personal-org-context.md | 5 ----- 4 files changed, 20 deletions(-) delete mode 100644 .changeset/await-action-success-callbacks.md delete mode 100644 .changeset/calm-otters-preview-auth.md delete mode 100644 .changeset/fence-incompatible-browser-clients.md delete mode 100644 .changeset/honor-personal-org-context.md diff --git a/.changeset/await-action-success-callbacks.md b/.changeset/await-action-success-callbacks.md deleted file mode 100644 index 20f4baba3e..0000000000 --- a/.changeset/await-action-success-callbacks.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@agent-native/core": patch ---- - -Keep action mutations pending until asynchronous success callbacks finish. diff --git a/.changeset/calm-otters-preview-auth.md b/.changeset/calm-otters-preview-auth.md deleted file mode 100644 index cc70951f19..0000000000 --- a/.changeset/calm-otters-preview-auth.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@agent-native/core": patch ---- - -Allow email-password signup without email verification on Netlify deploy previews while preserving explicit verification overrides, and hold auth requests until their serverless routes finish mounting. diff --git a/.changeset/fence-incompatible-browser-clients.md b/.changeset/fence-incompatible-browser-clients.md deleted file mode 100644 index b2d397e24f..0000000000 --- a/.changeset/fence-incompatible-browser-clients.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@agent-native/core": patch ---- - -Fence incompatible cached browser clients from newer action backends and reload version-aware tabs with a cache-busted build. diff --git a/.changeset/honor-personal-org-context.md b/.changeset/honor-personal-org-context.md deleted file mode 100644 index 11ad248c37..0000000000 --- a/.changeset/honor-personal-org-context.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@agent-native/core": patch ---- - -Honor an explicit Personal context for users who also belong to organizations. From d9866bfea7ccfad22d4c0ea8ca6f60cc90b0653f Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Mon, 20 Jul 2026 06:34:15 -0400 Subject: [PATCH 70/76] docs(content): note sidebar disclosure polish --- ...es-now-move-to-a-reversible-trash-and-the-organization-pi.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/content/changelog/2026-07-19-pages-now-move-to-a-reversible-trash-and-the-organization-pi.md b/templates/content/changelog/2026-07-19-pages-now-move-to-a-reversible-trash-and-the-organization-pi.md index 8382d43b51..7c343da9c6 100644 --- a/templates/content/changelog/2026-07-19-pages-now-move-to-a-reversible-trash-and-the-organization-pi.md +++ b/templates/content/changelog/2026-07-19-pages-now-move-to-a-reversible-trash-and-the-organization-pi.md @@ -3,4 +3,4 @@ type: fixed date: 2026-07-19 --- -Pages now move to a reversible Trash, and the organization picker is restored in the sidebar. +Pages now move to a reversible Trash, the organization picker is restored, and workspace and Favorites disclosure icons behave consistently in the sidebar. From a7a810d0bec7c2e3bb3d8b1a7b274ad4592e61b7 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Mon, 20 Jul 2026 06:47:52 -0400 Subject: [PATCH 71/76] fix(content): align collapsed trash navigation --- .../sidebar/DocumentSidebar.layout.test.ts | 11 ++-- .../components/sidebar/DocumentSidebar.tsx | 50 +++++++++++++++++-- ...eversible-trash-and-the-organization-pi.md | 2 +- 3 files changed, 56 insertions(+), 7 deletions(-) diff --git a/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts b/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts index e7757a194d..46f810f565 100644 --- a/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts +++ b/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts @@ -275,9 +275,14 @@ describe("document sidebar layout", () => { "const handlePermanentDeleteDatabase = useCallback", ); expect(sidebar).toContain("const renderTrashSection = () =>"); - expect(sidebar).toContain( - 'renderSectionHeader("trash", t("sidebar.trash"))', - ); + expect(sidebar).toContain("trash: true"); + expect(sidebar).toContain("value?.trash ?? true"); + expect(sidebar).toContain("TRASH_COLLAPSED_DEFAULT_MIGRATION_KEY"); + expect(sidebar).toContain('toggleSection("trash")'); + expect(sidebar).toContain("; 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 DEFAULT_COLLAPSED_SECTIONS: CollapsedSectionsState = { favorites: false, @@ -224,7 +226,7 @@ const DEFAULT_COLLAPSED_SECTIONS: CollapsedSectionsState = { "shared-copies": false, private: false, organization: false, - trash: false, + trash: true, }; function normalizeCollapsedSections( @@ -236,7 +238,7 @@ function normalizeCollapsedSections( "shared-copies": value?.["shared-copies"] ?? false, private: value?.private ?? false, organization: value?.organization ?? false, - trash: value?.trash ?? false, + trash: value?.trash ?? true, }; } @@ -642,6 +644,21 @@ export function DocumentSidebar({ () => normalizeCollapsedSections(storedCollapsedSections), [storedCollapsedSections], ); + useEffect(() => { + try { + if ( + window.localStorage.getItem(TRASH_COLLAPSED_DEFAULT_MIGRATION_KEY) === + "1" + ) { + return; + } + setStoredCollapsedSections((current) => ({ + ...normalizeCollapsedSections(current), + trash: true, + })); + window.localStorage.setItem(TRASH_COLLAPSED_DEFAULT_MIGRATION_KEY, "1"); + } catch {} + }, [setStoredCollapsedSections]); const [removeLocalFilesDialogOpen, setRemoveLocalFilesDialogOpen] = useState(false); const agentActive = location.pathname.startsWith("/agent"); @@ -1648,7 +1665,34 @@ export function DocumentSidebar({ return (
- {renderSectionHeader("trash", t("sidebar.trash"))} +
+ +
{!collapsed && (
{trashedPageItems.map((document) => { diff --git a/templates/content/changelog/2026-07-19-pages-now-move-to-a-reversible-trash-and-the-organization-pi.md b/templates/content/changelog/2026-07-19-pages-now-move-to-a-reversible-trash-and-the-organization-pi.md index 7c343da9c6..df0d7fa19f 100644 --- a/templates/content/changelog/2026-07-19-pages-now-move-to-a-reversible-trash-and-the-organization-pi.md +++ b/templates/content/changelog/2026-07-19-pages-now-move-to-a-reversible-trash-and-the-organization-pi.md @@ -3,4 +3,4 @@ type: fixed date: 2026-07-19 --- -Pages now move to a reversible Trash, the organization picker is restored, and workspace and Favorites disclosure icons behave consistently in the sidebar. +Pages now move to a reversible Trash, the organization picker is restored, and sidebar disclosure icons behave consistently. From 58705d9987b47c6275912708b87f235e5af160cd Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Mon, 20 Jul 2026 08:02:57 -0400 Subject: [PATCH 72/76] fix(content): share workspace source chooser --- templates/content/actions/get-document.ts | 5 +- .../editor/database/DatabaseView.tsx | 163 ++++++++++++------ .../DatabaseView.workspaces.layout.test.ts | 17 +- .../sidebar/DocumentSidebar.layout.test.ts | 8 +- .../components/sidebar/DocumentSidebar.tsx | 144 +++------------- .../WorkspaceSourceMenu.layout.test.ts | 20 +++ .../sidebar/WorkspaceSourceMenu.tsx | 151 ++++++++++++++++ templates/content/app/i18n-data.ts | 56 ++++++ templates/content/app/i18n/zh-TW.ts | 11 ++ ...ed-from-a-blank-workspace-or-a-connecte.md | 6 + 10 files changed, 396 insertions(+), 185 deletions(-) create mode 100644 templates/content/app/components/sidebar/WorkspaceSourceMenu.layout.test.ts create mode 100644 templates/content/app/components/sidebar/WorkspaceSourceMenu.tsx create mode 100644 templates/content/changelog/2026-07-20-workspaces-can-be-added-from-a-blank-workspace-or-a-connecte.md diff --git a/templates/content/actions/get-document.ts b/templates/content/actions/get-document.ts index 9fba8d6776..73ed27321b 100644 --- a/templates/content/actions/get-document.ts +++ b/templates/content/actions/get-document.ts @@ -70,7 +70,10 @@ export default defineAction({ statusCode: 404, }); } - if (access.resource.trashedAt || (await isSoftDeletedDatabaseDocument(args.id))) { + if ( + access.resource.trashedAt || + (await isSoftDeletedDatabaseDocument(args.id)) + ) { throw Object.assign(new Error(`Document "${args.id}" not found`), { statusCode: 404, }); diff --git a/templates/content/app/components/editor/database/DatabaseView.tsx b/templates/content/app/components/editor/database/DatabaseView.tsx index 4eb24db3a2..fc9e4787b2 100644 --- a/templates/content/app/components/editor/database/DatabaseView.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.tsx @@ -157,6 +157,7 @@ import { SELECTED_CONTENT_SPACE_STORAGE_KEY, selectContentSpace, } from "@/components/sidebar/select-content-space"; +import { WorkspaceSourceMenu } from "@/components/sidebar/WorkspaceSourceMenu"; import { isContentDatabaseUnavailable, useAddDatabaseItem, @@ -188,7 +189,6 @@ import { } from "@/hooks/use-content-database"; import { useContentSpaces, - useCreateContentSpace, useDeleteContentSpace, } from "@/hooks/use-content-spaces"; import { @@ -729,8 +729,6 @@ function DatabaseTable({ ); const database = useContentDatabase(document.id, databaseRequestItemLimit); const addItem = useAddDatabaseItem(document.id); - const createContentSpace = useCreateContentSpace(); - const workspaceCreateRequestIdRef = useRef(null); const attachSource = useAttachContentDatabaseSource(document.id); const changeSourceRole = useChangeContentDatabaseSourceRole(document.id); const refreshSource = useRefreshContentDatabaseSource(document.id); @@ -750,10 +748,8 @@ function DatabaseTable({ const data = isContentDatabaseUnavailable(database.data) ? undefined : database.data; - const isCreatingDatabaseItem = - data?.database.systemRole === "workspaces" - ? createContentSpace.isPending - : addItem.isPending; + const isWorkspaceCatalog = data?.database.systemRole === "workspaces"; + const isCreatingDatabaseItem = addItem.isPending; const isDatabaseInitialLoading = database.isLoading && !data; const properties = data?.properties ?? []; const items = data?.items ?? []; @@ -764,10 +760,9 @@ function DatabaseTable({ const isLoadingMoreItems = database.isFetching && data?.pagination?.limit !== databaseRequestItemLimit; const databaseId = data?.database.id ?? expectedDatabaseId; - const newDatabaseRowLabel = - data?.database.systemRole === "workspaces" - ? t("sidebar.newWorkspace") - : dbText("newPage"); + const newDatabaseRowLabel = isWorkspaceCatalog + ? t("sidebar.addWorkspace") + : dbText("newPage"); const personalView = useContentDatabasePersonalView(databaseId); const updatePersonalView = useUpdateContentDatabasePersonalView(databaseId); const source = data?.source ?? null; @@ -1427,29 +1422,7 @@ function DatabaseTable({ } = {}, ) { if (!databaseId) return null; - if (data?.database.systemRole === "workspaces") { - const name = title.trim() || t("sidebar.newWorkspace"); - const requestId = - workspaceCreateRequestIdRef.current ?? crypto.randomUUID(); - workspaceCreateRequestIdRef.current = requestId; - try { - await createContentSpace.mutateAsync({ - name, - requestId, - propertyValues: - Object.keys(propertyValueOverrides).length > 0 - ? propertyValueOverrides - : undefined, - }); - workspaceCreateRequestIdRef.current = null; - } catch (err) { - toast.error(dbText("failedToCreateRow"), { - description: - err instanceof Error ? err.message : dbText("somethingWentWrong"), - }); - } - return null; - } + if (isWorkspaceCatalog) return null; const propertyValues = { ...databasePropertyValuesForNewItem(filters, properties, filterMode), ...propertyValueOverrides, @@ -2417,7 +2390,18 @@ function DatabaseTable({ ) : null} - {canEdit ? ( + {canEdit && isWorkspaceCatalog ? ( + + + + ) : canEdit ? ( + + ); +} + function NewDatabaseRow({ label, properties, @@ -17918,15 +17964,24 @@ function DatabaseGroupedTableSection({ /> ))} {canEdit ? ( - onCreateRow(group, title)} - /> + workspaceCatalog ? ( + + ) : ( + onCreateRow(group, title)} + /> + ) ) : null} ) : null} diff --git a/templates/content/app/components/editor/database/DatabaseView.workspaces.layout.test.ts b/templates/content/app/components/editor/database/DatabaseView.workspaces.layout.test.ts index aab464a7cb..963ec7e844 100644 --- a/templates/content/app/components/editor/database/DatabaseView.workspaces.layout.test.ts +++ b/templates/content/app/components/editor/database/DatabaseView.workspaces.layout.test.ts @@ -3,21 +3,26 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; describe("Workspaces database lifecycle", () => { - it("routes every create surface through workspace semantics and shared pending state", () => { + it("routes every create surface through the shared workspace source chooser", () => { const source = readFileSync( new URL("./DatabaseView.tsx", import.meta.url), "utf8", ); - expect(source).toContain('data?.database.systemRole === "workspaces"'); - expect(source).toContain("createContentSpace.mutateAsync"); + expect(source).toContain( + 'const isWorkspaceCatalog = data?.database.systemRole === "workspaces"', + ); + expect(source).toContain( + 'import { WorkspaceSourceMenu } from "@/components/sidebar/WorkspaceSourceMenu"', + ); + expect(source.match(/)/g)).toHaveLength(2); + expect(source.match(/ { sidebar.indexOf("{/* Footer */}"), ); expect(sidebar).toContain('t("sidebar.addWorkspace")'); - expect(sidebar).toContain('t("sidebar.newWorkspace")'); - expect(sidebar).toContain("useCreateContentSpace"); - expect(sidebar).toContain("handleCreateWorkspace"); + expect(sidebar).toContain(""); expect(sidebar).toContain("name: item.document.title || space.name"); expect(sidebar).toContain("scroll={false}"); - expect(sidebar).toContain(''); }); it("keeps a unified page and database Trash lifecycle visible in the sidebar", () => { diff --git a/templates/content/app/components/sidebar/DocumentSidebar.tsx b/templates/content/app/components/sidebar/DocumentSidebar.tsx index a78d9ce811..01c3fd5e39 100644 --- a/templates/content/app/components/sidebar/DocumentSidebar.tsx +++ b/templates/content/app/components/sidebar/DocumentSidebar.tsx @@ -78,14 +78,6 @@ import { AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; import { DropdownMenu, DropdownMenuContent, @@ -93,7 +85,6 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { Input } from "@/components/ui/input"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Tooltip, @@ -113,7 +104,6 @@ import { useTrashedContentDatabases, } from "@/hooks/use-content-database"; import { - useCreateContentSpace, useContentSpaces, useEnsureContentSpaces, type ContentSpaceSummary, @@ -152,6 +142,10 @@ import { selectContentSpace, toggleExpandedWorkspaceIds, } from "./select-content-space"; +import { + WorkspaceSourceMenu, + type CreatedWorkspace, +} from "./WorkspaceSourceMenu"; function nanoid(size = 12): string { const chars = @@ -367,7 +361,6 @@ export function DocumentSidebar({ const { isCodeMode } = useCodeMode(); const updateDocument = useUpdateDocument(); const contentSpacesQuery = useContentSpaces(); - const createContentSpace = useCreateContentSpace(); const ensureContentSpaces = useEnsureContentSpaces(); const workspaceSelectionQueueRef = useRef(createContentSpaceSelectionQueue()); const contentSpaces = contentSpacesQuery.data?.spaces ?? []; @@ -430,10 +423,6 @@ export function DocumentSidebar({ const sidebarStateWriteTimerRef = useRef | null>(null); - const [createWorkspaceDialogOpen, setCreateWorkspaceDialogOpen] = - useState(false); - const [newWorkspaceName, setNewWorkspaceName] = useState(""); - const createWorkspaceRequestIdRef = useRef(null); const contentSpaceState = contentSpaceAvailability({ hasSelectedSpace: Boolean(selectedSpace), contentSpacesLoading: contentSpacesQuery.isLoading, @@ -582,14 +571,9 @@ export function DocumentSidebar({ }, [navigate, setStoredSpaceId, updateExpandedWorkspaceIds], ); - const handleCreateWorkspace = useCallback(async () => { - const name = newWorkspaceName.trim(); - if (!name) return; - const requestId = createWorkspaceRequestIdRef.current ?? nanoid(); - createWorkspaceRequestIdRef.current = requestId; - try { - const created = await createContentSpace.mutateAsync({ name, requestId }); - const space: ContentSpaceSummary = { + const handleWorkspaceCreated = useCallback( + (created: CreatedWorkspace) => + handleSelectContentSpace({ id: created.spaceId, name: created.name, kind: created.kind, @@ -599,18 +583,9 @@ export function DocumentSidebar({ role: "owner", catalogItemId: created.catalogItemId, catalogDocumentId: created.catalogDocumentId, - }; - const selected = await handleSelectContentSpace(space); - if (!selected) return; - setCreateWorkspaceDialogOpen(false); - setNewWorkspaceName(""); - createWorkspaceRequestIdRef.current = null; - } catch (error) { - toast.error(t("sidebar.failedCreateWorkspace"), { - description: error instanceof Error ? error.message : String(error), - }); - } - }, [createContentSpace, handleSelectContentSpace, newWorkspaceName, t]); + }), + [handleSelectContentSpace], + ); useEffect(() => { if (!selectedSpace) return; void setClientAppState( @@ -1611,37 +1586,20 @@ export function DocumentSidebar({ /> )}
- - - - - - setCreateWorkspaceDialogOpen(true)} - > - - {t("sidebar.newWorkspace")} - - - - - - {t("sidebar.localFolder")} - - - - + + +
) : contentSpaceState === "loading" ? ( @@ -2155,60 +2113,6 @@ export function DocumentSidebar({ onMouseDown={handleMouseDown} /> )} - { - setCreateWorkspaceDialogOpen(open); - if (!open && !createContentSpace.isPending) { - setNewWorkspaceName(""); - createWorkspaceRequestIdRef.current = null; - } - }} - > - -
{ - event.preventDefault(); - void handleCreateWorkspace(); - }} - > - - {t("sidebar.newWorkspace")} - - {t("sidebar.newWorkspaceDescription")} - - - setNewWorkspaceName(event.target.value)} - /> - - - - -
-
-
{ + it("offers the same blank and local-folder sources to every trigger", () => { + const source = readFileSync( + new URL("./WorkspaceSourceMenu.tsx", import.meta.url), + "utf8", + ); + + expect(source).toContain('t("sidebar.newWorkspace")'); + expect(source).toContain(''); + expect(source).toContain('t("sidebar.localFolder")'); + expect(source).toContain("createContentSpace.mutateAsync"); + expect(source).toContain("propertyValues,"); + expect(source).toContain("const accepted = await onCreated?.(created)"); + expect(source).toContain("if (accepted === false) return"); + }); +}); diff --git a/templates/content/app/components/sidebar/WorkspaceSourceMenu.tsx b/templates/content/app/components/sidebar/WorkspaceSourceMenu.tsx new file mode 100644 index 0000000000..4a0d4d029c --- /dev/null +++ b/templates/content/app/components/sidebar/WorkspaceSourceMenu.tsx @@ -0,0 +1,151 @@ +import { useT } from "@agent-native/core/client/i18n"; +import { IconFolder, IconPlus } from "@tabler/icons-react"; +import { useRef, useState, type ReactElement } from "react"; +import { Link } from "react-router"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { useCreateContentSpace } from "@/hooks/use-content-spaces"; + +export type CreatedWorkspace = { + spaceId: string; + filesDatabaseId: string; + filesDocumentId: string; + catalogDatabaseId: string; + catalogItemId: string; + catalogDocumentId: string; + name: string; + kind: "user"; +}; + +export function WorkspaceSourceMenu({ + children, + align = "start", + propertyValues, + onCreated, +}: { + children: ReactElement; + align?: "start" | "center" | "end"; + propertyValues?: Record; + onCreated?: ( + workspace: CreatedWorkspace, + ) => boolean | void | Promise; +}) { + const t = useT(); + const createContentSpace = useCreateContentSpace(); + const [dialogOpen, setDialogOpen] = useState(false); + const [name, setName] = useState(""); + const requestIdRef = useRef(null); + + async function createWorkspace() { + const workspaceName = name.trim(); + if (!workspaceName) return; + const requestId = requestIdRef.current ?? crypto.randomUUID(); + requestIdRef.current = requestId; + try { + const created = await createContentSpace.mutateAsync({ + name: workspaceName, + requestId, + propertyValues, + }); + const accepted = await onCreated?.(created); + if (accepted === false) return; + setDialogOpen(false); + setName(""); + requestIdRef.current = null; + } catch (error) { + toast.error(t("sidebar.failedCreateWorkspace"), { + description: error instanceof Error ? error.message : String(error), + }); + } + } + + return ( + <> + + {children} + + setDialogOpen(true)}> + + {t("sidebar.newWorkspace")} + + + + + + {t("sidebar.localFolder")} + + + + + + { + setDialogOpen(open); + if (!open && !createContentSpace.isPending) { + setName(""); + requestIdRef.current = null; + } + }} + > + +
{ + event.preventDefault(); + void createWorkspace(); + }} + > + + {t("sidebar.newWorkspace")} + + {t("sidebar.newWorkspaceDescription")} + + + setName(event.target.value)} + /> + + + + +
+
+
+ + ); +} diff --git a/templates/content/app/i18n-data.ts b/templates/content/app/i18n-data.ts index 288c3dbb09..0cbeebd23c 100644 --- a/templates/content/app/i18n-data.ts +++ b/templates/content/app/i18n-data.ts @@ -8926,6 +8926,7 @@ export const messagesByLocale = { }, sidebar: { cannotReorderPages: "无法重新排序页面", + deletePagePermanentlyQuestion: "永久删除页面?", loadingFiles: "正在加载文件…", newDatabase: "新建数据库", noWorkspaces: "还没有工作区", @@ -8933,6 +8934,8 @@ export const messagesByLocale = { expand: "展开侧边栏", failedCreatePage: "创建页面失败", failedDeletePage: "删除页面失败", + failedPermanentDeletePage: "永久删除页面失败", + failedRestorePage: "恢复页面失败", failedMovePage: "移动页面失败", localFiles: "本地文件", newPage: "新页面", @@ -8944,11 +8947,14 @@ export const messagesByLocale = { noSharedCopiesYet: "还没有共享副本", oneAffectedPageReadOnly: "受影响的页面之一是只读的。", organization: "组织", + pagePermanentlyDeleted: "页面已永久删除", + pageRestored: "页面已恢复", private: "私有", results: "结果", search: "搜索", searchPages: "搜索页面...", sharedCopies: "共享副本", + trashEmpty: "回收站为空", favorites: "收藏", untitled: "无标题", }, @@ -9116,6 +9122,7 @@ export const messagesByLocale = { }, sidebar: { cannotReorderPages: "No se pueden reordenar las páginas", + deletePagePermanentlyQuestion: "¿Eliminar la página permanentemente?", loadingFiles: "Cargando archivos…", newDatabase: "Nueva base de datos", noWorkspaces: "Aún no hay espacios de trabajo", @@ -9123,6 +9130,9 @@ export const messagesByLocale = { expand: "Expandir barra lateral", failedCreatePage: "No se pudo crear la página", failedDeletePage: "No se pudo eliminar la página", + failedPermanentDeletePage: + "No se pudo eliminar la página permanentemente", + failedRestorePage: "No se pudo restaurar la página", failedMovePage: "No se pudo mover la página", localFiles: "Archivos locales", newPage: "Nueva página", @@ -9135,11 +9145,14 @@ export const messagesByLocale = { oneAffectedPageReadOnly: "Una de las páginas afectadas es de solo lectura.", organization: "Organización", + pagePermanentlyDeleted: "Página eliminada permanentemente", + pageRestored: "Página restaurada", private: "Privado", results: "Resultados", search: "Buscar", searchPages: "Buscar páginas...", sharedCopies: "Copias compartidas", + trashEmpty: "La papelera está vacía", favorites: "Favoritos", untitled: "Sin título", }, @@ -9307,6 +9320,7 @@ export const messagesByLocale = { }, sidebar: { cannotReorderPages: "Impossible de réordonner les pages", + deletePagePermanentlyQuestion: "Supprimer définitivement la page ?", loadingFiles: "Chargement des fichiers…", newDatabase: "Nouvelle base de données", noWorkspaces: "Aucun espace de travail pour le moment", @@ -9314,6 +9328,9 @@ export const messagesByLocale = { expand: "Développer la barre latérale", failedCreatePage: "Échec de la création de la page", failedDeletePage: "Échec de la suppression de la page", + failedPermanentDeletePage: + "Échec de la suppression définitive de la page", + failedRestorePage: "Échec de la restauration de la page", failedMovePage: "Échec du déplacement de la page", localFiles: "Fichiers locaux", newPage: "Nouvelle page", @@ -9326,11 +9343,14 @@ export const messagesByLocale = { oneAffectedPageReadOnly: "L'une des pages concernées est en lecture seule.", organization: "Organisation", + pagePermanentlyDeleted: "Page supprimée définitivement", + pageRestored: "Page restaurée", private: "Privé", results: "Résultats", search: "Rechercher", searchPages: "Rechercher des pages...", sharedCopies: "Copies partagées", + trashEmpty: "La corbeille est vide", favorites: "Favoris", untitled: "Sans titre", }, @@ -9496,6 +9516,7 @@ export const messagesByLocale = { }, sidebar: { cannotReorderPages: "Seiten können nicht neu angeordnet werden", + deletePagePermanentlyQuestion: "Seite dauerhaft löschen?", loadingFiles: "Dateien werden geladen…", newDatabase: "Neue Datenbank", noWorkspaces: "Noch keine Arbeitsbereiche", @@ -9503,6 +9524,8 @@ export const messagesByLocale = { expand: "Seitenleiste ausklappen", failedCreatePage: "Seite konnte nicht erstellt werden", failedDeletePage: "Seite konnte nicht gelöscht werden", + failedPermanentDeletePage: "Seite konnte nicht dauerhaft gelöscht werden", + failedRestorePage: "Seite konnte nicht wiederhergestellt werden", failedMovePage: "Seite konnte nicht verschoben werden", localFiles: "Lokale Dateien", newPage: "Neue Seite", @@ -9515,11 +9538,14 @@ export const messagesByLocale = { oneAffectedPageReadOnly: "Eine der betroffenen Seiten ist schreibgeschützt.", organization: "Organisation", + pagePermanentlyDeleted: "Seite dauerhaft gelöscht", + pageRestored: "Seite wiederhergestellt", private: "Privat", results: "Ergebnisse", search: "Suchen", searchPages: "Seiten suchen...", sharedCopies: "Geteilte Kopien", + trashEmpty: "Papierkorb ist leer", favorites: "Favoriten", untitled: "Ohne Titel", }, @@ -9685,6 +9711,7 @@ export const messagesByLocale = { }, sidebar: { cannotReorderPages: "ページを並べ替えられません", + deletePagePermanentlyQuestion: "ページを完全に削除しますか?", loadingFiles: "ファイルを読み込んでいます…", newDatabase: "新しいデータベース", noWorkspaces: "ワークスペースはまだありません", @@ -9692,6 +9719,8 @@ export const messagesByLocale = { expand: "サイドバーを展開", failedCreatePage: "ページを作成できませんでした", failedDeletePage: "ページを削除できませんでした", + failedPermanentDeletePage: "ページを完全に削除できませんでした", + failedRestorePage: "ページを復元できませんでした", failedMovePage: "ページを移動できませんでした", localFiles: "ローカルファイル", newPage: "新しいページ", @@ -9703,11 +9732,14 @@ export const messagesByLocale = { noSharedCopiesYet: "まだ共有コピーはありません", oneAffectedPageReadOnly: "影響を受けるページの 1 つは読み取り専用です。", organization: "組織", + pagePermanentlyDeleted: "ページを完全に削除しました", + pageRestored: "ページを復元しました", private: "非公開", results: "結果", search: "検索", searchPages: "ページを検索...", sharedCopies: "共有コピー", + trashEmpty: "ゴミ箱は空です", favorites: "お気に入り", untitled: "無題", }, @@ -9863,6 +9895,7 @@ export const messagesByLocale = { }, sidebar: { cannotReorderPages: "페이지 순서를 변경할 수 없습니다", + deletePagePermanentlyQuestion: "페이지를 영구적으로 삭제하시겠습니까?", loadingFiles: "파일을 불러오는 중…", newDatabase: "새 데이터베이스", noWorkspaces: "아직 워크스페이스가 없습니다", @@ -9870,6 +9903,8 @@ export const messagesByLocale = { expand: "사이드바 펼치기", failedCreatePage: "페이지를 만들지 못했습니다", failedDeletePage: "페이지를 삭제하지 못했습니다", + failedPermanentDeletePage: "페이지를 영구적으로 삭제하지 못했습니다", + failedRestorePage: "페이지를 복원하지 못했습니다", failedMovePage: "페이지를 이동하지 못했습니다", localFiles: "로컬 파일", newPage: "새 페이지", @@ -9881,11 +9916,14 @@ export const messagesByLocale = { noSharedCopiesYet: "아직 공유 사본이 없습니다", oneAffectedPageReadOnly: "영향을 받는 페이지 중 하나가 읽기 전용입니다.", organization: "조직", + pagePermanentlyDeleted: "페이지가 영구적으로 삭제되었습니다", + pageRestored: "페이지가 복원되었습니다", private: "비공개", results: "결과", search: "검색", searchPages: "페이지 검색...", sharedCopies: "공유 사본", + trashEmpty: "휴지통이 비어 있습니다", favorites: "즐겨찾기", untitled: "제목 없음", }, @@ -10053,6 +10091,7 @@ export const messagesByLocale = { }, sidebar: { cannotReorderPages: "Não é possível reordenar páginas", + deletePagePermanentlyQuestion: "Excluir a página permanentemente?", loadingFiles: "Carregando arquivos…", newDatabase: "Novo banco de dados", noWorkspaces: "Ainda não há espaços de trabalho", @@ -10060,6 +10099,8 @@ export const messagesByLocale = { expand: "Expandir barra lateral", failedCreatePage: "Falha ao criar página", failedDeletePage: "Falha ao excluir página", + failedPermanentDeletePage: "Falha ao excluir a página permanentemente", + failedRestorePage: "Falha ao restaurar a página", failedMovePage: "Falha ao mover página", localFiles: "Arquivos locais", newPage: "Nova página", @@ -10071,11 +10112,14 @@ export const messagesByLocale = { noSharedCopiesYet: "Ainda não há cópias compartilhadas", oneAffectedPageReadOnly: "Uma das páginas afetadas é somente leitura.", organization: "Organização", + pagePermanentlyDeleted: "Página excluída permanentemente", + pageRestored: "Página restaurada", private: "Privado", results: "Resultados", search: "Buscar", searchPages: "Buscar páginas...", sharedCopies: "Cópias compartilhadas", + trashEmpty: "A lixeira está vazia", favorites: "Favoritos", untitled: "Sem título", }, @@ -10229,6 +10273,7 @@ export const messagesByLocale = { }, sidebar: { cannotReorderPages: "पेजों को फिर से क्रमबद्ध नहीं किया जा सकता", + deletePagePermanentlyQuestion: "पेज को स्थायी रूप से हटाएं?", loadingFiles: "फ़ाइलें लोड हो रही हैं…", newDatabase: "नया डेटाबेस", noWorkspaces: "अभी कोई कार्यस्थान नहीं है", @@ -10236,6 +10281,8 @@ export const messagesByLocale = { expand: "साइडबार फैलाएं", failedCreatePage: "पेज नहीं बन सका", failedDeletePage: "पेज हटाया नहीं जा सका", + failedPermanentDeletePage: "पेज को स्थायी रूप से हटाया नहीं जा सका", + failedRestorePage: "पेज पुनर्स्थापित नहीं किया जा सका", failedMovePage: "पेज स्थानांतरित नहीं हो सका", localFiles: "स्थानीय फ़ाइलें", newPage: "नया पेज", @@ -10247,11 +10294,14 @@ export const messagesByLocale = { noSharedCopiesYet: "अभी कोई साझा कॉपी नहीं है", oneAffectedPageReadOnly: "प्रभावित पेजों में से एक केवल-पढ़ने योग्य है।", organization: "संगठन", + pagePermanentlyDeleted: "पेज स्थायी रूप से हटा दिया गया", + pageRestored: "पेज पुनर्स्थापित किया गया", private: "निजी", results: "परिणाम", search: "खोजें", searchPages: "पेज खोजें...", sharedCopies: "साझा कॉपियां", + trashEmpty: "ट्रैश खाली है", favorites: "पसंदीदा", untitled: "शीर्षकहीन", }, @@ -10408,6 +10458,7 @@ export const messagesByLocale = { }, sidebar: { cannotReorderPages: "تعذرت إعادة ترتيب الصفحات", + deletePagePermanentlyQuestion: "هل تريد حذف الصفحة نهائيًا؟", loadingFiles: "جارٍ تحميل الملفات…", newDatabase: "قاعدة بيانات جديدة", noWorkspaces: "لا توجد مساحات عمل بعد", @@ -10415,6 +10466,8 @@ export const messagesByLocale = { expand: "توسيع الشريط الجانبي", failedCreatePage: "فشل إنشاء الصفحة", failedDeletePage: "فشل حذف الصفحة", + failedPermanentDeletePage: "فشل حذف الصفحة نهائيًا", + failedRestorePage: "فشل استعادة الصفحة", failedMovePage: "فشل نقل الصفحة", localFiles: "الملفات المحلية", newPage: "صفحة جديدة", @@ -10426,11 +10479,14 @@ export const messagesByLocale = { noSharedCopiesYet: "لا توجد نسخ مشتركة بعد", oneAffectedPageReadOnly: "إحدى الصفحات المتأثرة للقراءة فقط.", organization: "المؤسسة", + pagePermanentlyDeleted: "تم حذف الصفحة نهائيًا", + pageRestored: "تمت استعادة الصفحة", private: "خاص", results: "النتائج", search: "بحث", searchPages: "البحث في الصفحات...", sharedCopies: "النسخ المشتركة", + trashEmpty: "سلة المهملات فارغة", favorites: "المفضلة", untitled: "بدون عنوان", }, diff --git a/templates/content/app/i18n/zh-TW.ts b/templates/content/app/i18n/zh-TW.ts index ed37c37a89..ec069880a9 100644 --- a/templates/content/app/i18n/zh-TW.ts +++ b/templates/content/app/i18n/zh-TW.ts @@ -1102,10 +1102,16 @@ const messages = { database: "資料庫", databasePermanentlyDeleted: "資料庫已永久刪除", databaseRestored: "資料庫已還原", + pagePermanentlyDeleted: "頁面已永久刪除", + pageRestored: "頁面已還原", deleteDatabaseNamedPermanently: "永久刪除 {{title}}", deleteDatabasePermanentlyDescription: "這會永久刪除「{{title}}」及其頁面。此操作無法復原。", deleteDatabasePermanentlyQuestion: "永久刪除資料庫?", + deletePageNamedPermanently: "永久刪除 {{title}}", + deletePagePermanentlyDescription: + "這會永久刪除「{{title}}」及其子頁面。此操作無法復原。", + deletePagePermanentlyQuestion: "永久刪除頁面?", deletePermanently: "永久刪除", failedCreateDatabase: "建立資料庫失敗", failedCreateWorkspace: "無法建立工作區", @@ -1123,8 +1129,10 @@ const messages = { failedCreatePage: "建立頁面失敗", failedDeletePage: "刪除頁面失敗", failedPermanentDeleteDatabase: "永久刪除資料庫失敗", + failedPermanentDeletePage: "永久刪除頁面失敗", failedMovePage: "行動頁面失敗", failedRestoreDatabase: "還原資料庫失敗", + failedRestorePage: "還原頁面失敗", deletePageDescription: "「{{title}}」及其所有子頁面將被永久刪除。此操作無法復原。", deletePageQuestion: "刪除頁面?", @@ -1182,10 +1190,13 @@ const messages = { refreshConnection: "重新整理連線", restoreDatabase: "還原", restoreDatabaseNamed: "還原 {{title}}", + restorePage: "還原", + restorePageNamed: "還原 {{title}}", saving: "正在儲存...", sharedCopies: "共用副本", synced: "已同步", trash: "垃圾桶", + trashEmpty: "垃圾桶是空的", favorites: "釘選", untitled: "無標題", workspaces: "工作區", diff --git a/templates/content/changelog/2026-07-20-workspaces-can-be-added-from-a-blank-workspace-or-a-connecte.md b/templates/content/changelog/2026-07-20-workspaces-can-be-added-from-a-blank-workspace-or-a-connecte.md new file mode 100644 index 0000000000..622d61564d --- /dev/null +++ b/templates/content/changelog/2026-07-20-workspaces-can-be-added-from-a-blank-workspace-or-a-connecte.md @@ -0,0 +1,6 @@ +--- +type: improved +date: 2026-07-20 +--- + +Workspaces can be added from a blank workspace or a connected local folder from any creation menu. From 64dea8b063b7561adf9026bf9a6e1078ea3c6861 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Mon, 20 Jul 2026 09:29:46 -0400 Subject: [PATCH 73/76] fix(content): preserve workspace expansion and nesting --- .../editor/database/sidebar.test.tsx | 107 ++++++++++++++++++ .../components/editor/database/sidebar.tsx | 32 +++++- .../sidebar/DocumentSidebar.layout.test.ts | 4 + .../components/sidebar/DocumentSidebar.tsx | 37 ++++-- .../sidebar/select-content-space.test.ts | 24 ++++ .../sidebar/select-content-space.ts | 11 ++ ...-stay-independently-open-or-closed-and-.md | 6 + 7 files changed, 204 insertions(+), 17 deletions(-) create mode 100644 templates/content/changelog/2026-07-20-workspace-toggles-now-stay-independently-open-or-closed-and-.md diff --git a/templates/content/app/components/editor/database/sidebar.test.tsx b/templates/content/app/components/editor/database/sidebar.test.tsx index cecbe9e4e2..e479b8135a 100644 --- a/templates/content/app/components/editor/database/sidebar.test.tsx +++ b/templates/content/app/components/editor/database/sidebar.test.tsx @@ -13,6 +13,7 @@ import { ContentFilesSidebarView, DatabaseSidebarView, databaseSidebarItemTree, + databaseSidebarRootItems, databaseSidebarRowIndent, databaseSidebarRows, } from "./sidebar"; @@ -152,6 +153,55 @@ describe("DatabaseSidebarView", () => { expect(markup).toContain('aria-current="page"'); }); + it("does not promote a child when sorting places it before its parent", () => { + const parent = item("parent", "Zulu parent"); + const child = item("child", "Alpha child", "parent"); + const sortedItems = [child, parent]; + + expect( + databaseSidebarRootItems(sortedItems, sortedItems).map( + (candidate) => candidate.document.id, + ), + ).toEqual(["parent"]); + + expect( + databaseSidebarItemTree( + databaseSidebarRootItems(sortedItems, sortedItems), + sortedItems, + ), + ).toMatchObject([ + { + item: { document: { id: "parent" } }, + children: [{ item: { document: { id: "child" } } }], + }, + ]); + }); + + it("does not promote a matching child when its existing parent is filtered out", () => { + const parent = item("parent", "Parent"); + const child = item("child", "Matching child", "parent"); + + expect(databaseSidebarRootItems([child], [parent, child])).toEqual([]); + }); + + it("keeps a true orphan visible as a root", () => { + const orphan = item("orphan", "Orphan", "deleted-parent"); + + expect(databaseSidebarRootItems([orphan], [orphan])).toEqual([orphan]); + }); + + it("does not promote a database row whose database page is outside Files", () => { + const databaseRow = item("row", "Database row", "database-page"); + databaseRow.document.databaseMembership = { + databaseId: "database", + databaseDocumentId: "database-page", + databaseTitle: "Database", + position: 0, + }; + + expect(databaseSidebarRootItems([databaseRow], [databaseRow])).toEqual([]); + }); + it("reveals descendants only after their parent is explicitly expanded", async () => { const rootItem = item("parent", "Page one"); const childItem = item("child", "Page two", "parent"); @@ -261,6 +311,63 @@ describe("DatabaseSidebarView", () => { expect(markup).not.toContain(">Child<"); }); + it("does not promote a matching child whose parent is filtered out", () => { + const parent = item("parent", "Parent"); + const child = item("child", "Matching child", "parent"); + const data = { + database: { + viewConfig: { + version: 1, + activeViewId: "default", + views: [ + { + id: "default", + name: "Table", + type: "table", + filters: [ + { + key: "name", + label: "Name", + operator: "contains", + value: "Matching", + }, + ], + sorts: [], + filterMode: "and", + }, + ], + }, + }, + items: [parent, child], + properties: [ + { + definition: { id: "parent", systemRole: "files_parent" }, + }, + ], + } as unknown as ContentDatabaseResponse; + + const markup = renderToStaticMarkup( + + + + + , + ); + + expect(markup).not.toContain("Matching child"); + }); + it("lets a saved database view render workspace roots inside its groups", () => { const groups = [ { diff --git a/templates/content/app/components/editor/database/sidebar.tsx b/templates/content/app/components/editor/database/sidebar.tsx index 532c951379..67b746d332 100644 --- a/templates/content/app/components/editor/database/sidebar.tsx +++ b/templates/content/app/components/editor/database/sidebar.tsx @@ -172,11 +172,11 @@ export function ContentFilesSidebarView({ ), activeView.hideEmptyGroups === true, ); - const hierarchyItems = data?.properties.some( + const hasFilesHierarchy = data?.properties.some( (property) => property.definition.systemRole === "files_parent", - ) - ? items - : undefined; + ); + const hierarchyItems = hasFilesHierarchy ? items : undefined; + const hierarchyUniverseItems = hasFilesHierarchy ? data?.items : undefined; return (
{viewConfig.views.length > 1 && ( @@ -223,6 +223,7 @@ export function ContentFilesSidebarView({ onDocumentExpandedChange={onDocumentExpandedChange} renderItem={renderItem} hierarchyItems={hierarchyItems} + hierarchyUniverseItems={hierarchyUniverseItems} scroll={scroll} />
@@ -247,6 +248,7 @@ export function DatabaseSidebarView({ onDocumentExpandedChange, renderItem, hierarchyItems, + hierarchyUniverseItems, scroll = true, loadingLabel, noMatchesLabel, @@ -271,6 +273,7 @@ export function DatabaseSidebarView({ onDocumentExpandedChange?: (documentId: string, expanded: boolean) => void; renderItem?: (item: ContentDatabaseItem) => ReactNode; hierarchyItems?: ContentDatabaseItem[]; + hierarchyUniverseItems?: ContentDatabaseItem[]; scroll?: boolean; loadingLabel: string; noMatchesLabel: string; @@ -287,7 +290,13 @@ export function DatabaseSidebarView({ const items = groups.flatMap((group) => group.items); const itemTree = !grouped && hierarchyItems - ? databaseSidebarItemTree(items, hierarchyItems) + ? databaseSidebarItemTree( + databaseSidebarRootItems( + items, + hierarchyUniverseItems ?? hierarchyItems, + ), + hierarchyItems, + ) : null; function setGroupOpen(groupId: string, open: boolean) { @@ -690,6 +699,19 @@ export interface DatabaseSidebarItemTreeNode { children: DatabaseSidebarItemTreeNode[]; } +export function databaseSidebarRootItems( + visibleItems: ContentDatabaseItem[], + allItems: ContentDatabaseItem[], +) { + const documentIds = new Set(allItems.map((item) => item.document.id)); + return visibleItems.filter((item) => { + const parentId = item.document.parentId; + if (!parentId) return true; + if (item.document.databaseMembership) return false; + return !documentIds.has(parentId); + }); +} + export function databaseSidebarRowIndent(depth: number, _hasChildren: boolean) { return depth * 18; } diff --git a/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts b/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts index 0811db74b4..5d5e3f3022 100644 --- a/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts +++ b/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts @@ -190,6 +190,10 @@ describe("document sidebar layout", () => { expect(sidebar).toContain("expandedDocumentIds={expandedDocumentIdSet}"); 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).toContain( '"group/workspace-header flex h-7 w-full min-w-0 items-center rounded-md"', ); diff --git a/templates/content/app/components/sidebar/DocumentSidebar.tsx b/templates/content/app/components/sidebar/DocumentSidebar.tsx index 01c3fd5e39..89c6e5f5cd 100644 --- a/templates/content/app/components/sidebar/DocumentSidebar.tsx +++ b/templates/content/app/components/sidebar/DocumentSidebar.tsx @@ -136,6 +136,7 @@ import { NotionButton } from "./NotionButton"; import { contentSpaceAvailability, contentSpaceForStoredSelection, + createContentSidebarStateWriteQueue, createContentSpaceSelectionQueue, ensureWorkspaceExpanded, SELECTED_CONTENT_SPACE_STORAGE_KEY, @@ -214,6 +215,11 @@ const SIDEBAR_SECTION_COLLAPSE_STORAGE_KEY = const TRASH_COLLAPSED_DEFAULT_MIGRATION_KEY = "content-sidebar-trash-collapsed-default-v2"; const CONTENT_SIDEBAR_STATE_VERSION = 1 as const; +interface ContentSidebarStateSnapshot { + version: typeof CONTENT_SIDEBAR_STATE_VERSION; + expandedWorkspaceIds: string[]; + expandedDocumentIds: string[]; +} const DEFAULT_COLLAPSED_SECTIONS: CollapsedSectionsState = { favorites: false, "local-files": false, @@ -409,6 +415,17 @@ export function DocumentSidebar({ ); }, }); + const updateSidebarStateRef = useRef(updateSidebarState); + updateSidebarStateRef.current = updateSidebarState; + const sidebarStateWriteQueueRef = useRef< + ((snapshot: ContentSidebarStateSnapshot) => Promise) | null + >(null); + if (!sidebarStateWriteQueueRef.current) { + sidebarStateWriteQueueRef.current = createContentSidebarStateWriteQueue( + (snapshot: ContentSidebarStateSnapshot) => + updateSidebarStateRef.current.mutateAsync(snapshot), + ); + } const [expandedWorkspaceIds, setExpandedWorkspaceIds] = useState( [], ); @@ -478,14 +495,16 @@ export function DocumentSidebar({ } sidebarStateWriteTimerRef.current = setTimeout(() => { sidebarStateWriteTimerRef.current = null; - updateSidebarState.mutate({ - version: CONTENT_SIDEBAR_STATE_VERSION, - expandedWorkspaceIds: workspaceIds, - expandedDocumentIds: documentIds, - }); + void sidebarStateWriteQueueRef + .current?.({ + version: CONTENT_SIDEBAR_STATE_VERSION, + expandedWorkspaceIds: workspaceIds, + expandedDocumentIds: documentIds, + }) + .catch(() => undefined); }, 150); }, - [updateSidebarState], + [], ); const updateExpandedWorkspaceIds = useCallback( @@ -525,12 +544,6 @@ export function DocumentSidebar({ [], ); - useEffect(() => { - if (!selectedSpace || !sidebarStateHydratedRef.current) return; - updateExpandedWorkspaceIds((current) => - ensureWorkspaceExpanded(current, selectedSpace.id), - ); - }, [selectedSpace, updateExpandedWorkspaceIds]); const handleSelectContentSpace = useCallback( async ( space: (typeof contentSpaces)[number], diff --git a/templates/content/app/components/sidebar/select-content-space.test.ts b/templates/content/app/components/sidebar/select-content-space.test.ts index 9eb646f56f..a12e7c9cbf 100644 --- a/templates/content/app/components/sidebar/select-content-space.test.ts +++ b/templates/content/app/components/sidebar/select-content-space.test.ts @@ -7,6 +7,7 @@ import { contentSpaceForStoredSelection, contentSpaceForCatalogItem, contentSpaceIdForCreate, + createContentSidebarStateWriteQueue, createContentSpaceSelectionQueue, ensureWorkspaceExpanded, selectContentSpace, @@ -276,6 +277,29 @@ describe("workspace expansion", () => { const expanded = ["personal", "organization"]; expect(ensureWorkspaceExpanded(expanded, "organization")).toBe(expanded); }); + + it("serializes persisted expansion snapshots in interaction order", async () => { + const writes: string[][] = []; + let releaseFirst!: () => void; + const firstPending = new Promise((resolve) => { + releaseFirst = resolve; + }); + const enqueue = createContentSidebarStateWriteQueue( + async (workspaceIds: string[]) => { + if (writes.length === 0) await firstPending; + writes.push(workspaceIds); + }, + ); + + const first = enqueue(["personal", "organization"]); + const second = enqueue(["personal"]); + await Promise.resolve(); + expect(writes).toEqual([]); + releaseFirst(); + await Promise.all([first, second]); + + expect(writes).toEqual([["personal", "organization"], ["personal"]]); + }); }); describe("contentSpaceIdForCreate", () => { diff --git a/templates/content/app/components/sidebar/select-content-space.ts b/templates/content/app/components/sidebar/select-content-space.ts index de0607baec..8bb7311d38 100644 --- a/templates/content/app/components/sidebar/select-content-space.ts +++ b/templates/content/app/components/sidebar/select-content-space.ts @@ -104,3 +104,14 @@ export function createContentSpaceSelectionQueue() { return next; }; } + +export function createContentSidebarStateWriteQueue( + write: (snapshot: T) => Promise, +) { + let pending: Promise = Promise.resolve(); + return (snapshot: T) => { + const next = pending.catch(() => undefined).then(() => write(snapshot)); + pending = next; + return next; + }; +} diff --git a/templates/content/changelog/2026-07-20-workspace-toggles-now-stay-independently-open-or-closed-and-.md b/templates/content/changelog/2026-07-20-workspace-toggles-now-stay-independently-open-or-closed-and-.md new file mode 100644 index 0000000000..54386cf598 --- /dev/null +++ b/templates/content/changelog/2026-07-20-workspace-toggles-now-stay-independently-open-or-closed-and-.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-07-20 +--- + +Workspace toggles now stay independently open or closed, and nested pages remain grouped beneath their parents. From 3301823e9cbea9e43cb2752a4b8cafc0ca11d3da Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Mon, 20 Jul 2026 10:28:57 -0400 Subject: [PATCH 74/76] fix(content): harden workspace and trash boundaries --- .../content/actions/_database-row-batch.ts | 1 + templates/content/actions/_database-utils.ts | 89 +++++++++++--- .../content/actions/add-database-item.ts | 3 + .../content-database-lifecycle.db.test.ts | 110 ++++++++++++++++++ .../content/actions/content-spaces.db.test.ts | 23 ++++ .../database-row-batch-actions.db.test.ts | 36 ++++++ .../actions/delete-content-database.ts | 2 + templates/content/actions/delete-document.ts | 104 ++++++++++++++++- .../actions/duplicate-database-item.ts | 1 + .../actions/permanently-delete-document.ts | 23 +--- templates/content/actions/pull-document.ts | 17 ++- .../actions/restore-content-database.ts | 35 +++++- .../editor/database/DatabaseView.tsx | 45 ++++++- .../DatabaseView.workspaces.layout.test.ts | 4 + .../editor/database/GalleryView.tsx | 9 +- .../components/editor/database/ListView.tsx | 9 +- .../editor/database/TimelineView.tsx | 3 + .../editor/database/sidebar.test.tsx | 10 +- .../components/editor/database/sidebar.tsx | 1 - .../sidebar/DocumentSidebar.layout.test.ts | 2 + .../components/sidebar/DocumentSidebar.tsx | 36 ++---- ...serve-independent-archived-pages-enforc.md | 6 + 22 files changed, 493 insertions(+), 76 deletions(-) create mode 100644 templates/content/changelog/2026-07-20-trash-actions-now-preserve-independent-archived-pages-enforc.md diff --git a/templates/content/actions/_database-row-batch.ts b/templates/content/actions/_database-row-batch.ts index 3fff90695a..edd21b7392 100644 --- a/templates/content/actions/_database-row-batch.ts +++ b/templates/content/actions/_database-row-batch.ts @@ -151,6 +151,7 @@ export async function resolveDatabaseRowsForBatch( eq(schema.contentDatabaseItems.databaseId, database.id), rowPredicates.length === 1 ? rowPredicates[0] : or(...rowPredicates), isNull(schema.contentDatabases.deletedAt), + isNull(schema.documents.trashedAt), ), ) .orderBy(asc(schema.contentDatabaseItems.position)); diff --git a/templates/content/actions/_database-utils.ts b/templates/content/actions/_database-utils.ts index 5491a49c80..6ec1470fdf 100644 --- a/templates/content/actions/_database-utils.ts +++ b/templates/content/actions/_database-utils.ts @@ -22,7 +22,10 @@ import type { ContentDatabaseResponse, } from "../shared/api.js"; import { favoriteDocumentIds } from "./_content-favorites.js"; -import { listContentOrganizationMemberships } from "./_content-space-access.js"; +import { + listContentOrganizationMemberships, + normalizeContentSpaceEmail, +} from "./_content-space-access.js"; import { getAllContentDatabaseSourceSnapshots } from "./_database-source-utils.js"; import { applyFederatedOverlayValues, @@ -302,9 +305,14 @@ export async function getContentDatabaseResponse( const { limit, offset } = normalizeContentDatabasePageOptions(options); const userEmail = getRequestUserEmail(); + const normalizedUserEmail = userEmail + ? normalizeContentSpaceEmail(userEmail) + : null; const activeOrgId = getRequestOrgId(); const authorizedOrgIds = - database.systemRole === "favorites" && userEmail + (database.systemRole === "favorites" || + database.systemRole === "workspaces") && + userEmail ? [ ...new Set([ ...(await listContentOrganizationMemberships(userEmail)).map( @@ -314,6 +322,45 @@ export async function getContentDatabaseResponse( ]), ] : []; + const workspacesVisibleDocumentIds = + database.systemRole === "workspaces" && normalizedUserEmail + ? ( + await db + .select({ + documentId: schema.contentSpaceCatalogItems.documentId, + ownerEmail: schema.contentSpaces.ownerEmail, + orgId: schema.contentSpaces.orgId, + }) + .from(schema.contentSpaceCatalogItems) + .innerJoin( + schema.contentSpaces, + eq( + schema.contentSpaces.id, + schema.contentSpaceCatalogItems.spaceId, + ), + ) + .where( + and( + eq( + schema.contentSpaceCatalogItems.catalogDatabaseId, + databaseId, + ), + eq( + schema.contentSpaceCatalogItems.ownerEmail, + normalizedUserEmail, + ), + isNull(schema.contentSpaces.archivedAt), + ), + ) + ) + .filter( + (row) => + normalizeContentSpaceEmail(row.ownerEmail) === + normalizedUserEmail || + (!!row.orgId && authorizedOrgIds.includes(row.orgId)), + ) + .map((row) => row.documentId) + : null; const favoritesVisibleDocumentIds = database.systemRole === "favorites" && userEmail ? ( @@ -368,6 +415,14 @@ export async function getContentDatabaseResponse( ) : sql`1 = 0` : undefined, + workspacesVisibleDocumentIds + ? workspacesVisibleDocumentIds.length > 0 + ? inArray( + schema.contentDatabaseItems.documentId, + workspacesVisibleDocumentIds, + ) + : sql`1 = 0` + : undefined, ); const [itemCount] = await db .select({ count: sql`COUNT(*)` }) @@ -401,19 +456,23 @@ export async function getContentDatabaseResponse( ? favoritesVisibleDocumentIds?.length ? inArray(schema.documents.id, favoritesVisibleDocumentIds) : sql`1 = 0` - : database.systemRole === "files" && database.orgId - ? and( - eq(schema.documents.orgId, database.orgId), - or( - eq(schema.documents.visibility, "org"), - eq(schema.documents.visibility, "public"), - ), - or( - eq(schema.documents.hideFromSearch, 0), - isNull(schema.documents.hideFromSearch), - ), - ) - : eq(schema.documents.ownerEmail, database.ownerEmail), + : database.systemRole === "workspaces" + ? workspacesVisibleDocumentIds?.length + ? inArray(schema.documents.id, workspacesVisibleDocumentIds) + : sql`1 = 0` + : database.systemRole === "files" && database.orgId + ? and( + eq(schema.documents.orgId, database.orgId), + or( + eq(schema.documents.visibility, "org"), + eq(schema.documents.visibility, "public"), + ), + or( + eq(schema.documents.hideFromSearch, 0), + isNull(schema.documents.hideFromSearch), + ), + ) + : eq(schema.documents.ownerEmail, database.ownerEmail), ), ) : []; diff --git a/templates/content/actions/add-database-item.ts b/templates/content/actions/add-database-item.ts index 12d2f6bfe2..34af4d132d 100644 --- a/templates/content/actions/add-database-item.ts +++ b/templates/content/actions/add-database-item.ts @@ -41,6 +41,9 @@ export default defineAction({ ), ); if (!database) throw new Error(`Database "${databaseId}" not found`); + if (database.systemRole === "workspaces") { + throw new Error("Use create-content-space to add a workspace"); + } const access = await assertAccess( "document", diff --git a/templates/content/actions/content-database-lifecycle.db.test.ts b/templates/content/actions/content-database-lifecycle.db.test.ts index 9d07c675d9..b3119d624c 100644 --- a/templates/content/actions/content-database-lifecycle.db.test.ts +++ b/templates/content/actions/content-database-lifecycle.db.test.ts @@ -24,6 +24,7 @@ let getContentDatabaseAction: typeof import("./get-content-database.js").default let listDocumentsAction: typeof import("./list-documents.js").default; let listTrashedContentDatabasesAction: typeof import("./list-trashed-content-databases.js").default; let getDocumentAction: typeof import("./get-document.js").default; +let pullDocumentAction: typeof import("./pull-document.js").default; let listDocumentPropertiesAction: typeof import("./list-document-properties.js").default; let addDatabaseItemAction: typeof import("./add-database-item.js").default; let deleteDocumentAction: typeof import("./delete-document.js").default; @@ -52,6 +53,7 @@ beforeAll(async () => { await import("./list-trashed-content-databases.js") ).default; getDocumentAction = (await import("./get-document.js")).default; + pullDocumentAction = (await import("./pull-document.js")).default; listDocumentPropertiesAction = (await import("./list-document-properties.js")) .default; addDatabaseItemAction = (await import("./add-database-item.js")).default; @@ -309,6 +311,49 @@ describe("document trash lifecycle", () => { expect(await documentRow(documentId)).toBeUndefined(); }); + it("permanently deletes only a selected Trash root", async () => { + const rootId = await createDocument({ title: "Trash root" }); + const childId = await createDocument({ + parentId: rootId, + title: "Trash child", + }); + await runWithRequestContext({ userEmail: OWNER }, () => + deleteDocumentAction.run({ id: rootId }), + ); + + await expect( + runWithRequestContext({ userEmail: OWNER }, () => + permanentlyDeleteDocumentAction.run({ id: childId }), + ), + ).rejects.toThrow("Trash root"); + expect(await documentRow(rootId)).toBeDefined(); + expect(await documentRow(childId)).toBeDefined(); + }); + + it("preserves an independently trashed descendant when deleting its parent root", async () => { + const rootId = await createDocument({ title: "Later root" }); + const childId = await createDocument({ + parentId: rootId, + title: "Earlier root", + }); + await runWithRequestContext({ userEmail: OWNER }, () => + deleteDocumentAction.run({ id: childId }), + ); + await runWithRequestContext({ userEmail: OWNER }, () => + deleteDocumentAction.run({ id: rootId }), + ); + await runWithRequestContext({ userEmail: OWNER }, () => + permanentlyDeleteDocumentAction.run({ id: rootId }), + ); + + expect(await documentRow(rootId)).toBeUndefined(); + expect(await documentRow(childId)).toMatchObject({ + parentId: null, + trashRootId: childId, + trashedAt: expect.any(String), + }); + }); + it("does not expose another owner's trashed page title", async () => { const foreignId = await createDocument({ title: "Sensitive foreign title", @@ -643,6 +688,11 @@ describe("content database soft-delete actions and reads", () => { getDocumentAction.run({ id: rowDocumentId }), ), ).rejects.toThrow(`Document "${rowDocumentId}" not found`); + await expect( + runWithRequestContext({ userEmail: OWNER }, () => + pullDocumentAction.run({ id: rowDocumentId, format: "markdown" }), + ), + ).rejects.toThrow(`Document "${rowDocumentId}" not found`); await expect( runWithRequestContext({ userEmail: OWNER }, () => listDocumentPropertiesAction.run({ documentId: rowDocumentId }), @@ -650,6 +700,66 @@ describe("content database soft-delete actions and reads", () => { ).rejects.toThrow(`Document "${rowDocumentId}" not found`); }); + it("rejects restoring a database whose page belongs to another Trash root", async () => { + const rootId = await createDocument({ title: "Parent Trash root" }); + const { databaseId, databaseDocumentId } = await createDatabase({ + backingParentId: rootId, + }); + await runWithRequestContext({ userEmail: OWNER }, () => + deleteDocumentAction.run({ id: rootId }), + ); + + await expect( + runWithRequestContext({ userEmail: OWNER }, () => + restoreContentDatabaseAction.run({ databaseId }), + ), + ).rejects.toThrow("Restore the parent Trash item instead"); + expect(await databaseRow(databaseId)).toMatchObject({ + deletedAt: expect.any(String), + }); + expect(await documentRow(databaseDocumentId)).toMatchObject({ + trashRootId: rootId, + trashedAt: expect.any(String), + }); + }); + + it("requires backing-page admin access before deleting an inline database", async () => { + const hostDocumentId = await createDocument({ title: "Shared host" }); + const { databaseId, databaseDocumentId } = await createDatabase({ + hostDocumentId, + ownerBlockId: nextId("inline_database"), + }); + const db = getDb(); + await db.insert(schema.documentShares).values({ + id: nextId("share"), + resourceId: hostDocumentId, + principalType: "user", + principalId: COLLABORATOR, + role: "editor", + createdBy: OWNER, + createdAt: new Date().toISOString(), + }); + await db.insert(schema.documentShares).values({ + id: nextId("share"), + resourceId: databaseDocumentId, + principalType: "user", + principalId: COLLABORATOR, + role: "editor", + createdBy: OWNER, + createdAt: new Date().toISOString(), + }); + + await expect( + runWithRequestContext({ userEmail: COLLABORATOR }, () => + deleteContentDatabaseAction.run({ databaseId }), + ), + ).rejects.toThrow(`Requires admin role on document ${databaseDocumentId}`); + expect(await documentRow(databaseDocumentId)).toMatchObject({ + trashedAt: null, + }); + expect(await databaseRow(databaseId)).toMatchObject({ deletedAt: null }); + }); + it("blocks row mutations for soft-deleted databases", async () => { const { databaseId } = await createDatabase({ deletedAt: new Date().toISOString(), diff --git a/templates/content/actions/content-spaces.db.test.ts b/templates/content/actions/content-spaces.db.test.ts index 5f268a6c2f..fc7693b631 100644 --- a/templates/content/actions/content-spaces.db.test.ts +++ b/templates/content/actions/content-spaces.db.test.ts @@ -32,6 +32,7 @@ let setDocumentPropertyAction: typeof import("./set-document-property.js").defau let deleteContentDatabaseAction: typeof import("./delete-content-database.js").default; let deleteDocumentAction: typeof import("./delete-document.js").default; let deleteDatabaseItemsAction: typeof import("./delete-database-items.js").default; +let addDatabaseItemAction: typeof import("./add-database-item.js").default; let duplicateDatabaseItemAction: typeof import("./duplicate-database-item.js").default; let duplicateDatabaseItemsAction: typeof import("./duplicate-database-items.js").default; @@ -71,6 +72,7 @@ beforeAll(async () => { deleteDocumentAction = (await import("./delete-document.js")).default; deleteDatabaseItemsAction = (await import("./delete-database-items.js")) .default; + addDatabaseItemAction = (await import("./add-database-item.js")).default; duplicateDatabaseItemAction = (await import("./duplicate-database-item.js")) .default; duplicateDatabaseItemsAction = (await import("./duplicate-database-items.js")) @@ -423,6 +425,12 @@ describe("Content space provisioning", () => { await expect( deleteDocumentAction.run({ id: files.documentId }), ).rejects.toThrow("System Content database documents cannot be deleted"); + await expect( + addDatabaseItemAction.run({ + databaseId: workspaces.id, + title: "Not a workspace", + }), + ).rejects.toThrow("Use create-content-space to add a workspace"); }); }); @@ -740,6 +748,21 @@ describe("Content space provisioning", () => { expect.objectContaining({ id: spaceId }), ]), }); + const [workspacesDatabase] = await getDb() + .select() + .from(schema.contentDatabases) + .where( + and( + eq(schema.contentDatabases.ownerEmail, MEMBER), + eq(schema.contentDatabases.systemRole, "workspaces"), + ), + ); + const catalog = await getContentDatabaseAction.run({ + databaseId: workspacesDatabase!.id, + }); + expect(catalog.items.map((item) => item.document.title)).not.toContain( + "Shared", + ); }); }); diff --git a/templates/content/actions/database-row-batch-actions.db.test.ts b/templates/content/actions/database-row-batch-actions.db.test.ts index 655e3f79f2..ad04b5e2a2 100644 --- a/templates/content/actions/database-row-batch-actions.db.test.ts +++ b/templates/content/actions/database-row-batch-actions.db.test.ts @@ -15,8 +15,10 @@ type Schema = typeof import("../server/db/schema.js"); let getDb: () => any; let schema: Schema; let duplicateDatabaseItemsAction: typeof import("./duplicate-database-items.js").default; +let duplicateDatabaseItemAction: typeof import("./duplicate-database-item.js").default; let deleteDatabaseItemsAction: typeof import("./delete-database-items.js").default; let addDatabaseItemAction: typeof import("./add-database-item.js").default; +let restoreDocumentAction: typeof import("./restore-document.js").default; let spaceId: string; const OWNER = "owner@example.com"; @@ -29,9 +31,12 @@ beforeAll(async () => { schema = dbModule.schema; duplicateDatabaseItemsAction = (await import("./duplicate-database-items.js")) .default; + duplicateDatabaseItemAction = (await import("./duplicate-database-item.js")) + .default; deleteDatabaseItemsAction = (await import("./delete-database-items.js")) .default; addDatabaseItemAction = (await import("./add-database-item.js")).default; + restoreDocumentAction = (await import("./restore-document.js")).default; const plugin = (await import("../server/plugins/db.js")).default; await plugin(undefined as any); const { systemIdsForContentSpace } = await import("./_content-spaces.js"); @@ -381,6 +386,37 @@ describe("database row batch actions", () => { expect(preservedValues).toHaveLength(1); }); + it("rejects duplicating trashed rows and restores unique ordering", async () => { + const { databaseId, rows } = await createDatabaseWithRows(2); + await runWithRequestContext({ userEmail: OWNER }, () => + deleteDatabaseItemsAction.run({ + databaseId, + itemIds: [rows[0].itemId], + }), + ); + + await expect( + runWithRequestContext({ userEmail: OWNER }, () => + duplicateDatabaseItemAction.run({ itemId: rows[0].itemId }), + ), + ).rejects.toThrow("Database row not found"); + await expect( + runWithRequestContext({ userEmail: OWNER }, () => + duplicateDatabaseItemsAction.run({ + databaseId, + itemIds: [rows[0].itemId], + }), + ), + ).rejects.toThrow("All requested rows must exist in the target database"); + + await runWithRequestContext({ userEmail: OWNER }, () => + restoreDocumentAction.run({ id: rows[0].documentId }), + ); + const restoredRows = await orderedRows(databaseId); + expect(restoredRows.map((row) => row.itemPosition)).toEqual([0, 1]); + expect(restoredRows.map((row) => row.documentPosition)).toEqual([0, 1]); + }); + it("rejects unauthorized delete batches before writing", async () => { const { databaseId, databaseDocumentId, rows } = await createDatabaseWithRows(2); diff --git a/templates/content/actions/delete-content-database.ts b/templates/content/actions/delete-content-database.ts index 5d2d2040e0..6312903b20 100644 --- a/templates/content/actions/delete-content-database.ts +++ b/templates/content/actions/delete-content-database.ts @@ -1,5 +1,6 @@ import { defineAction } from "@agent-native/core"; import { writeAppState } from "@agent-native/core/application-state"; +import { assertAccess } from "@agent-native/core/sharing"; import { z } from "zod"; import { getDb } from "../server/db/index.js"; @@ -17,6 +18,7 @@ export default defineAction({ if (database.systemRole) { throw new Error("System Content databases cannot be deleted"); } + await assertAccess("document", database.documentId, "admin"); const db = getDb(); const deletedAt = database.deletedAt ?? new Date().toISOString(); await db.transaction((tx) => diff --git a/templates/content/actions/delete-document.ts b/templates/content/actions/delete-document.ts index e37c328500..4432b2c65e 100644 --- a/templates/content/actions/delete-document.ts +++ b/templates/content/actions/delete-document.ts @@ -1,12 +1,13 @@ import { defineAction } from "@agent-native/core"; import { writeAppState } from "@agent-native/core/application-state"; import { assertAccess } from "@agent-native/core/sharing"; -import { and, eq, inArray, isNotNull, isNull } from "drizzle-orm"; +import { and, eq, inArray, isNotNull, isNull, ne, or } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; import { chunks } from "./_batch-utils.js"; import { assertNotWorkspaceCatalogDocuments } from "./_content-space-catalog-guards.js"; +import { renumberDatabaseRows } from "./_database-row-batch.js"; const DELETE_BATCH_SIZE = 90; @@ -274,6 +275,30 @@ export async function restoreDocumentSubtree( ); } + const databaseIds = [ + ...new Set( + ( + await db + .select({ databaseId: schema.contentDatabaseItems.databaseId }) + .from(schema.contentDatabaseItems) + .where(inArray(schema.contentDatabaseItems.documentId, documentIds)) + ).map((item) => item.databaseId), + ), + ]; + for (const databaseId of databaseIds) { + const [database] = await db + .select() + .from(schema.contentDatabases) + .where( + and( + eq(schema.contentDatabases.id, databaseId), + isNull(schema.contentDatabases.deletedAt), + ), + ) + .limit(1); + if (database) await renumberDatabaseRows(db, database, now); + } + return documentIds; } @@ -293,6 +318,20 @@ export async function deleteDocumentRecursive( ): Promise { const { documentIds, ownedDatabaseIds } = await collectDocumentSubtreeForDelete(db, id, ownerEmail); + return deleteCollectedDocuments( + db, + documentIds, + ownedDatabaseIds, + ownerEmail, + ); +} + +async function deleteCollectedDocuments( + db: ReturnType, + documentIds: string[], + ownedDatabaseIds: string[], + ownerEmail: string, +): Promise { await assertNotWorkspaceCatalogDocuments(db, documentIds, "deleted"); const propertyDefinitionIds: string[] = []; @@ -475,6 +514,69 @@ export async function deleteDocumentRecursive( return documentIds; } +export async function deleteTrashedDocumentSubtree( + db: ReturnType, + id: string, + ownerEmail: string, +): Promise { + const [root] = await db + .select({ id: schema.documents.id }) + .from(schema.documents) + .where( + and( + eq(schema.documents.id, id), + eq(schema.documents.ownerEmail, ownerEmail), + eq(schema.documents.trashRootId, id), + isNotNull(schema.documents.trashedAt), + ), + ) + .limit(1); + if (!root) { + throw new Error( + "Document must be in Trash and be a Trash root before permanent deletion", + ); + } + + const documentIds = ( + await db + .select({ id: schema.documents.id }) + .from(schema.documents) + .where( + and( + eq(schema.documents.ownerEmail, ownerEmail), + eq(schema.documents.trashRootId, id), + isNotNull(schema.documents.trashedAt), + ), + ) + ).map((document) => document.id); + const ownedDatabaseIds = await selectOwnedDatabaseIds( + db, + documentIds, + ownerEmail, + ).then((rows) => rows.map((database) => database.id)); + + await db + .update(schema.documents) + .set({ parentId: null, updatedAt: new Date().toISOString() }) + .where( + and( + eq(schema.documents.ownerEmail, ownerEmail), + inArray(schema.documents.parentId, documentIds), + or( + isNull(schema.documents.trashRootId), + ne(schema.documents.trashRootId, id), + ), + ), + ); + + return deleteCollectedDocuments( + db, + documentIds, + ownedDatabaseIds, + ownerEmail, + ); +} + export default defineAction({ description: "Move a document and all its children to Trash. Use permanently-delete-document to destroy an item already in Trash.", diff --git a/templates/content/actions/duplicate-database-item.ts b/templates/content/actions/duplicate-database-item.ts index 47e145e503..5f28ecb52d 100644 --- a/templates/content/actions/duplicate-database-item.ts +++ b/templates/content/actions/duplicate-database-item.ts @@ -46,6 +46,7 @@ export default defineAction({ ? eq(schema.contentDatabaseItems.id, itemId) : eq(schema.contentDatabaseItems.documentId, documentId!), isNull(schema.contentDatabases.deletedAt), + isNull(schema.documents.trashedAt), ), ); diff --git a/templates/content/actions/permanently-delete-document.ts b/templates/content/actions/permanently-delete-document.ts index b3aeb6a940..39c6d1745c 100644 --- a/templates/content/actions/permanently-delete-document.ts +++ b/templates/content/actions/permanently-delete-document.ts @@ -1,11 +1,10 @@ import { defineAction } from "@agent-native/core"; import { writeAppState } from "@agent-native/core/application-state"; import { assertAccess } from "@agent-native/core/sharing"; -import { eq } from "drizzle-orm"; import { z } from "zod"; -import { getDb, schema } from "../server/db/index.js"; -import { deleteDocumentRecursive } from "./delete-document.js"; +import { getDb } from "../server/db/index.js"; +import { deleteTrashedDocumentSubtree } from "./delete-document.js"; export default defineAction({ description: @@ -16,24 +15,8 @@ export default defineAction({ run: async ({ id }) => { const access = await assertAccess("document", id, "admin"); const db = getDb(); - const [state] = await db - .select({ - trashedAt: schema.documents.trashedAt, - databaseDeletedAt: schema.contentDatabases.deletedAt, - }) - .from(schema.documents) - .leftJoin( - schema.contentDatabases, - eq(schema.contentDatabases.documentId, schema.documents.id), - ) - .where(eq(schema.documents.id, id)) - .limit(1); - if (!state?.trashedAt && !state?.databaseDeletedAt) { - throw new Error("Document must be in Trash before permanent deletion"); - } - const deleted = await db.transaction((tx) => - deleteDocumentRecursive( + deleteTrashedDocumentSubtree( tx as unknown as ReturnType, id, access.resource.ownerEmail as string, diff --git a/templates/content/actions/pull-document.ts b/templates/content/actions/pull-document.ts index e0a48bd2bb..27c8fc34a3 100644 --- a/templates/content/actions/pull-document.ts +++ b/templates/content/actions/pull-document.ts @@ -4,6 +4,7 @@ import { resolveAccess } from "@agent-native/core/sharing"; import { z } from "zod"; import "../server/db/index.js"; +import { isSoftDeletedDatabaseDocument } from "./_database-utils.js"; import { flushOpenDocumentEditorToSql } from "./_document-flush.js"; /** @@ -62,7 +63,13 @@ export default defineAction({ publicAgent: { expose: true, readOnly: true, requiresAuth: true }, run: async ({ id, format }) => { const access = await resolveAccess("document", id); - if (!access) throw new Error(`Document "${id}" not found`); + if ( + !access || + access.resource.trashedAt || + (await isSoftDeletedDatabaseDocument(id)) + ) { + throw new Error(`Document "${id}" not found`); + } // If a live Yjs collab session is open, the in-memory editor doc is fresher // than the SQL column. Ask the open editor to serialize + save, then wait @@ -74,7 +81,13 @@ export default defineAction({ // Re-resolve so we read the now-fresh row (and re-check access). const fresh = await resolveAccess("document", id); - if (!fresh) throw new Error(`Document "${id}" not found`); + if ( + !fresh || + fresh.resource.trashedAt || + (await isSoftDeletedDatabaseDocument(id)) + ) { + throw new Error(`Document "${id}" not found`); + } const doc = fresh.resource; const markdown = (doc.content as string) ?? ""; const content = formatDocumentContent(markdown, format); diff --git a/templates/content/actions/restore-content-database.ts b/templates/content/actions/restore-content-database.ts index 8652e752d7..5eb3e80146 100644 --- a/templates/content/actions/restore-content-database.ts +++ b/templates/content/actions/restore-content-database.ts @@ -1,7 +1,7 @@ import { defineAction } from "@agent-native/core"; import { writeAppState } from "@agent-native/core/application-state"; import { resolveAccess } from "@agent-native/core/sharing"; -import { eq } from "drizzle-orm"; +import { and, eq, isNotNull } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; @@ -49,11 +49,35 @@ export default defineAction({ }); await db.transaction(async (tx) => { - await restoreDocumentSubtree( + const [backingDocument] = await tx + .select({ + trashedAt: schema.documents.trashedAt, + trashRootId: schema.documents.trashRootId, + }) + .from(schema.documents) + .where(eq(schema.documents.id, ownership.database.documentId)) + .limit(1); + if (!backingDocument) { + throw new Error(`Database "${databaseId}" not found`); + } + if ( + backingDocument.trashedAt && + backingDocument.trashRootId !== ownership.database.documentId + ) { + throw new Error("Restore the parent Trash item instead"); + } + + const restoredDocumentIds = await restoreDocumentSubtree( tx as unknown as ReturnType, ownership.database.documentId, ownership.database.ownerEmail, ); + if ( + backingDocument.trashedAt && + !restoredDocumentIds.includes(ownership.database.documentId) + ) { + throw new Error("Database backing page was not restored"); + } await tx .update(schema.contentDatabases) .set({ @@ -63,7 +87,12 @@ export default defineAction({ ? { ownerDocumentId: null, ownerBlockId: null } : {}), }) - .where(eq(schema.contentDatabases.id, databaseId)); + .where( + and( + eq(schema.contentDatabases.id, databaseId), + isNotNull(schema.contentDatabases.deletedAt), + ), + ); }); await writeAppState("refresh-signal", { ts: Date.now() }); diff --git a/templates/content/app/components/editor/database/DatabaseView.tsx b/templates/content/app/components/editor/database/DatabaseView.tsx index fc9e4787b2..2e4264d78a 100644 --- a/templates/content/app/components/editor/database/DatabaseView.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.tsx @@ -842,6 +842,9 @@ function DatabaseTable({ const filters = activeView.filters; const visibleFilters = filters; const filterMode = activeView.filterMode ?? "and"; + const workspaceCreationPropertyValues = isWorkspaceCatalog + ? databasePropertyValuesForNewItem(filters, properties, filterMode) + : undefined; const columnWidths = activeView.columnWidths; const databaseGroupProperty = useMemo( () => databaseViewGroupingProperty(activeView, orderedProperties), @@ -2391,7 +2394,10 @@ function DatabaseTable({ ) : null} {canEdit && isWorkspaceCatalog ? ( - +
) : null} - {canEdit ? ( + {canEdit && canCreateItems ? ( ; rowDensity: DatabaseRowDensity; actionColumnWidth?: number; + propertyValues?: Record; }) { return ( - +