Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 895173b176 | |||
| 45d5ae1601 | |||
| ec30e6c08a | |||
| af481d401a | |||
| 438ef091f9 | |||
| c90caeb21a | |||
| 5664da57ad | |||
| c39fab70c1 |
@@ -72,7 +72,10 @@ git log -1 --format='Author: %an <%ae>%nCommitter: %cn <%ce>'
|
|||||||
|
|
||||||
### 4. Push and PR to develop
|
### 4. Push and PR to develop
|
||||||
|
|
||||||
PRs always target `develop`. The `claude_code` password lives in the macOS
|
PRs always target `develop`. Two different mechanisms are involved: **pushing
|
||||||
|
commits is git-native** (the Gitea MCP cannot push local git history, so the
|
||||||
|
branch is still pushed with `git push`), while **the PR itself is opened through
|
||||||
|
the Gitea MCP** (see below). The `claude_code` password lives in the macOS
|
||||||
keychain as a **generic password** under service `gitea-claude-code` (do not
|
keychain as a **generic password** under service `gitea-claude-code` (do not
|
||||||
duplicate it as an internet-password for `gitea.vvzvlad.xyz` — that creates a
|
duplicate it as an internet-password for `gitea.vvzvlad.xyz` — that creates a
|
||||||
conflict with the owner's account in the git credential helper):
|
conflict with the owner's account in the git credential helper):
|
||||||
@@ -94,18 +97,24 @@ git remote set-url gitea "$ORIG_URL"
|
|||||||
unset AGENT_PASS SAFE_PASS
|
unset AGENT_PASS SAFE_PASS
|
||||||
```
|
```
|
||||||
|
|
||||||
The PR is created via the Gitea REST API (Basic Auth as `claude_code`):
|
The PR is opened through the **Gitea MCP** (server `gitea`), not `curl`/`tea` —
|
||||||
|
the MCP authenticates in-process, so no keychain lookup or Basic-Auth is needed.
|
||||||
|
Call `pull_request_write` with:
|
||||||
|
|
||||||
```bash
|
- `method: "create"`
|
||||||
curl -s -X POST \
|
- `owner: "vvzvlad"`, `repo: "gitmost"`
|
||||||
-u "claude_code:$(security find-generic-password -s gitea-claude-code -w)" \
|
- `base: "develop"`, `head: "<branch>"`
|
||||||
-H "Content-Type: application/json" \
|
- `title`, `body` — in the body: what was done, what is out of scope,
|
||||||
-d @pr_body.json \
|
verification results (tsc/lint/tests).
|
||||||
"https://gitea.vvzvlad.xyz/api/v1/repos/vvzvlad/gitmost/pulls"
|
|
||||||
```
|
|
||||||
|
|
||||||
`base: develop`, `head: <branch>`. In the PR body: what was done, what is out
|
Manage and read PRs through the same server: `list_pull_requests`,
|
||||||
of scope, verification results (tsc/lint/tests).
|
`pull_request_read` (`get`, `get_diff`, `get_files`, `get_status`),
|
||||||
|
`pull_request_review_write`.
|
||||||
|
|
||||||
|
**Identity note:** the MCP acts under its **own** configured Gitea token (verify
|
||||||
|
with `get_me`), a different account from the `claude_code` used for git
|
||||||
|
commits/pushes in §3. Only the forge API calls (PR / issue / review) go through
|
||||||
|
the MCP account; the commits themselves stay authored as `claude_code`.
|
||||||
|
|
||||||
> If push fails with `User permission denied for writing`, then `claude_code`
|
> If push fails with `User permission denied for writing`, then `claude_code`
|
||||||
> lacks collaborator rights on the repo. Ask the owner to add them (once, via
|
> lacks collaborator rights on the repo. Ask the owner to add them (once, via
|
||||||
@@ -152,23 +161,25 @@ below.
|
|||||||
| Agent user (Gitea/git) | `claude_code` |
|
| Agent user (Gitea/git) | `claude_code` |
|
||||||
| Agent email | `claude_code@vvzvlad.xyz` |
|
| Agent email | `claude_code@vvzvlad.xyz` |
|
||||||
| Keychain password | `security find-generic-password -s gitea-claude-code -w` |
|
| Keychain password | `security find-generic-password -s gitea-claude-code -w` |
|
||||||
| PR API | `https://gitea.vvzvlad.xyz/api/v1/repos/vvzvlad/gitmost/pulls` (here `gitmost` is the repo's real slug on the server) |
|
| Forge API (PR / issue / review / reads) | **Gitea MCP** — server `gitea` (`pull_request_write`, `issue_write`, `list_pull_requests`, `pull_request_read`, `label_read`, …). Authenticated in-process; acts under its own token — check with `get_me`. Repo slug on the server is `gitmost`. |
|
||||||
| Base branch | `develop` |
|
| Base branch | `develop` |
|
||||||
| `origin` | GitHub mirror `vvzvlad/gitmost` — **do not push**, updated by the owner's CI |
|
| `origin` | GitHub mirror `vvzvlad/gitmost` — **do not push**, updated by the owner's CI |
|
||||||
| `upstream` | The original Docmost — **never push** |
|
| `upstream` | The original Docmost — **never push** |
|
||||||
|
|
||||||
## Creating issues (Gitea `tea` CLI)
|
## Creating issues (Gitea MCP)
|
||||||
|
|
||||||
Issues are filed with the official Gitea CLI `tea`, already logged in as
|
File issues through the **Gitea MCP** (server `gitea`), not a CLI — call
|
||||||
`claude_code` (`tea logins list` shows the `gitea` login as default):
|
`issue_write` with:
|
||||||
|
|
||||||
```bash
|
- `method: "create"`
|
||||||
tea issues create --repo vvzvlad/gitmost --labels feature \
|
- `owner: "vvzvlad"`, `repo: "gitmost"`
|
||||||
--title '<title>' --description "$(cat body.md)"
|
- `title`, `body`
|
||||||
```
|
- `labels` — an array of label **IDs** (numbers), *not* names. Resolve a name
|
||||||
|
such as `feature` to its id first with `label_read` (`method: "list"`), then
|
||||||
|
pass e.g. `labels: [<id>]`.
|
||||||
|
|
||||||
> Gotcha (tea 0.14.1): the issue body flag is `--description`/`-d`, **not**
|
Read issues with `list_issues`, `issue_read`, or `search_issues`. The MCP is
|
||||||
> `--body` — passing `--body` fails with `flag provided but not defined: -body`.
|
authenticated in-process, so no `tea`/`curl` and no keychain lookup are needed.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+70
-2
@@ -14,8 +14,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
- **Place several images side by side in a row.** A new "Inline (side by
|
- **Place several images side by side in a row.** A new "Inline (side by
|
||||||
side)" alignment mode in the image bubble menu renders consecutive inline
|
side)" alignment mode in the image bubble menu renders consecutive inline
|
||||||
images as a row that wraps onto the next line on narrow screens. Unlike the
|
images as a row that wraps onto the next line on narrow screens. The row is
|
||||||
float modes, text does not wrap around inline images. The mode round-trips
|
centered horizontally by default in modern browsers (CSS `:has()`), falling
|
||||||
|
back to start-aligned rows in browsers without support. Unlike the float
|
||||||
|
modes, text does not wrap around inline images. The mode round-trips
|
||||||
losslessly through markdown as `data-align`, like the other alignment
|
losslessly through markdown as `data-align`, like the other alignment
|
||||||
values.
|
values.
|
||||||
|
|
||||||
@@ -84,6 +86,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
with the `||text||` input rule; the rendered span blurs until clicked to reveal.
|
with the `||text||` input rule; the rendered span blurs until clicked to reveal.
|
||||||
The mark is preserved losslessly through Markdown export/import (as a raw
|
The mark is preserved losslessly through Markdown export/import (as a raw
|
||||||
`<span data-spoiler="true">…</span>`) and on public shares. (#259)
|
`<span data-spoiler="true">…</span>`) and on public shares. (#259)
|
||||||
|
- **Dock the AI chat window into the side menu.** The floating chat window can
|
||||||
|
be pinned to the sidebar — drag it onto the navbar (a drop-zone highlight
|
||||||
|
shows where it lands) or use the new "Dock to sidebar" header button; while
|
||||||
|
docked it fills the sidebar area and follows its live size. "Undock" (or
|
||||||
|
dragging it back out) restores the floating window, a collapsed/absent
|
||||||
|
sidebar falls back to floating, and the docked state survives a reload.
|
||||||
|
(#276, #282)
|
||||||
|
- **Hovering commented text shows the comment thread in a tooltip.** Pointing
|
||||||
|
at a highlighted comment mark pops a small card with the author and plain
|
||||||
|
text of the root comment and its replies, so a thread can be skimmed without
|
||||||
|
opening the side panel. The card appears after a short delay (no flicker on a
|
||||||
|
passing glance), skips resolved and text-less threads, and dismisses on
|
||||||
|
scroll or click — clicking a mark still opens the comments panel. (#268,
|
||||||
|
#271)
|
||||||
|
- **"Move to trash" button in the temporary-note banner.** Besides "Make
|
||||||
|
permanent", the banner on an open temporary note now also offers to trash the
|
||||||
|
note immediately instead of waiting out its lifetime. It reuses the regular
|
||||||
|
soft-delete path, so the "Page moved to trash" undo toast is the safety net —
|
||||||
|
no confirmation dialog. (#273, #277)
|
||||||
|
- **Code-block controls float as an overlay instead of taking a row above the
|
||||||
|
code.** The language selector and copy button now sit in the block's top-right
|
||||||
|
corner, and the selector stays invisible until the block is hovered or the
|
||||||
|
selector is focused, so reading code is chrome-free. In read-only views only
|
||||||
|
the copy button renders. (#275, #278)
|
||||||
|
- **The AI agent is told about your page edits between turns.** The server
|
||||||
|
snapshots the open page's Markdown at the end of every agent turn and, on the
|
||||||
|
next turn, injects a unified diff of what changed in between, so the agent
|
||||||
|
knows its earlier copy of the page is stale and builds on the user's edits
|
||||||
|
instead of reverting or overwriting them. The diff is whitespace-normalized
|
||||||
|
(pure formatting churn injects nothing) and size-capped, with a hint to
|
||||||
|
re-read the full page via `getPage` when truncated. (#274, #281)
|
||||||
|
- **Stress-accent button (U+0301) in the bubble menu.** Select a vowel and
|
||||||
|
toggle a combining acute accent over it — a Russian-style stress mark. The
|
||||||
|
accent is stored as plain text (no custom mark), so it survives Markdown/HTML
|
||||||
|
export, full-text search and public shares unchanged; the toggle is a single
|
||||||
|
undo step and re-clicking removes the accent. (#270, #280)
|
||||||
|
- **Reading position survives a reload.** The editor remembers how far you
|
||||||
|
scrolled in each page (per tab, in `sessionStorage`) and restores that
|
||||||
|
position after an F5 or reopening the document, waiting for the collaborative
|
||||||
|
content to finish laying out first. A URL `#hash` anchor still wins — restore
|
||||||
|
is a no-op then. (#266, #267)
|
||||||
|
- **The slash menu finds commands typed in the wrong keyboard layout.** A query
|
||||||
|
typed with the wrong layout active (e.g. `/сщву` for `/code`, or `/cyjcrf`
|
||||||
|
for the Cyrillic «сноска» → Footnote) is additionally remapped ЙЦУКЕН↔QWERTY
|
||||||
|
by physical key position and matched against the commands; genuine Cyrillic
|
||||||
|
search terms keep priority over remapped candidates, and short wrong-layout
|
||||||
|
prefixes match by command title. (#283, #285, #287)
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
@@ -149,6 +198,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
emits a single-use "intentional clear" signal that lets exactly that one empty
|
emits a single-use "intentional clear" signal that lets exactly that one empty
|
||||||
write through the guard, so genuinely emptying a page is persisted while
|
write through the guard, so genuinely emptying a page is persisted while
|
||||||
accidental empties are blocked. (#248, #251)
|
accidental empties are blocked. (#248, #251)
|
||||||
|
- **Ctrl+Z works again right after using a table menu.** Closing a table
|
||||||
|
row/column menu (grip or chevron) left focus on the menu's portaled target
|
||||||
|
outside the editor, so undo keystrokes went nowhere until you clicked back
|
||||||
|
into a cell. The editor is now refocused after the menu closes — unless you
|
||||||
|
deliberately moved focus to another input or editable (e.g. the page title).
|
||||||
|
(#269, #279)
|
||||||
|
- **The AI reindex progress counter no longer freezes at 0.** Right after
|
||||||
|
"Reindex now" the client could read the stale pre-reindex snapshot of an
|
||||||
|
already-indexed workspace (`reindexing=false`, all pages counted) as
|
||||||
|
"finished" and stop polling on the very first tick, leaving the counter
|
||||||
|
frozen until a manual reload. Polling now keeps going until it has actually
|
||||||
|
observed the active run. (#262, #264)
|
||||||
|
- **An MCP edit can no longer be silently lost to a duplicate collab document.**
|
||||||
|
When the agent addressed a page by its short slugId, the MCP opened a
|
||||||
|
collaboration document named after that slugId while the web editor always
|
||||||
|
uses the page's canonical UUID — two independent live documents for one page,
|
||||||
|
whose debounced stores clobbered each other. The MCP now resolves every page
|
||||||
|
id to the canonical UUID before opening the collab doc (a UUID input
|
||||||
|
short-circuits locally; a slugId is resolved once and cached). (#260, #265)
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ community feature, with no enterprise license. Open it from the page header; the
|
|||||||
- ✅ **Page templates** — flag a page as a template and embed its whole content live into other pages; edits to the template propagate to every place it is inserted (whole-page transclusion on top of the existing synced blocks).
|
- ✅ **Page templates** — flag a page as a template and embed its whole content live into other pages; edits to the template propagate to every place it is inserted (whole-page transclusion on top of the existing synced blocks).
|
||||||
- ✅ **Public-share AI assistant** — anonymous visitors of a shared page can ask the AI agent, scoped strictly to that share's page tree (read-only, share-scoped search), behind a workspace toggle.
|
- ✅ **Public-share AI assistant** — anonymous visitors of a shared page can ask the AI agent, scoped strictly to that share's page tree (read-only, share-scoped search), behind a workspace toggle.
|
||||||
- ✅ **Footnotes** — academic-style footnotes: a numbered superscript reference inline (read it in place via a hover popover), with the note text living as a real, editable block at the bottom of the page; auto-numbered, collaboration-safe, and round-trips through Markdown export/import and the AI agent / MCP.
|
- ✅ **Footnotes** — academic-style footnotes: a numbered superscript reference inline (read it in place via a hover popover), with the note text living as a real, editable block at the bottom of the page; auto-numbered, collaboration-safe, and round-trips through Markdown export/import and the AI agent / MCP.
|
||||||
- ✅ **Temporary notes** — mark a note as temporary and it auto-moves to Trash after a configurable per-workspace lifetime (default 24h) unless made permanent first; create one in a click from the Home screen, any space overview, or the space sidebar, with a "Make permanent" rescue banner on the open note.
|
- ✅ **Temporary notes** — create a note as temporary and it auto-moves to Trash after a configurable per-workspace lifetime (default 24h) unless made permanent first; create one in a click from the Home screen, any space overview.
|
||||||
|
|
||||||
### In progress
|
### In progress
|
||||||
|
|
||||||
@@ -187,14 +187,17 @@ start the new migrations apply on top of your existing schema (`CREATE EXTENSION
|
|||||||
- Spaces
|
- Spaces
|
||||||
- Permissions management
|
- Permissions management
|
||||||
- Groups
|
- Groups
|
||||||
- Comments (with resolve / re-open)
|
- Comments (with resolve / re-open and hover tooltips showing the comment text)
|
||||||
- Page history
|
- Page history
|
||||||
- Search
|
- Search
|
||||||
- File attachments
|
- File attachments
|
||||||
- Embeds (Airtable, Loom, Miro and more)
|
- Embeds (Airtable, Loom, Miro and more)
|
||||||
- Translations (10+ languages)
|
- Translations (10+ languages)
|
||||||
- Embedded MCP server (`/mcp`)
|
- Embedded MCP server (`/mcp`)
|
||||||
- AI agent chat over your wiki (read + write, RAG search, external MCP / web access)
|
- AI agent chat over your wiki (read + write, RAG search, external MCP / web access); the chat window docks into the side menu, and the agent is told about your in-page edits between turns
|
||||||
|
- Code-block buttons as an overlay, with the language selector revealed on hover
|
||||||
|
- Stress-accent button (U+0301) in the bubble menu
|
||||||
|
- Reading scroll position restored on reload
|
||||||
|
|
||||||
### Screenshots
|
### Screenshots
|
||||||
|
|
||||||
|
|||||||
+7
-3
@@ -105,7 +105,7 @@ real-time-коллаборации Docmost, поэтому запись нико
|
|||||||
- ✅ **Шаблоны страниц** — пометить страницу шаблоном и вставлять её содержимое живой ссылкой в другие страницы; правки шаблона распространяются на все места вставки (whole-page-транслюзия поверх существующих synced-блоков).
|
- ✅ **Шаблоны страниц** — пометить страницу шаблоном и вставлять её содержимое живой ссылкой в другие страницы; правки шаблона распространяются на все места вставки (whole-page-транслюзия поверх существующих synced-блоков).
|
||||||
- ✅ **AI-ассистент на публичных шарах** — анонимный зритель расшаренной страницы может спросить AI-агента, который ищет строго по дереву этой шары (read-only, share-scoped поиск), за тумблером воркспейса.
|
- ✅ **AI-ассистент на публичных шарах** — анонимный зритель расшаренной страницы может спросить AI-агента, который ищет строго по дереву этой шары (read-only, share-scoped поиск), за тумблером воркспейса.
|
||||||
- ✅ **Сноски** — сноски академического вида: нумерованная ссылка-надстрочник прямо в тексте (читается на месте во всплывающем окне по наведению), а текст сноски живёт реальным редактируемым блоком внизу страницы; авто-нумерация, безопасна для совместного редактирования, переживает экспорт/импорт Markdown и доступна AI-агенту / MCP.
|
- ✅ **Сноски** — сноски академического вида: нумерованная ссылка-надстрочник прямо в тексте (читается на месте во всплывающем окне по наведению), а текст сноски живёт реальным редактируемым блоком внизу страницы; авто-нумерация, безопасна для совместного редактирования, переживает экспорт/импорт Markdown и доступна AI-агенту / MCP.
|
||||||
- ✅ **Временные заметки** — пометьте заметку временной, и она автоматически уедет в корзину по истечении настраиваемого срока жизни воркспейса (по умолчанию 24 ч), если её предварительно не сделать постоянной; создать такую можно в один клик с домашнего экрана, с обзора любого пространства или из сайдбара пространства, а на открытой заметке есть баннер «Сделать постоянной».
|
- ✅ **Временные заметки** — создайте временную заметку, и она автоматически уедет в корзину по истечении настраиваемого срока жизни (по умолчанию 24 ч); создать такую можно в один клик с домашнего экрана, с обзора любого пространства или из сайдбара пространства.
|
||||||
|
|
||||||
### В процессе
|
### В процессе
|
||||||
|
|
||||||
@@ -174,14 +174,18 @@ dump/restore, существующий каталог данных переис
|
|||||||
- Пространства (Spaces)
|
- Пространства (Spaces)
|
||||||
- Управление правами доступа
|
- Управление правами доступа
|
||||||
- Группы
|
- Группы
|
||||||
- Комментарии (с резолвом / переоткрытием)
|
- Комментарии (с резолвом / переоткрытием и всплывающими подсказками с текстом комментария при наведении)
|
||||||
- История страниц
|
- История страниц
|
||||||
- Поиск
|
- Поиск
|
||||||
- Вложения файлов
|
- Вложения файлов
|
||||||
- Встраивания (Airtable, Loom, Miro и другие)
|
- Встраивания (Airtable, Loom, Miro и другие)
|
||||||
- Переводы (10+ языков)
|
- Переводы (10+ языков)
|
||||||
- Встроенный MCP-сервер (`/mcp`)
|
- Встроенный MCP-сервер (`/mcp`)
|
||||||
- Чат с AI-агентом по вики (чтение + запись, RAG-поиск, внешние MCP / доступ в интернет)
|
- Чат с AI-агентом по вики (чтение + запись, RAG-поиск, внешние MCP / доступ в интернет); окно чата закрепляется в боковом меню, а агент узнаёт о ваших правках страницы между ходами
|
||||||
|
- Кнопки код-блока оверлеем, селектор языка появляется при наведении
|
||||||
|
- Кнопка «Ударение» (U+0301) в bubble-меню
|
||||||
|
- Позиция чтения (прокрутка) восстанавливается после перезагрузки
|
||||||
|
- Slash-меню терпимо к неправильной раскладке (ЙЦУКЕН↔QWERTY)
|
||||||
|
|
||||||
### Скриншоты
|
### Скриншоты
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import { acceptInvitation } from "@/features/workspace/services/workspace-servic
|
|||||||
import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route.ts";
|
import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route.ts";
|
||||||
import { RESET } from "jotai/utils";
|
import { RESET } from "jotai/utils";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { clearPersistedTreeCaches } from "@/features/page/tree/atoms/tree-data-atom";
|
|
||||||
|
|
||||||
export default function useAuth() {
|
export default function useAuth() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -123,9 +122,6 @@ export default function useAuth() {
|
|||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
setCurrentUser(RESET);
|
setCurrentUser(RESET);
|
||||||
// Purge the persisted sidebar tree caches (they contain page titles) so
|
|
||||||
// nothing readable is left in localStorage on a shared machine.
|
|
||||||
clearPersistedTreeCaches();
|
|
||||||
await logout();
|
await logout();
|
||||||
window.location.replace(`${APP_ROUTE.AUTH.LOGIN}?logout=1`);
|
window.location.replace(`${APP_ROUTE.AUTH.LOGIN}?logout=1`);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -71,3 +71,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Inline image rows (#284): center the anonymous line boxes formed by
|
||||||
|
consecutive [data-image-align="inline"] node-view containers. A row has no
|
||||||
|
DOM wrapper of its own, so its horizontal placement is controlled by the
|
||||||
|
text-align of the nearest block ancestor (the editor root or a nested
|
||||||
|
block container: blockquote, callout, list item, table cell, details).
|
||||||
|
Centering is enabled only in containers that actually hold an inline
|
||||||
|
image (:has), and every other child of such a container gets its default
|
||||||
|
alignment back so ordinary text is unaffected. Explicit per-block
|
||||||
|
alignment from the toolbar is an inline style and still wins. Browsers
|
||||||
|
without :has() degrade to left-pinned rows. */
|
||||||
|
.ProseMirror:has(> [data-image-align="inline"]),
|
||||||
|
.ProseMirror :has(> [data-image-align="inline"]) {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror:has(> [data-image-align="inline"]) > :not([data-image-align="inline"]),
|
||||||
|
.ProseMirror :has(> [data-image-align="inline"]) > :not([data-image-align="inline"]) {
|
||||||
|
text-align: start;
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,30 +13,20 @@ export type OpenMap = Record<string, boolean>;
|
|||||||
// `OpenMap | Promise<OpenMap>` and break the functional-updater setter below).
|
// `OpenMap | Promise<OpenMap>` and break the functional-updater setter below).
|
||||||
const openTreeNodesStorage = createJSONStorage<OpenMap>(() => localStorage);
|
const openTreeNodesStorage = createJSONStorage<OpenMap>(() => localStorage);
|
||||||
|
|
||||||
// Single source of truth for the open-map localStorage key prefix. Exported so
|
|
||||||
// the logout cache sweep (tree-data-atom.ts) removes keys by the SAME prefix
|
|
||||||
// used to write them — a rename here can never silently desync the cleanup.
|
|
||||||
export const OPEN_TREE_NODES_KEY_PREFIX = "openTreeNodes:";
|
|
||||||
|
|
||||||
// One persisted open/closed map per (workspace, user). Scoping the localStorage
|
// One persisted open/closed map per (workspace, user). Scoping the localStorage
|
||||||
// key prevents accounts that share a browser origin from leaking tree state.
|
// key prevents accounts that share a browser origin from leaking tree state.
|
||||||
// `getOnInit: true` reads localStorage synchronously at atom init (not on mount),
|
// `getOnInit: true` reads localStorage synchronously at atom init (not on mount),
|
||||||
// so the first render already has the saved state — no collapse-then-expand
|
// so the first render already has the saved state — no collapse-then-expand
|
||||||
// flicker on reload, and writes never run against an un-hydrated empty map.
|
// flicker on reload, and writes never run against an un-hydrated empty map.
|
||||||
const openTreeNodesFamily = atomFamily((scopeKey: string) =>
|
const openTreeNodesFamily = atomFamily((scopeKey: string) =>
|
||||||
atomWithStorage<OpenMap>(
|
atomWithStorage<OpenMap>(`openTreeNodes:${scopeKey}`, {}, openTreeNodesStorage, {
|
||||||
`${OPEN_TREE_NODES_KEY_PREFIX}${scopeKey}`,
|
getOnInit: true,
|
||||||
{},
|
}),
|
||||||
openTreeNodesStorage,
|
|
||||||
{ getOnInit: true },
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Resolve the storage scope from the current user. Fall back to "anon" for the
|
// Resolve the storage scope from the current user. Fall back to "anon" for the
|
||||||
// workspace/user parts when nothing is loaded yet (logged out / first paint).
|
// workspace/user parts when nothing is loaded yet (logged out / first paint).
|
||||||
// Shared by the open-map atom below and the persisted tree-data atom
|
const scopeKeyAtom = atom((get) => {
|
||||||
// (tree-data-atom.ts) so both caches are scoped identically.
|
|
||||||
export const scopeKeyAtom = atom((get) => {
|
|
||||||
const currentUser = get(currentUserAtom);
|
const currentUser = get(currentUserAtom);
|
||||||
const workspaceId = currentUser?.workspace?.id ?? "anon";
|
const workspaceId = currentUser?.workspace?.id ?? "anon";
|
||||||
const userId = currentUser?.user?.id ?? "anon";
|
const userId = currentUser?.user?.id ?? "anon";
|
||||||
|
|||||||
@@ -1,232 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
||||||
import type { SpaceTreeNode } from "@/features/page/tree/types";
|
|
||||||
import type { ICurrentUser } from "@/features/user/types/user.types";
|
|
||||||
|
|
||||||
// The persisted tree-data atom hydrates from localStorage ONCE, at family-atom
|
|
||||||
// creation (`getOnInit: true`). To exercise hydration deterministically each
|
|
||||||
// test imports a FRESH module instance (fresh atomFamily) after seeding the
|
|
||||||
// storage stub from vitest.setup.ts. jotai itself is externalized by vitest, so
|
|
||||||
// `createStore` can stay a static import — atoms are plain objects and any
|
|
||||||
// store works with any module instance.
|
|
||||||
import { createStore } from "jotai";
|
|
||||||
|
|
||||||
// Storage key for the default scope: no currentUser -> "anon:anon" (see
|
|
||||||
// scopeKeyAtom in open-tree-nodes-atom.ts) with the `v1` cache-shape version.
|
|
||||||
const ANON_KEY = "treeData:v1:anon:anon";
|
|
||||||
const DEBOUNCE_MS = 500;
|
|
||||||
|
|
||||||
async function freshImport() {
|
|
||||||
vi.resetModules();
|
|
||||||
const treeDataModule = await import("./tree-data-atom");
|
|
||||||
const userModule = await import(
|
|
||||||
"@/features/user/atoms/current-user-atom"
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
treeDataAtom: treeDataModule.treeDataAtom,
|
|
||||||
flushPendingTreeDataWrites: treeDataModule.flushPendingTreeDataWrites,
|
|
||||||
clearPersistedTreeCaches: treeDataModule.clearPersistedTreeCaches,
|
|
||||||
currentUserAtom: userModule.currentUserAtom,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function node(id: string): SpaceTreeNode {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
slugId: `slug-${id}`,
|
|
||||||
name: id,
|
|
||||||
position: "a0",
|
|
||||||
spaceId: "space-1",
|
|
||||||
parentPageId: null as unknown as string,
|
|
||||||
hasChildren: false,
|
|
||||||
children: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every persisted tree key currently in storage — asserting on the whole
|
|
||||||
// prefix (not one known key) catches writes that resurrect under ANY scope.
|
|
||||||
function persistedTreeDataKeys(): string[] {
|
|
||||||
const keys: string[] = [];
|
|
||||||
for (let i = 0; i < localStorage.length; i++) {
|
|
||||||
const key = localStorage.key(i);
|
|
||||||
if (key !== null && key.startsWith("treeData:v1:")) keys.push(key);
|
|
||||||
}
|
|
||||||
return keys;
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentUser(workspaceId: string, userId: string): ICurrentUser {
|
|
||||||
return {
|
|
||||||
user: { id: userId },
|
|
||||||
workspace: { id: workspaceId },
|
|
||||||
} as unknown as ICurrentUser;
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
localStorage.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("treeDataAtom (localStorage-persisted)", () => {
|
|
||||||
it("reads [] from a fresh store with empty storage", async () => {
|
|
||||||
const { treeDataAtom } = await freshImport();
|
|
||||||
const store = createStore();
|
|
||||||
|
|
||||||
expect(store.get(treeDataAtom)).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("persists through the debounced setItem and hydrates a fresh module back", async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
const setItemSpy = vi.spyOn(localStorage, "setItem");
|
|
||||||
|
|
||||||
const { treeDataAtom } = await freshImport();
|
|
||||||
const store = createStore();
|
|
||||||
|
|
||||||
store.set(treeDataAtom, [node("a")]);
|
|
||||||
// Second write inside the debounce window — must coalesce into ONE flush
|
|
||||||
// carrying only the latest value.
|
|
||||||
vi.advanceTimersByTime(DEBOUNCE_MS / 2);
|
|
||||||
store.set(treeDataAtom, [node("a"), node("b")]);
|
|
||||||
|
|
||||||
// Nothing flushed yet: the write is trailing-debounced.
|
|
||||||
expect(localStorage.getItem(ANON_KEY)).toBeNull();
|
|
||||||
|
|
||||||
vi.advanceTimersByTime(DEBOUNCE_MS + 100);
|
|
||||||
|
|
||||||
expect(setItemSpy).toHaveBeenCalledTimes(1);
|
|
||||||
expect(JSON.parse(localStorage.getItem(ANON_KEY)!)).toEqual([
|
|
||||||
node("a"),
|
|
||||||
node("b"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// A fresh module (fresh atom family -> getOnInit re-reads storage) and a
|
|
||||||
// fresh store hydrate the persisted tree back — the reload scenario.
|
|
||||||
const second = await freshImport();
|
|
||||||
const store2 = createStore();
|
|
||||||
expect(store2.get(second.treeDataAtom)).toEqual([node("a"), node("b")]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reads [] (without throwing) when storage holds corrupted JSON", async () => {
|
|
||||||
localStorage.setItem(ANON_KEY, "{definitely not JSON!!!");
|
|
||||||
|
|
||||||
const { treeDataAtom } = await freshImport();
|
|
||||||
const store = createStore();
|
|
||||||
|
|
||||||
expect(store.get(treeDataAtom)).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reads [] when storage holds valid JSON of a non-array shape", async () => {
|
|
||||||
localStorage.setItem(ANON_KEY, JSON.stringify({ id: "not-a-tree" }));
|
|
||||||
|
|
||||||
const { treeDataAtom } = await freshImport();
|
|
||||||
const store = createStore();
|
|
||||||
|
|
||||||
expect(store.get(treeDataAtom)).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("supports functional-updater writes", async () => {
|
|
||||||
const { treeDataAtom } = await freshImport();
|
|
||||||
const store = createStore();
|
|
||||||
|
|
||||||
store.set(treeDataAtom, [node("a")]);
|
|
||||||
store.set(treeDataAtom, (prev) => [...prev, node("b")]);
|
|
||||||
|
|
||||||
expect(store.get(treeDataAtom).map((n) => n.id)).toEqual(["a", "b"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("isolates trees between (workspace, user) scopes", async () => {
|
|
||||||
const { treeDataAtom, currentUserAtom } = await freshImport();
|
|
||||||
const store = createStore();
|
|
||||||
|
|
||||||
store.set(currentUserAtom, currentUser("w1", "u1"));
|
|
||||||
store.set(treeDataAtom, [node("a")]);
|
|
||||||
expect(store.get(treeDataAtom).map((n) => n.id)).toEqual(["a"]);
|
|
||||||
|
|
||||||
// Another account on the same browser origin must NOT see u1's tree.
|
|
||||||
store.set(currentUserAtom, currentUser("w2", "u2"));
|
|
||||||
expect(store.get(treeDataAtom)).toEqual([]);
|
|
||||||
|
|
||||||
store.set(treeDataAtom, [node("b")]);
|
|
||||||
expect(store.get(treeDataAtom).map((n) => n.id)).toEqual(["b"]);
|
|
||||||
|
|
||||||
// Switching back resolves the original scope's tree untouched.
|
|
||||||
store.set(currentUserAtom, currentUser("w1", "u1"));
|
|
||||||
expect(store.get(treeDataAtom).map((n) => n.id)).toEqual(["a"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clearPersistedTreeCaches removes all tree keys and discards pending writes", async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
|
|
||||||
// Stale caches across scopes plus an UNRELATED key that must survive.
|
|
||||||
localStorage.setItem("treeData:v1:a:b", JSON.stringify([node("stale")]));
|
|
||||||
localStorage.setItem("openTreeNodes:a:b", JSON.stringify({ p1: true }));
|
|
||||||
localStorage.setItem("currentUser", JSON.stringify({ user: { id: "b" } }));
|
|
||||||
|
|
||||||
const { treeDataAtom, clearPersistedTreeCaches } = await freshImport();
|
|
||||||
const store = createStore();
|
|
||||||
|
|
||||||
// Queue a debounced write (not flushed yet) for the anon scope.
|
|
||||||
store.set(treeDataAtom, [node("pending")]);
|
|
||||||
expect(localStorage.getItem(ANON_KEY)).toBeNull();
|
|
||||||
|
|
||||||
clearPersistedTreeCaches();
|
|
||||||
|
|
||||||
// Both prefixed caches are swept; the unrelated key is untouched.
|
|
||||||
expect(localStorage.getItem("treeData:v1:a:b")).toBeNull();
|
|
||||||
expect(localStorage.getItem("openTreeNodes:a:b")).toBeNull();
|
|
||||||
expect(localStorage.getItem("currentUser")).toBe(
|
|
||||||
JSON.stringify({ user: { id: "b" } }),
|
|
||||||
);
|
|
||||||
|
|
||||||
// The queued write was DISCARDED, not merely delayed: the debounce timer
|
|
||||||
// firing later must not resurrect a tree key after logout.
|
|
||||||
vi.advanceTimersByTime(DEBOUNCE_MS + 100);
|
|
||||||
expect(localStorage.getItem(ANON_KEY)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clearPersistedTreeCaches discards queued writes even when flushed DIRECTLY", async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
|
|
||||||
const { treeDataAtom, clearPersistedTreeCaches, flushPendingTreeDataWrites } =
|
|
||||||
await freshImport();
|
|
||||||
const store = createStore();
|
|
||||||
|
|
||||||
// Queue a debounced write, then clear. Calling the flush directly (not via
|
|
||||||
// the debounce timer) isolates the pending-queue discard from the timer
|
|
||||||
// cancel: if the queue survived, this flush would resurrect the key even
|
|
||||||
// though the timer never fired.
|
|
||||||
store.set(treeDataAtom, [node("pending")]);
|
|
||||||
clearPersistedTreeCaches();
|
|
||||||
flushPendingTreeDataWrites();
|
|
||||||
|
|
||||||
expect(localStorage.getItem(ANON_KEY)).toBeNull();
|
|
||||||
expect(persistedTreeDataKeys()).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("disables persistence after clearPersistedTreeCaches: NEW writes never reach storage", async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
|
|
||||||
const { treeDataAtom, clearPersistedTreeCaches, flushPendingTreeDataWrites } =
|
|
||||||
await freshImport();
|
|
||||||
const store = createStore();
|
|
||||||
|
|
||||||
clearPersistedTreeCaches();
|
|
||||||
|
|
||||||
// The resurrection scenario: a websocket tree event lands while `await
|
|
||||||
// logout()` is still in flight, AFTER the sweep. The write must not be
|
|
||||||
// queued, must not arm a new debounce timer, and must not survive the
|
|
||||||
// beforeunload flush fired by the logout redirect.
|
|
||||||
store.set(treeDataAtom, [node("late")]);
|
|
||||||
|
|
||||||
vi.advanceTimersByTime(DEBOUNCE_MS + 100);
|
|
||||||
flushPendingTreeDataWrites(); // what the beforeunload handler runs
|
|
||||||
|
|
||||||
expect(persistedTreeDataKeys()).toEqual([]);
|
|
||||||
|
|
||||||
// Only PERSISTENCE is disabled: the in-memory atom keeps working, so the
|
|
||||||
// UI stays intact during the brief pre-redirect window.
|
|
||||||
expect(store.get(treeDataAtom).map((n) => n.id)).toEqual(["late"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,200 +1,8 @@
|
|||||||
import { atom } from "jotai";
|
import { atom } from "jotai";
|
||||||
import { atomFamily, atomWithStorage } from "jotai/utils";
|
|
||||||
import { SpaceTreeNode } from "@/features/page/tree/types";
|
import { SpaceTreeNode } from "@/features/page/tree/types";
|
||||||
import { appendNodeChildren } from "../utils";
|
import { appendNodeChildren } from "../utils";
|
||||||
import {
|
|
||||||
OPEN_TREE_NODES_KEY_PREFIX,
|
|
||||||
scopeKeyAtom,
|
|
||||||
} from "./open-tree-nodes-atom";
|
|
||||||
|
|
||||||
// The sidebar tree is persisted to localStorage so a page reload can paint the
|
export const treeDataAtom = atom<SpaceTreeNode[]>([]);
|
||||||
// last-known tree IMMEDIATELY (no blank sidebar while the root query runs) and
|
|
||||||
// then reconcile with the server in the background. localStorage is a BOOT
|
|
||||||
// CACHE only — the in-memory atom stays the source of truth while the app runs.
|
|
||||||
|
|
||||||
// Trailing-debounce machinery for the localStorage writes. The tree is
|
|
||||||
// rewritten on every lazy load / drag / socket event; serializing a large tree
|
|
||||||
// on each update would burn CPU and thrash the storage quota, so writes are
|
|
||||||
// coalesced (~500 ms per burst) and only the latest value per key is flushed.
|
|
||||||
const WRITE_DEBOUNCE_MS = 500;
|
|
||||||
|
|
||||||
// Single source of truth for the tree-cache localStorage key prefix. The `v1`
|
|
||||||
// segment versions the cached node shape (bump it when SpaceTreeNode changes
|
|
||||||
// incompatibly). Shared by the storage key construction below AND the logout
|
|
||||||
// sweep in clearPersistedTreeCaches() so the two can never drift apart.
|
|
||||||
export const TREE_DATA_KEY_PREFIX = "treeData:v1:";
|
|
||||||
|
|
||||||
// Size guard: skip persisting trees whose JSON exceeds ~4M chars. localStorage
|
|
||||||
// quota is typically ~5 MB per origin; a huge tree must not evict everything
|
|
||||||
// else or spam QuotaExceededError on every debounce tick.
|
|
||||||
const MAX_SERIALIZED_LENGTH = 4_000_000;
|
|
||||||
|
|
||||||
const pendingWrites = new Map<string, SpaceTreeNode[]>();
|
|
||||||
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
let writeFailureWarned = false;
|
|
||||||
|
|
||||||
// Persistence kill-switch, armed by clearPersistedTreeCaches(). Once set, the
|
|
||||||
// debounced setItem and the flush become no-ops so nothing can be written back
|
|
||||||
// to localStorage AFTER the logout sweep: a websocket tree event landing while
|
|
||||||
// `await logout()` is still in flight would otherwise re-queue a write that
|
|
||||||
// the `beforeunload` flush (fired by the redirect) silently resurrects.
|
|
||||||
// Intentionally never reset: every caller of clearPersistedTreeCaches()
|
|
||||||
// immediately navigates away with a full page load
|
|
||||||
// (window.location.replace/href), so this module instance is torn down anyway.
|
|
||||||
// Only PERSISTENCE stops — the in-memory atoms keep working, so the UI stays
|
|
||||||
// intact during the brief pre-redirect window.
|
|
||||||
let persistenceDisabled = false;
|
|
||||||
|
|
||||||
function writeNow(key: string, value: SpaceTreeNode[]): void {
|
|
||||||
try {
|
|
||||||
const serialized = JSON.stringify(value);
|
|
||||||
if (serialized.length > MAX_SERIALIZED_LENGTH) {
|
|
||||||
console.warn("[tree] cached tree too large to persist; skipping", key);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
localStorage.setItem(key, serialized);
|
|
||||||
} catch (err) {
|
|
||||||
// QuotaExceededError, private mode, jsdom shims without working storage…
|
|
||||||
// The cache is best-effort: warn once, keep the in-memory tree working.
|
|
||||||
if (!writeFailureWarned) {
|
|
||||||
writeFailureWarned = true;
|
|
||||||
console.warn("[tree] failed to persist tree cache", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exported so tests can force the debounced write synchronously; production
|
|
||||||
// code must never need it (the beforeunload hook below covers reloads).
|
|
||||||
export function flushPendingTreeDataWrites(): void {
|
|
||||||
if (flushTimer !== null) {
|
|
||||||
clearTimeout(flushTimer);
|
|
||||||
flushTimer = null;
|
|
||||||
}
|
|
||||||
if (persistenceDisabled) {
|
|
||||||
// Belt-and-braces: after logout nothing may reach localStorage, even via
|
|
||||||
// the beforeunload flush racing the redirect. Drop anything queued.
|
|
||||||
pendingWrites.clear();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (const [key, value] of pendingWrites) {
|
|
||||||
writeNow(key, value);
|
|
||||||
}
|
|
||||||
pendingWrites.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Logout hygiene: the tree cache stores PAGE TITLES, so leaving it behind
|
|
||||||
// would keep them readable in localStorage on a shared machine after logout.
|
|
||||||
// Sweep by key prefix (not just the current scope) so stale scopes — old
|
|
||||||
// users, the `anon:anon` fallback — are purged too. Pending debounced writes
|
|
||||||
// are DISCARDED first (not flushed): a queued write firing after the sweep
|
|
||||||
// would silently resurrect a removed key.
|
|
||||||
export function clearPersistedTreeCaches(): void {
|
|
||||||
// Disable persistence FIRST so no write can be queued (or flushed) between
|
|
||||||
// the sweep below and the full-page navigation every caller performs next.
|
|
||||||
persistenceDisabled = true;
|
|
||||||
if (flushTimer !== null) {
|
|
||||||
clearTimeout(flushTimer);
|
|
||||||
flushTimer = null;
|
|
||||||
}
|
|
||||||
pendingWrites.clear();
|
|
||||||
try {
|
|
||||||
// Collect matching keys BEFORE removing: deleting while iterating
|
|
||||||
// `localStorage.key(i)` shifts the indices and skips entries.
|
|
||||||
const keysToRemove: string[] = [];
|
|
||||||
for (let i = 0; i < localStorage.length; i++) {
|
|
||||||
const key = localStorage.key(i);
|
|
||||||
if (
|
|
||||||
key !== null &&
|
|
||||||
(key.startsWith(TREE_DATA_KEY_PREFIX) ||
|
|
||||||
key.startsWith(OPEN_TREE_NODES_KEY_PREFIX))
|
|
||||||
) {
|
|
||||||
keysToRemove.push(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const key of keysToRemove) {
|
|
||||||
localStorage.removeItem(key);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Best-effort: disabled storage / jsdom shims must never break logout.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Flush the pending debounced write on unload so a reload right after a tree
|
|
||||||
// change doesn't lose the newest state (the debounce would otherwise eat it).
|
|
||||||
if (
|
|
||||||
typeof window !== "undefined" &&
|
|
||||||
typeof window.addEventListener === "function"
|
|
||||||
) {
|
|
||||||
window.addEventListener("beforeunload", flushPendingTreeDataWrites);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Custom sync storage for the tree cache. Deliberately NO `subscribe` key:
|
|
||||||
// cross-tab sync would REPLACE this tab's tree wholesale and clobber in-flight
|
|
||||||
// lazy loads; websockets already keep every open tab live. Each tab keeps its
|
|
||||||
// own in-memory tree — localStorage only seeds the next boot.
|
|
||||||
const treeDataStorage = {
|
|
||||||
getItem: (key: string, initialValue: SpaceTreeNode[]): SpaceTreeNode[] => {
|
|
||||||
// Defensive: jsdom test shims may lack methods, stored JSON may be
|
|
||||||
// corrupted or of a wrong shape. Any failure falls back to the empty tree.
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(key);
|
|
||||||
if (raw === null) return initialValue;
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
return Array.isArray(parsed) ? (parsed as SpaceTreeNode[]) : initialValue;
|
|
||||||
} catch {
|
|
||||||
return initialValue;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
setItem: (key: string, newValue: SpaceTreeNode[]): void => {
|
|
||||||
// After logout the cache must stay purged: neither queue the write nor arm
|
|
||||||
// a new flush timer (see persistenceDisabled above). The in-memory atom
|
|
||||||
// value is unaffected — only the localStorage mirror is frozen.
|
|
||||||
if (persistenceDisabled) return;
|
|
||||||
pendingWrites.set(key, newValue);
|
|
||||||
if (flushTimer !== null) clearTimeout(flushTimer);
|
|
||||||
flushTimer = setTimeout(flushPendingTreeDataWrites, WRITE_DEBOUNCE_MS);
|
|
||||||
},
|
|
||||||
removeItem: (key: string): void => {
|
|
||||||
pendingWrites.delete(key);
|
|
||||||
try {
|
|
||||||
localStorage.removeItem(key);
|
|
||||||
} catch {
|
|
||||||
/* best-effort cache — ignore */
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// One persisted tree per (workspace, user) — same scoping rationale as the
|
|
||||||
// open-map atom (accounts sharing a browser origin must not leak trees).
|
|
||||||
// `getOnInit: true` reads localStorage synchronously at atom init, so the very
|
|
||||||
// first render already has the cached tree — no blank-then-jump sidebar.
|
|
||||||
const treeDataFamily = atomFamily((scopeKey: string) =>
|
|
||||||
atomWithStorage<SpaceTreeNode[]>(
|
|
||||||
`${TREE_DATA_KEY_PREFIX}${scopeKey}`,
|
|
||||||
[],
|
|
||||||
treeDataStorage,
|
|
||||||
{ getOnInit: true },
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Public facade — same read value (SpaceTreeNode[]) and same setter shape
|
|
||||||
// (value OR functional updater) as the previous in-memory atom, transparently
|
|
||||||
// routed to the persisted tree of the current workspace/user.
|
|
||||||
export const treeDataAtom = atom(
|
|
||||||
(get) => get(treeDataFamily(get(scopeKeyAtom))),
|
|
||||||
(
|
|
||||||
get,
|
|
||||||
set,
|
|
||||||
update: SpaceTreeNode[] | ((prev: SpaceTreeNode[]) => SpaceTreeNode[]),
|
|
||||||
) => {
|
|
||||||
const target = treeDataFamily(get(scopeKeyAtom));
|
|
||||||
const next =
|
|
||||||
typeof update === "function"
|
|
||||||
? (update as (prev: SpaceTreeNode[]) => SpaceTreeNode[])(get(target))
|
|
||||||
: update;
|
|
||||||
set(target, next);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Atom
|
// Atom
|
||||||
export const appendNodeChildrenAtom = atom(
|
export const appendNodeChildrenAtom = atom(
|
||||||
|
|||||||
@@ -71,8 +71,7 @@ vi.mock("@mantine/core", () => ({
|
|||||||
// getOnInit), which crashes under jsdom's localStorage shim here. Swap in a
|
// getOnInit), which crashes under jsdom's localStorage shim here. Swap in a
|
||||||
// plain in-memory atom with the same read value (OpenMap) and the same setter
|
// plain in-memory atom with the same read value (OpenMap) and the same setter
|
||||||
// shape (value OR functional updater) so the component's open-state logic runs
|
// shape (value OR functional updater) so the component's open-state logic runs
|
||||||
// unchanged while staying inside the test store. `scopeKeyAtom` is also
|
// unchanged while staying inside the test store.
|
||||||
// re-exported (the real module exports it for the persisted tree-data atom).
|
|
||||||
vi.mock("@/features/page/tree/atoms/open-tree-nodes-atom.ts", async () => {
|
vi.mock("@/features/page/tree/atoms/open-tree-nodes-atom.ts", async () => {
|
||||||
const { atom } = await import("jotai");
|
const { atom } = await import("jotai");
|
||||||
type OpenMap = Record<string, boolean>;
|
type OpenMap = Record<string, boolean>;
|
||||||
@@ -87,17 +86,11 @@ vi.mock("@/features/page/tree/atoms/open-tree-nodes-atom.ts", async () => {
|
|||||||
set(base, next);
|
set(base, next);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
// Fixed scope key: the tree-data atom family resolves through this, so all
|
return { openTreeNodesAtom };
|
||||||
// tests read/write the same (empty at start of each test) storage key.
|
|
||||||
const scopeKeyAtom = atom(() => "test-workspace:test-user");
|
|
||||||
return { openTreeNodesAtom, scopeKeyAtom };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
import SpaceTree, { SpaceTreeApi } from "./space-tree";
|
import SpaceTree, { SpaceTreeApi } from "./space-tree";
|
||||||
import {
|
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||||
treeDataAtom,
|
|
||||||
flushPendingTreeDataWrites,
|
|
||||||
} from "@/features/page/tree/atoms/tree-data-atom.ts";
|
|
||||||
import { openTreeNodesAtom } from "@/features/page/tree/atoms/open-tree-nodes-atom.ts";
|
import { openTreeNodesAtom } from "@/features/page/tree/atoms/open-tree-nodes-atom.ts";
|
||||||
import { createStore, Provider } from "jotai";
|
import { createStore, Provider } from "jotai";
|
||||||
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||||
@@ -141,10 +134,6 @@ function renderTree(store: ReturnType<typeof createStore>) {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
getSpaceTreeMock.mockReset();
|
getSpaceTreeMock.mockReset();
|
||||||
notificationsShowMock.mockReset();
|
notificationsShowMock.mockReset();
|
||||||
// The tree-data atom persists via a ~500 ms trailing debounce; flush it NOW
|
|
||||||
// (cancelling the timer) so a previous test's pending write can't land in
|
|
||||||
// storage mid-test after the clear below.
|
|
||||||
flushPendingTreeDataWrites();
|
|
||||||
// jsdom's localStorage shim here lacks `clear`; guard it. Each test uses a
|
// jsdom's localStorage shim here lacks `clear`; guard it. Each test uses a
|
||||||
// fresh jotai store anyway, so cross-test open-state never leaks.
|
// fresh jotai store anyway, so cross-test open-state never leaks.
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -199,66 +199,45 @@ const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
|
|||||||
const openIdsRef = useRef(openIds);
|
const openIdsRef = useRef(openIds);
|
||||||
openIdsRef.current = openIds;
|
openIdsRef.current = openIds;
|
||||||
|
|
||||||
// Re-fetch and reconcile the children of every currently-open, already-loaded
|
// Reconnect refresh (#159 #8): on a socket reconnect, re-fetch and reconcile
|
||||||
// branch of THIS space. Shared by the socket reconnect handler and the
|
// the children of every currently-open, already-loaded branch of THIS space,
|
||||||
// post-load cache refresh below. The ROOT level is reconciled separately by
|
|
||||||
// the root-query refetch + mergeRootTrees; an UNLOADED branch is skipped
|
|
||||||
// (lazy-load fetches it fresh on expand). Reads refs so it always sees the
|
|
||||||
// latest tree/open-state/space without re-creating the callback.
|
|
||||||
const refreshOpenBranches = useCallback(async () => {
|
|
||||||
const effectSpaceId = spaceIdRef.current;
|
|
||||||
const branchIds = loadedOpenBranchIds(
|
|
||||||
dataRef.current.filter((n) => n?.spaceId === effectSpaceId),
|
|
||||||
openIdsRef.current,
|
|
||||||
);
|
|
||||||
if (branchIds.length === 0) return;
|
|
||||||
for (const id of branchIds) {
|
|
||||||
try {
|
|
||||||
// `fresh: true` bypasses the 30-min sidebar-pages cache so the
|
|
||||||
// reconcile sees the server's CURRENT children (handler-order
|
|
||||||
// independent — no reliance on the global reconnect invalidation).
|
|
||||||
const fresh = await fetchAllAncestorChildren(
|
|
||||||
{ pageId: id, spaceId: effectSpaceId },
|
|
||||||
{ fresh: true },
|
|
||||||
);
|
|
||||||
if (spaceIdRef.current !== effectSpaceId) return; // space switched
|
|
||||||
setData((prev) => treeModel.reconcileChildren(prev, id, fresh));
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[tree] open branch refresh failed", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [setData]);
|
|
||||||
|
|
||||||
// Reconnect refresh (#159 #8): on a socket reconnect, refresh open branches
|
|
||||||
// so a move/rename/delete that happened INSIDE a loaded branch while events
|
// so a move/rename/delete that happened INSIDE a loaded branch while events
|
||||||
// were missed (laptop sleep / wifi gap) is reflected instead of left stale.
|
// were missed (laptop sleep / wifi gap) is reflected instead of left stale.
|
||||||
// No first-connect guard is needed: space-tree usually mounts AFTER the
|
// The ROOT level is reconciled separately by the root-query refetch +
|
||||||
// initial connect, so every `connect` it sees is a reconnect; the rare
|
// mergeRootTrees; an UNLOADED branch is skipped (lazy-load fetches it fresh on
|
||||||
|
// expand). No first-connect guard is needed: space-tree usually mounts AFTER
|
||||||
|
// the initial connect, so every `connect` it sees is a reconnect; the rare
|
||||||
// initial-connect case has an empty tree, so the refresh is a harmless no-op.
|
// initial-connect case has an empty tree, so the refresh is a harmless no-op.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!socket) return;
|
if (!socket) return;
|
||||||
const onConnect = () => {
|
const onConnect = async () => {
|
||||||
refreshOpenBranches();
|
const effectSpaceId = spaceIdRef.current;
|
||||||
|
const branchIds = loadedOpenBranchIds(
|
||||||
|
dataRef.current.filter((n) => n?.spaceId === effectSpaceId),
|
||||||
|
openIdsRef.current,
|
||||||
|
);
|
||||||
|
if (branchIds.length === 0) return;
|
||||||
|
for (const id of branchIds) {
|
||||||
|
try {
|
||||||
|
// `fresh: true` bypasses the 30-min sidebar-pages cache so the
|
||||||
|
// reconcile sees the server's CURRENT children (handler-order
|
||||||
|
// independent — no reliance on the global reconnect invalidation).
|
||||||
|
const fresh = await fetchAllAncestorChildren(
|
||||||
|
{ pageId: id, spaceId: effectSpaceId },
|
||||||
|
{ fresh: true },
|
||||||
|
);
|
||||||
|
if (spaceIdRef.current !== effectSpaceId) return; // space switched
|
||||||
|
setData((prev) => treeModel.reconcileChildren(prev, id, fresh));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[tree] reconnect branch refresh failed", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
socket.on("connect", onConnect);
|
socket.on("connect", onConnect);
|
||||||
return () => {
|
return () => {
|
||||||
socket.off("connect", onConnect);
|
socket.off("connect", onConnect);
|
||||||
};
|
};
|
||||||
}, [socket, refreshOpenBranches]);
|
}, [socket, setData]);
|
||||||
|
|
||||||
// Post-load cache refresh: the sidebar paints instantly from the
|
|
||||||
// localStorage-cached tree, so children of open branches may be stale. Once
|
|
||||||
// the server root set has been merged for this space (isDataLoaded flips
|
|
||||||
// true), refresh every open, already-loaded branch ONCE per space per mount.
|
|
||||||
// dataRef.current is already up to date here: refs are assigned during
|
|
||||||
// render, and this effect runs after the merge-triggered re-render commit.
|
|
||||||
const refreshedSpacesRef = useRef<Set<string>>(new Set());
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isDataLoaded) return;
|
|
||||||
if (refreshedSpacesRef.current.has(spaceId)) return;
|
|
||||||
refreshedSpacesRef.current.add(spaceId);
|
|
||||||
refreshOpenBranches();
|
|
||||||
}, [isDataLoaded, spaceId, refreshOpenBranches]);
|
|
||||||
|
|
||||||
const handleToggle = useCallback(
|
const handleToggle = useCallback(
|
||||||
async (id: string, isOpen: boolean) => {
|
async (id: string, isOpen: boolean) => {
|
||||||
@@ -354,17 +333,12 @@ const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classes.treeContainer}>
|
<div className={classes.treeContainer}>
|
||||||
{/* "No pages yet" only after the SERVER confirmed the space is empty —
|
|
||||||
never while just the localStorage cache is empty. */}
|
|
||||||
{isDataLoaded && filteredData.length === 0 && (
|
{isDataLoaded && filteredData.length === 0 && (
|
||||||
<Text size="xs" c="dimmed" py="xs" px="sm">
|
<Text size="xs" c="dimmed" py="xs" px="sm">
|
||||||
{t("No pages yet")}
|
{t("No pages yet")}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{/* Cache-first paint: render as soon as ANY data exists (synchronous
|
{isDataLoaded && filteredData.length > 0 && (
|
||||||
localStorage hydration) instead of waiting for the server round-trip;
|
|
||||||
the background merge/refresh reconciles it afterwards. */}
|
|
||||||
{filteredData.length > 0 && (
|
|
||||||
<DocTree<SpaceTreeNode>
|
<DocTree<SpaceTreeNode>
|
||||||
data={filteredData}
|
data={filteredData}
|
||||||
openIds={openIds}
|
openIds={openIds}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import axios, { AxiosInstance } from "axios";
|
import axios, { AxiosInstance } from "axios";
|
||||||
import APP_ROUTE from "@/lib/app-route.ts";
|
import APP_ROUTE from "@/lib/app-route.ts";
|
||||||
import { isCloud } from "@/lib/config.ts";
|
import { isCloud } from "@/lib/config.ts";
|
||||||
import { clearPersistedTreeCaches } from "@/features/page/tree/atoms/tree-data-atom";
|
|
||||||
|
|
||||||
const api: AxiosInstance = axios.create({
|
const api: AxiosInstance = axios.create({
|
||||||
baseURL: "/api",
|
baseURL: "/api",
|
||||||
@@ -72,12 +71,6 @@ function redirectToLogin() {
|
|||||||
"/invites",
|
"/invites",
|
||||||
];
|
];
|
||||||
if (!exemptPaths.some((path) => window.location.pathname.startsWith(path))) {
|
if (!exemptPaths.some((path) => window.location.pathname.startsWith(path))) {
|
||||||
// Forced logout (401 / expired session) must purge the persisted sidebar
|
|
||||||
// tree caches too: they contain page titles, and on a shared machine most
|
|
||||||
// sessions end via cookie expiry — not the logout button — so this is the
|
|
||||||
// only cleanup that runs on that path. It also disables further cache
|
|
||||||
// persistence until the full page load below.
|
|
||||||
clearPersistedTreeCaches();
|
|
||||||
const redirectTo = window.location.pathname;
|
const redirectTo = window.location.pathname;
|
||||||
if (redirectTo === APP_ROUTE.HOME) {
|
if (redirectTo === APP_ROUTE.HOME) {
|
||||||
window.location.href = APP_ROUTE.AUTH.LOGIN;
|
window.location.href = APP_ROUTE.AUTH.LOGIN;
|
||||||
|
|||||||
@@ -303,6 +303,11 @@ describe('buildSystemPrompt page-changed note (#274)', () => {
|
|||||||
expect(prompt).toContain(NOTE_MARKER);
|
expect(prompt).toContain(NOTE_MARKER);
|
||||||
expect(prompt).toContain('-old line');
|
expect(prompt).toContain('-old line');
|
||||||
expect(prompt).toContain('+new line');
|
expect(prompt).toContain('+new line');
|
||||||
|
// Strengthened note (#274): instructs a fresh re-read via getPage and steers
|
||||||
|
// the agent toward small, targeted edits instead of a full-page overwrite.
|
||||||
|
expect(prompt).toContain('getPage');
|
||||||
|
expect(prompt.toLowerCase()).toContain('targeted');
|
||||||
|
expect(prompt).toContain('editPageText');
|
||||||
// Inside the safety sandwich: the trailing SAFETY block follows the note.
|
// Inside the safety sandwich: the trailing SAFETY block follows the note.
|
||||||
expect(prompt.lastIndexOf(SAFETY_MARKER)).toBeGreaterThan(
|
expect(prompt.lastIndexOf(SAFETY_MARKER)).toBeGreaterThan(
|
||||||
prompt.indexOf(NOTE_MARKER),
|
prompt.indexOf(NOTE_MARKER),
|
||||||
|
|||||||
@@ -85,11 +85,17 @@ const INTERRUPT_NOTE =
|
|||||||
const PAGE_CHANGED_NOTE =
|
const PAGE_CHANGED_NOTE =
|
||||||
'NOTE: The user edited the open page AFTER your last response in this ' +
|
'NOTE: The user edited the open page AFTER your last response in this ' +
|
||||||
'conversation, so any copy of that page you produced or remember from earlier ' +
|
'conversation, so any copy of that page you produced or remember from earlier ' +
|
||||||
'is now STALE. The unified diff below shows exactly what changed since you last ' +
|
'is now STALE and must not be reused. Before you edit the page, you MUST first ' +
|
||||||
'spoke (lines starting with "-" were removed, "+" were added) and is the source ' +
|
're-read its current content with the getPage tool and base your work on that ' +
|
||||||
'of truth. Preserve the user\'s edits: build on the current page, do not revert ' +
|
'live version — never on your earlier copy or on the transcript. The unified ' +
|
||||||
'or overwrite their changes. If you need the full up-to-date page, re-read it ' +
|
'diff below shows exactly what the user changed since you last spoke (lines ' +
|
||||||
'with the getPage tool before editing.';
|
'starting with "-" were removed, "+" were added) and is the source of truth. ' +
|
||||||
|
'Preserve every one of the user\'s edits: make the smallest change that ' +
|
||||||
|
'satisfies the request using the targeted edit tools (editPageText, patchNode, ' +
|
||||||
|
'insertNode, deleteNode) rather than replacing the whole page, and do not ' +
|
||||||
|
'revert, drop, or overwrite anything the user changed. If a full rewrite is ' +
|
||||||
|
'truly unavoidable, start from the current getPage content and carry over all ' +
|
||||||
|
'of the user\'s edits.';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sanitize a value interpolated into a prompt XML-ish attribute (e.g.
|
* Sanitize a value interpolated into a prompt XML-ish attribute (e.g.
|
||||||
|
|||||||
@@ -356,6 +356,32 @@ describe('flushAssistant', () => {
|
|||||||
expect(flushed.toolCalls).not.toBeNull();
|
expect(flushed.toolCalls).not.toBeNull();
|
||||||
expect(flushed.metadata.error).toBe('boom');
|
expect(flushed.metadata.error).toBe('boom');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #274 observability: the page-change diff the agent saw this turn is persisted
|
||||||
|
// to metadata.pageChanged when a non-empty diff was injected, and omitted when
|
||||||
|
// the diff is empty/whitespace or the arg is not supplied.
|
||||||
|
it('persists metadata.pageChanged when a non-empty diff was injected', () => {
|
||||||
|
const f = flushAssistant([], '', 'completed', {
|
||||||
|
pageChanged: { title: 'Doc', diff: '@@ -1 +1 @@\n-old\n+new' },
|
||||||
|
});
|
||||||
|
expect(f.metadata.pageChanged).toEqual({
|
||||||
|
title: 'Doc',
|
||||||
|
diff: '@@ -1 +1 @@\n-old\n+new',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits metadata.pageChanged for an empty/whitespace diff or a missing arg', () => {
|
||||||
|
const whitespace = flushAssistant([], '', 'completed', {
|
||||||
|
pageChanged: { title: 'Doc', diff: ' \n ' },
|
||||||
|
});
|
||||||
|
expect('pageChanged' in whitespace.metadata).toBe(false);
|
||||||
|
|
||||||
|
const nullArg = flushAssistant([], '', 'completed', { pageChanged: null });
|
||||||
|
expect('pageChanged' in nullArg.metadata).toBe(false);
|
||||||
|
|
||||||
|
const omitted = flushAssistant([], '', 'streaming');
|
||||||
|
expect('pageChanged' in omitted.metadata).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -685,7 +685,7 @@ export class AiChatService implements OnModuleInit {
|
|||||||
// no-op (guarded below) so the turn still streams to the user.
|
// no-op (guarded below) so the turn still streams to the user.
|
||||||
let assistantId: string | undefined;
|
let assistantId: string | undefined;
|
||||||
try {
|
try {
|
||||||
const seed = flushAssistant([], '', 'streaming');
|
const seed = flushAssistant([], '', 'streaming', { pageChanged });
|
||||||
const seeded = await this.aiChatMessageRepo.insert({
|
const seeded = await this.aiChatMessageRepo.insert({
|
||||||
chatId,
|
chatId,
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
@@ -720,7 +720,7 @@ export class AiChatService implements OnModuleInit {
|
|||||||
await this.aiChatMessageRepo.update(
|
await this.aiChatMessageRepo.update(
|
||||||
assistantId,
|
assistantId,
|
||||||
workspace.id,
|
workspace.id,
|
||||||
flushAssistant(capturedSteps, '', 'streaming'),
|
flushAssistant(capturedSteps, '', 'streaming', { pageChanged }),
|
||||||
{ onlyIfStreaming: true },
|
{ onlyIfStreaming: true },
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -860,6 +860,7 @@ export class AiChatService implements OnModuleInit {
|
|||||||
// resolved from the admin-configured provider settings (in
|
// resolved from the admin-configured provider settings (in
|
||||||
// closure scope here). Omitted/0 = no limit.
|
// closure scope here). Omitted/0 = no limit.
|
||||||
maxContextTokens: resolved?.chatContextWindow,
|
maxContextTokens: resolved?.chatContextWindow,
|
||||||
|
pageChanged,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
// Lifecycle: release the external MCP clients leased for this turn.
|
// Lifecycle: release the external MCP clients leased for this turn.
|
||||||
@@ -911,6 +912,7 @@ export class AiChatService implements OnModuleInit {
|
|||||||
await finalizeAssistant(
|
await finalizeAssistant(
|
||||||
flushAssistant(capturedSteps, inProgressText, 'error', {
|
flushAssistant(capturedSteps, inProgressText, 'error', {
|
||||||
error: errorText,
|
error: errorText,
|
||||||
|
pageChanged,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await closeExternalClients();
|
await closeExternalClients();
|
||||||
@@ -940,7 +942,9 @@ export class AiChatService implements OnModuleInit {
|
|||||||
`steps=${steps.length}`,
|
`steps=${steps.length}`,
|
||||||
);
|
);
|
||||||
await finalizeAssistant(
|
await finalizeAssistant(
|
||||||
flushAssistant(capturedSteps, inProgressText, 'aborted'),
|
flushAssistant(capturedSteps, inProgressText, 'aborted', {
|
||||||
|
pageChanged,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
await closeExternalClients();
|
await closeExternalClients();
|
||||||
// Advance the page snapshot even on abort (#274): an agent edit that
|
// Advance the page snapshot even on abort (#274): an agent edit that
|
||||||
@@ -1506,6 +1510,7 @@ export function flushAssistant(
|
|||||||
contextTokens?: number;
|
contextTokens?: number;
|
||||||
maxContextTokens?: number;
|
maxContextTokens?: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
pageChanged?: { title: string; diff: string } | null;
|
||||||
},
|
},
|
||||||
): AssistantFlush {
|
): AssistantFlush {
|
||||||
const finished = capturedSteps ?? [];
|
const finished = capturedSteps ?? [];
|
||||||
@@ -1538,6 +1543,15 @@ export function flushAssistant(
|
|||||||
if (extra?.maxContextTokens)
|
if (extra?.maxContextTokens)
|
||||||
metadata.maxContextTokens = extra.maxContextTokens;
|
metadata.maxContextTokens = extra.maxContextTokens;
|
||||||
if (extra?.error) metadata.error = extra.error;
|
if (extra?.error) metadata.error = extra.error;
|
||||||
|
// Persist the page-change diff the agent saw this turn (#274 observability),
|
||||||
|
// so history / the Markdown export can show what the user changed. Only when
|
||||||
|
// a non-empty diff was actually injected into the prompt this turn.
|
||||||
|
if (extra?.pageChanged && extra.pageChanged.diff?.trim().length) {
|
||||||
|
metadata.pageChanged = {
|
||||||
|
title: extra.pageChanged.title,
|
||||||
|
diff: extra.pageChanged.diff,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: stepsText + trailing,
|
content: stepsText + trailing,
|
||||||
|
|||||||
@@ -269,6 +269,168 @@ describe('buildChatMarkdown (server) — structure', () => {
|
|||||||
expect(md).toContain('**⚠️ Error:** 401: Unauthorized');
|
expect(md).toContain('**⚠️ Error:** 401: Unauthorized');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #274 observability: an assistant row whose turn started with a user edit to
|
||||||
|
// the open page carries metadata.pageChanged = { title, diff }; the export
|
||||||
|
// renders the diff the agent saw, before the message body.
|
||||||
|
it('renders the persisted page-change diff block for an assistant row', () => {
|
||||||
|
const md = buildChatMarkdown({
|
||||||
|
title: 'T',
|
||||||
|
chatId: 'c',
|
||||||
|
rows: [
|
||||||
|
row({
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'answer',
|
||||||
|
metadata: {
|
||||||
|
pageChanged: { title: 'Doc', diff: '@@ -1 +1 @@\n-old\n+new' },
|
||||||
|
} as never,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(md).toContain(
|
||||||
|
'The user edited this page before this turn; the diff the agent saw:',
|
||||||
|
);
|
||||||
|
expect(md).toContain('("Doc")');
|
||||||
|
expect(md).toContain('-old');
|
||||||
|
expect(md).toContain('+new');
|
||||||
|
// The diff sits before the message body (chronological: change, then reply).
|
||||||
|
expect(md.indexOf('-old')).toBeLessThan(md.indexOf('answer'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render the page-change block when metadata.pageChanged is absent', () => {
|
||||||
|
const md = buildChatMarkdown({
|
||||||
|
title: 'T',
|
||||||
|
chatId: 'c',
|
||||||
|
rows: [row({ role: 'assistant', content: 'answer' })],
|
||||||
|
});
|
||||||
|
expect(md).not.toContain(
|
||||||
|
'The user edited this page before this turn; the diff the agent saw:',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// #288 F1/F2: an empty page title must render the BARE heading with no
|
||||||
|
// `("…")` suffix (the `pc.title ? … : …` false branch).
|
||||||
|
it('renders the page-change heading with no title suffix when title is empty', () => {
|
||||||
|
const md = buildChatMarkdown({
|
||||||
|
title: 'T',
|
||||||
|
chatId: 'c',
|
||||||
|
rows: [
|
||||||
|
row({
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'answer',
|
||||||
|
metadata: {
|
||||||
|
pageChanged: { title: '', diff: '@@ -1 +1 @@\n-old\n+new' },
|
||||||
|
} as never,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
// Bare heading, single line, no parenthesized title.
|
||||||
|
expect(md).toContain(
|
||||||
|
'> **📝 The user edited this page before this turn; the diff the agent saw:**',
|
||||||
|
);
|
||||||
|
expect(md).not.toContain('("');
|
||||||
|
expect(md).toContain('-old');
|
||||||
|
});
|
||||||
|
|
||||||
|
// #288 F1: the page title is UNTRUSTED cross-user data, so a title carrying a
|
||||||
|
// newline / backtick / `"` / `<`/`>` must be neutralized by escapeAttr before
|
||||||
|
// it is interpolated into the `> **…**` blockquote heading — otherwise it
|
||||||
|
// could break the blockquote onto multiple lines or inject markup/HTML into
|
||||||
|
// the downloaded .md. escapeAttr strips `<>"` and collapses whitespace runs to
|
||||||
|
// a single space, so `Ev"il\n> `x` <b>` becomes ``Evil `x` b``.
|
||||||
|
it('escapes an untrusted page title in the page-change heading', () => {
|
||||||
|
const md = buildChatMarkdown({
|
||||||
|
title: 'T',
|
||||||
|
chatId: 'c',
|
||||||
|
rows: [
|
||||||
|
row({
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'answer',
|
||||||
|
metadata: {
|
||||||
|
pageChanged: {
|
||||||
|
title: 'Ev"il\n> `x` <b>',
|
||||||
|
diff: '@@ -1 +1 @@\n-old\n+new',
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
// The heading stays a single blockquote line with the escaped title.
|
||||||
|
expect(md).toContain(
|
||||||
|
'> **📝 The user edited this page before this turn; the diff the agent saw: ("Evil `x` b")**',
|
||||||
|
);
|
||||||
|
// No raw attribute/markup breakers survived from the title.
|
||||||
|
expect(md).not.toContain('Ev"il');
|
||||||
|
expect(md).not.toContain('<b>');
|
||||||
|
});
|
||||||
|
|
||||||
|
// #288 review F1: escapeAttr ALONE is insufficient for this MARKDOWN sink —
|
||||||
|
// link/image syntax survives it. A cross-user title with `` /
|
||||||
|
// `[phish](url)` must NOT become a working remote image or clickable link in
|
||||||
|
// the downloaded .md; markdownHeadingSafe backslash-escapes `[`/`]` so both are
|
||||||
|
// inert. (Non-vacuous: fails against the escapeAttr-only version, which left
|
||||||
|
// `](https://` intact.)
|
||||||
|
it('neutralizes markdown link/image syntax in an untrusted page title', () => {
|
||||||
|
const md = buildChatMarkdown({
|
||||||
|
title: 'T',
|
||||||
|
chatId: 'c',
|
||||||
|
rows: [
|
||||||
|
row({
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'answer',
|
||||||
|
metadata: {
|
||||||
|
pageChanged: {
|
||||||
|
title:
|
||||||
|
' and [click](https://phish.example)',
|
||||||
|
diff: '@@ -1 +1 @@\n-old\n+new',
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
// No WORKING image/link syntax survives — the `[…]` sits escaped as `\[…\]`,
|
||||||
|
// so the unescaped ``: after escaping the
|
||||||
|
// literal `\](https://` still contains `](https://` as a raw substring — that
|
||||||
|
// check would false-fail even though the link is inert.)
|
||||||
|
expect(md).not.toContain(';
|
||||||
|
expect(md).not.toContain('[click](');
|
||||||
|
// The brackets are backslash-escaped, so `[text](url)`/`` are inert.
|
||||||
|
expect(md).toContain('\\[');
|
||||||
|
expect(md).toContain('\\]');
|
||||||
|
// The heading stays a SINGLE blockquote line (no newline injected).
|
||||||
|
const headingLine = md
|
||||||
|
.split('\n')
|
||||||
|
.find((l) => l.includes('the diff the agent saw:'));
|
||||||
|
expect(headingLine).toBeDefined();
|
||||||
|
expect(headingLine).toContain('\\[x\\]');
|
||||||
|
expect(headingLine).toContain('\\[click\\]');
|
||||||
|
});
|
||||||
|
|
||||||
|
// #288 internal review Finding 2: a NON-empty title made up entirely of
|
||||||
|
// escapeAttr breakers (`<>"`) escapes to '' — the ternary must then fall to the
|
||||||
|
// BARE heading with NO `("…")` suffix. Locks the ternary-on-escaped-value
|
||||||
|
// behavior (distinct from the empty-string input test above).
|
||||||
|
it('renders the bare heading for a title that escapes to empty', () => {
|
||||||
|
const md = buildChatMarkdown({
|
||||||
|
title: 'T',
|
||||||
|
chatId: 'c',
|
||||||
|
rows: [
|
||||||
|
row({
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'answer',
|
||||||
|
metadata: {
|
||||||
|
pageChanged: { title: '<>"', diff: '@@ -1 +1 @@\n-old\n+new' },
|
||||||
|
} as never,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(md).toContain(
|
||||||
|
'> **📝 The user edited this page before this turn; the diff the agent saw:**',
|
||||||
|
);
|
||||||
|
expect(md).not.toContain('("');
|
||||||
|
expect(md).toContain('-old');
|
||||||
|
});
|
||||||
|
|
||||||
it('escapes embedded triple-backtick fences with a longer delimiter', () => {
|
it('escapes embedded triple-backtick fences with a longer delimiter', () => {
|
||||||
const md = buildChatMarkdown({
|
const md = buildChatMarkdown({
|
||||||
title: 'T',
|
title: 'T',
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { AiChatMessage } from '@docmost/db/types/entity.types';
|
import type { AiChatMessage } from '@docmost/db/types/entity.types';
|
||||||
|
import { escapeAttr } from './ai-chat.prompt';
|
||||||
|
|
||||||
/** Supported export label languages. Defaults to English. */
|
/** Supported export label languages. Defaults to English. */
|
||||||
export type ExportLang = 'en' | 'ru';
|
export type ExportLang = 'en' | 'ru';
|
||||||
@@ -63,6 +64,7 @@ const LABELS: Record<
|
|||||||
tools: Record<string, string>;
|
tools: Record<string, string>;
|
||||||
ranTool: (name: string) => string;
|
ranTool: (name: string) => string;
|
||||||
stillGenerating: string;
|
stillGenerating: string;
|
||||||
|
pageEditedByUser: string;
|
||||||
}
|
}
|
||||||
> = {
|
> = {
|
||||||
en: {
|
en: {
|
||||||
@@ -83,6 +85,8 @@ const LABELS: Record<
|
|||||||
ranTool: (name) => `Ran tool ${name}`,
|
ranTool: (name) => `Ran tool ${name}`,
|
||||||
stillGenerating:
|
stillGenerating:
|
||||||
'This message is still being generated — the export captured a partial, in-progress response.',
|
'This message is still being generated — the export captured a partial, in-progress response.',
|
||||||
|
pageEditedByUser:
|
||||||
|
'The user edited this page before this turn; the diff the agent saw:',
|
||||||
},
|
},
|
||||||
ru: {
|
ru: {
|
||||||
untitled: 'Без названия',
|
untitled: 'Без названия',
|
||||||
@@ -102,9 +106,29 @@ const LABELS: Record<
|
|||||||
ranTool: (name) => `Выполнил инструмент ${name}`,
|
ranTool: (name) => `Выполнил инструмент ${name}`,
|
||||||
stillGenerating:
|
stillGenerating:
|
||||||
'Это сообщение всё ещё генерируется — экспорт захватил частичный, незавершённый ответ.',
|
'Это сообщение всё ещё генерируется — экспорт захватил частичный, незавершённый ответ.',
|
||||||
|
pageEditedByUser:
|
||||||
|
'Пользователь изменил страницу перед этим ходом; дифф, который видел агент:',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make an untrusted title safe to interpolate into a Markdown blockquote
|
||||||
|
* HEADING. escapeAttr() neutralizes the XML/HTML breakers (`<` `>` `"`) and
|
||||||
|
* collapses whitespace for the PROMPT sink (`page="…"`), but this export sink is
|
||||||
|
* MARKDOWN — link/image syntax survives escapeAttr. So additionally backslash-
|
||||||
|
* escape `[` and `]`: that disables both `[text](url)` links and ``
|
||||||
|
* images, so a cross-user title like `` or `[phish](http://evil)`
|
||||||
|
* cannot inject a remote (auto-loading) image or a clickable link into the
|
||||||
|
* downloaded .md disguised as a trusted system annotation. A bare `(url)` with no
|
||||||
|
* preceding `[]` is inert Markdown, so brackets are the only security-critical
|
||||||
|
* characters here. (We leave backticks to escapeAttr's whitespace pass — a title
|
||||||
|
* shown as inline code cannot escape the blockquote line or load a resource, so
|
||||||
|
* it is not a security concern for this sink.)
|
||||||
|
*/
|
||||||
|
function markdownHeadingSafe(title: string): string {
|
||||||
|
return escapeAttr(title).replace(/[[\]]/g, (m) => `\\${m}`);
|
||||||
|
}
|
||||||
|
|
||||||
/** True for AI SDK tool parts (static `tool-*` or `dynamic-tool`). */
|
/** True for AI SDK tool parts (static `tool-*` or `dynamic-tool`). */
|
||||||
function isToolPart(type: string): boolean {
|
function isToolPart(type: string): boolean {
|
||||||
return type.startsWith('tool-') || type === 'dynamic-tool';
|
return type.startsWith('tool-') || type === 'dynamic-tool';
|
||||||
@@ -208,6 +232,23 @@ function rowParts(row: AiChatMessage): ExportPart[] {
|
|||||||
: [{ type: 'text', text: row.content ?? '' }];
|
: [{ type: 'text', text: row.content ?? '' }];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The persisted page-change diff the agent saw this turn (#274), when any. */
|
||||||
|
function pageChangedOf(
|
||||||
|
row: AiChatMessage,
|
||||||
|
): { title: string; diff: string } | undefined {
|
||||||
|
const meta = (row.metadata ?? {}) as {
|
||||||
|
pageChanged?: { title?: string; diff?: string };
|
||||||
|
};
|
||||||
|
const pc = meta.pageChanged;
|
||||||
|
if (pc && typeof pc.diff === 'string' && pc.diff.trim().length > 0) {
|
||||||
|
return {
|
||||||
|
title: typeof pc.title === 'string' ? pc.title : '',
|
||||||
|
diff: pc.diff,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serialize a chat to a Markdown string from its persisted rows. Source = DB
|
* Serialize a chat to a Markdown string from its persisted rows. Source = DB
|
||||||
* ONLY (no live client state). A row whose `status` is still 'streaming' is an
|
* ONLY (no live client state). A row whose `status` is still 'streaming' is an
|
||||||
@@ -266,6 +307,26 @@ export function buildChatMarkdown(args: {
|
|||||||
blocks.push(`<!-- ${iso} -->`);
|
blocks.push(`<!-- ${iso} -->`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Page-change observability (#274): show the diff the agent saw at the start
|
||||||
|
// of this turn, before its response, so the export reflects the stale-page
|
||||||
|
// warning the model received.
|
||||||
|
const pc = pageChangedOf(row);
|
||||||
|
if (pc) {
|
||||||
|
// The page title is UNTRUSTED cross-user data (a collaborative page's title
|
||||||
|
// controllable by another user). escapeAttr() alone (the prompt sink) is
|
||||||
|
// INSUFFICIENT here: this is a MARKDOWN sink, so we neutralize link/image
|
||||||
|
// syntax too (backslash-escaping `[`/`]`) before interpolating it into this
|
||||||
|
// `> **…**` blockquote heading — otherwise `` / `[phish](url)` would
|
||||||
|
// inject a remote image or clickable link into the downloaded .md. An
|
||||||
|
// all-`<>"` title escapes to empty and correctly falls to the bare heading.
|
||||||
|
// The diff body is already safe via fence(). (#288 review F1.)
|
||||||
|
const safeTitle = markdownHeadingSafe(pc.title);
|
||||||
|
const heading = safeTitle
|
||||||
|
? `${L.pageEditedByUser} ("${safeTitle}")`
|
||||||
|
: L.pageEditedByUser;
|
||||||
|
blocks.push(`> **📝 ${heading}**\n\n${fence(pc.diff, 'diff')}`);
|
||||||
|
}
|
||||||
|
|
||||||
blocks.push(...renderMessageParts(rowParts(row), lang));
|
blocks.push(...renderMessageParts(rowParts(row), lang));
|
||||||
|
|
||||||
// A still-'streaming' row is an interrupted/in-progress turn captured by the
|
// A still-'streaming' row is an interrupted/in-progress turn captured by the
|
||||||
|
|||||||
@@ -449,7 +449,9 @@ export function applyAlignment(container: HTMLElement, align: string) {
|
|||||||
// the next line when the viewport is narrow. The right/bottom padding
|
// the next line when the viewport is narrow. The right/bottom padding
|
||||||
// provides the gap between images in a row and between wrapped rows;
|
// provides the gap between images in a row and between wrapped rows;
|
||||||
// vertical-align: top keeps rows of different-height images aligned by
|
// vertical-align: top keeps rows of different-height images aligned by
|
||||||
// their top edge.
|
// their top edge. Horizontal centering of the whole row is handled by the
|
||||||
|
// client stylesheet (media.css) via a :has() rule on the parent block
|
||||||
|
// container, since the row has no wrapper element of its own.
|
||||||
container.style.display = "inline-block";
|
container.style.display = "inline-block";
|
||||||
container.style.verticalAlign = "top";
|
container.style.verticalAlign = "top";
|
||||||
container.style.padding = "0 10px 10px 0";
|
container.style.padding = "0 10px 10px 0";
|
||||||
|
|||||||
Reference in New Issue
Block a user