Compare commits

..

8 Commits

Author SHA1 Message Date
claude_code 895173b176 Merge branch 'develop' of https://gitea.vvzvlad.xyz/vvzvlad/gitmost into develop 2026-07-02 19:31:05 +03:00
vvzvlad 45d5ae1601 Merge pull request '[feature][ai-chat] Наблюдаемость page_changed-диффа в истории/экспорте + усиление ноты против перезаписи правок' (#288) from feature/ai-chat-page-change-observability into develop
Reviewed-on: #288
2026-07-02 19:30:53 +03:00
claude_code ec30e6c08a docs(agents): update Gitea MCP workflow details in agents guide
Add clarification that pushing commits is git‑native while PR creation uses the Gitea MCP, replace curl/tea examples with MCP method calls, update API table entries, and revise issue creation instructions accordingly.
2026-07-02 19:20:56 +03:00
vvzvlad af481d401a Merge pull request 'feat(editor): center inline image rows by default via CSS :has()' (#295) from image-inline-center into develop 2026-07-02 16:50:32 +03:00
agent_coder 438ef091f9 fix(#288 review): markdown-safe-escape the untrusted page title in chat export
F1: pc.title (untrusted cross-user page title) was interpolated raw into the
markdown export heading. Reusing escapeAttr alone (the prompt sink's XML-attribute
sanitizer, strips < > ") is insufficient here because the sink is MARKDOWN: link
/image syntax survives, so a title like ![x](http://evil) or [phish](http://evil)
injects a remote image / clickable link into the downloaded .md disguised as a
trusted system annotation. Add markdownHeadingSafe() = escapeAttr() + backslash-
escape [ and ] (disables both [text](url) and ![text](url); a bare (url) is inert).
F2: cover the title branch — a title that collapses to empty via escapeAttr falls
to the bare heading (no ("")), and a link/image-injection title is neutralized
(non-vacuous vs the escapeAttr-only version).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 15:46:44 +03:00
claude_code c90caeb21a docs: update changelog and readme with new feature details
Add recent feature entries to CHANGELOG, including inline image row centering, AI chat docking, comment hover tooltips, temporary‑note trash button, code‑block overlay controls, stress‑accent button, reading‑position restore, and slash‑menu layout fixes. Update README and Russian README to reflect these changes.
2026-07-02 14:53:53 +03:00
claude_code 5664da57ad feat(editor): center inline image rows by default via CSS :has()
Follow-up to #284: rows of inline-aligned images were pinned left while
a single image defaults to centered — inconsistent. A row has no DOM
wrapper (each image is an independent block node), so its placement is
controlled by the text-align of the nearest block ancestor.

- media.css: enable text-align:center only on containers that actually
  hold a direct inline-image child (:has), and reset every other child
  back to text-align:start so ordinary text is unaffected; explicit
  per-block toolbar alignment (inline style) still wins; browsers
  without :has() keep the previous start-pinned rows
- image.ts: comment in the inline branch now points to the media.css
  rule (cross-package discoverability), no code change

Reviewed: math/caption/table-header/footnote text-align rules audited;
React node views are wrapped in .react-renderer, so .mathBlock is not a
direct child and keeps its own centering (verified in happy-dom).
2026-07-02 14:51:50 +03:00
claude_code c39fab70c1 feat(ai-chat): persist page-change diff to history and harden stale-page note
The #274 page_changed marker lived only in the ephemeral system prompt, so the
diff the agent saw was invisible in the chat export/history, and the note was
too weak — the agent still overwrote the user's manual edits with a full-page
replace.

- Persist the diff the agent saw as metadata.pageChanged on the assistant row
  (flushAssistant), threaded into all five flush call sites in stream(). Model
  replay (rowToUiMessage/rowParts) reads only metadata.parts, so the sibling
  never re-injects the note into the model context on later turns.
- Render the persisted diff as a labelled block (en/ru) before the message body
  in the server-side Markdown export (chat-markdown.util.ts).
- Strengthen PAGE_CHANGED_NOTE: mandate a fresh getPage re-read and targeted
  edits (editPageText/patchNode/insertNode/deleteNode) instead of a whole-page
  replace, and never revert or overwrite the user's edits.

Tests: prompt, export and service specs updated; 114 pass, tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 14:31:41 +03:00
15 changed files with 442 additions and 221 deletions
+32 -21
View File
@@ -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
View File
@@ -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
+6 -3
View File
@@ -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
View File
@@ -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)
### Скриншоты ### Скриншоты
@@ -100,7 +100,7 @@ describe("useScrollPosition", () => {
expect(window.scrollTo).toHaveBeenCalledWith({ top: 500, behavior: "auto" }); expect(window.scrollTo).toHaveBeenCalledWith({ top: 500, behavior: "auto" });
}); });
it("(a3) is idempotent: re-asserting the same target does not scroll again", () => { it("(a3) restores at most once per mount even if called again", () => {
vi.useFakeTimers(); vi.useFakeTimers();
window.sessionStorage.setItem(`${KEY_PREFIX}once`, "500"); window.sessionStorage.setItem(`${KEY_PREFIX}once`, "500");
setScrollHeight(2000); // tall enough to restore synchronously setScrollHeight(2000); // tall enough to restore synchronously
@@ -111,12 +111,8 @@ describe("useScrollPosition", () => {
}); });
expect(window.scrollTo).toHaveBeenCalledTimes(1); expect(window.scrollTo).toHaveBeenCalledTimes(1);
// Simulate the browser now being at the restored position.
setScrollY(500);
// A second call (e.g. the wiring effect re-running on [showStatic, editor, // A second call (e.g. the wiring effect re-running on [showStatic, editor,
// restoreScrollPosition]) must NOT scroll again: the redundancy guard sees // restoreScrollPosition]) must NOT scroll again and yank the reader.
// the window is already at the target and does nothing.
act(() => { act(() => {
result.current.restoreScrollPosition(); result.current.restoreScrollPosition();
}); });
@@ -166,84 +162,6 @@ describe("useScrollPosition", () => {
expect(window.scrollTo).not.toHaveBeenCalled(); expect(window.scrollTo).not.toHaveBeenCalled();
}); });
it("(g) does not restore if the reader scrolled (wheel) before restore fires", () => {
window.sessionStorage.setItem(`${KEY_PREFIX}g1`, "500");
setScrollHeight(2000); // tall enough to restore synchronously
const { result } = renderHook(() => useScrollPosition("g1"));
// The reader shows scroll intent before restore is triggered.
act(() => {
window.dispatchEvent(new Event("wheel"));
});
act(() => {
result.current.restoreScrollPosition();
});
expect(window.scrollTo).not.toHaveBeenCalled();
});
it("(h) aborts an in-flight restore poll when the reader scrolls", () => {
vi.useFakeTimers();
window.sessionStorage.setItem(`${KEY_PREFIX}h1`, "500");
setInnerHeight(800);
setScrollHeight(100); // maxScroll = -700: target not reachable yet, so it polls.
const { result } = renderHook(() => useScrollPosition("h1"));
act(() => {
result.current.restoreScrollPosition();
});
expect(window.scrollTo).not.toHaveBeenCalled(); // still polling
// The reader takes over mid-poll: this cancels the in-flight poll.
act(() => {
window.dispatchEvent(new Event("wheel"));
});
// Content of the page grows tall enough and time passes: the cancelled poll
// must NOT resurrect and yank the reader.
setScrollHeight(2000);
act(() => {
vi.advanceTimersByTime(5000);
});
expect(window.scrollTo).not.toHaveBeenCalled();
});
it("(i) a non-scroll keydown does NOT abort restore", () => {
window.sessionStorage.setItem(`${KEY_PREFIX}i1`, "500");
setScrollHeight(2000); // tall enough to restore synchronously
const { result } = renderHook(() => useScrollPosition("i1"));
// A non-scroll key (e.g. typing, a shortcut) must NOT count as scroll intent.
act(() => {
window.dispatchEvent(new KeyboardEvent("keydown", { key: "a" }));
});
act(() => {
result.current.restoreScrollPosition();
});
// Restore still happens: the innocuous keypress did not disable it.
expect(window.scrollTo).toHaveBeenCalledWith({ top: 500, behavior: "auto" });
});
it("(j) a scroll keydown (Space) DOES abort restore", () => {
window.sessionStorage.setItem(`${KEY_PREFIX}j1`, "500");
setScrollHeight(2000); // tall enough to restore synchronously
const { result } = renderHook(() => useScrollPosition("j1"));
// Space scrolls the page: this is real scroll intent and must abort restore.
act(() => {
window.dispatchEvent(new KeyboardEvent("keydown", { key: " " }));
});
act(() => {
result.current.restoreScrollPosition();
});
expect(window.scrollTo).not.toHaveBeenCalled();
});
it("(c) does nothing when nothing is saved or the saved value is <= 0", () => { it("(c) does nothing when nothing is saved or the saved value is <= 0", () => {
// Nothing saved. // Nothing saved.
const a = renderHook(() => useScrollPosition("nope")); const a = renderHook(() => useScrollPosition("nope"));
@@ -13,18 +13,6 @@ const RESTORE_POLL_MS = 100;
// "remember where I was reading" feature (self-limiting, no cross-tab leak). // "remember where I was reading" feature (self-limiting, no cross-tab leak).
const STORAGE_PREFIX = "gitmost:scroll-position:"; const STORAGE_PREFIX = "gitmost:scroll-position:";
// Keys that scroll the window. Only these count as scroll intent for keydown;
// other keys (shortcuts, modifiers, typing) must NOT disable scroll restore.
const SCROLL_KEYS = new Set([
"ArrowUp",
"ArrowDown",
"PageUp",
"PageDown",
"Home",
"End",
" ", // Space (and Shift+Space) scroll the page
]);
function storageKey(pageId: string): string { function storageKey(pageId: string): string {
return `${STORAGE_PREFIX}${pageId}`; return `${STORAGE_PREFIX}${pageId}`;
} }
@@ -60,41 +48,32 @@ function writeStorage(pageId: string, scrollY: number): void {
* Persists and restores the window scroll position per page so a reader keeps * Persists and restores the window scroll position per page so a reader keeps
* their place across a reload (F5) or reopening the document. * their place across a reload (F5) or reopening the document.
* *
* Returns `restoreScrollPosition`, which the page editor calls from two triggers * Returns `restoreScrollPosition`, which the page editor calls once the live
* (early, while the static/cached content is laid out, and again after the * (non-static) content is laid out. The two scroll mechanisms are mutually
* static->live editor swap); it is idempotent, so re-asserting the same target is * exclusive: if the URL has a `#hash` anchor, the existing anchor-scroll logic
* a no-op. The two scroll mechanisms are mutually exclusive: if the URL has a * wins and restore is a no-op.
* `#hash` anchor, the existing anchor-scroll logic wins and restore is a no-op.
*/ */
export function useScrollPosition(pageId: string): { export function useScrollPosition(pageId: string): {
restoreScrollPosition: () => void; restoreScrollPosition: () => void;
} { } {
// CONTRACT: this hook assumes PageEditor REMOUNTS per page — page.tsx renders // CONTRACT: this hook assumes PageEditor REMOUNTS per page — page.tsx renders
// `<MemoizedFullEditor key={page.id} ...>`, so switching pages creates a fresh // `<MemoizedFullEditor key={page.id} ...>`, so switching pages creates a fresh
// hook instance with fresh refs. Restore is idempotent and interaction-gated // hook instance with fresh refs. These refs latch per-mount and are NOT reset
// (not single-shot): it may be called from several triggers and re-asserts the // when `pageId` changes in place (only the effect re-runs on [pageId]). If that
// SAME captured target, which is a no-op once the window is already positioned. // `key={page.id}` is ever removed, restore would silently break on the 2nd page
// The per-mount refs that latch are `initialTargetRef` (the captured target) // (refs would hold the first page's target / already-restored flag) — in that
// and `userInteractedRef` (the reader has taken over scrolling). They are NOT // case the refs must be reset on a pageId change.
// reset when `pageId` changes in place (only the effect re-runs on [pageId]).
// If that `key={page.id}` is ever removed, restore would silently break on the
// 2nd page (refs would hold the first page's target / interaction flag) — in
// that case the refs must be reset on a pageId change.
// //
// The target Y captured synchronously at mount, BEFORE any scroll/visibility // The target Y captured synchronously at mount, BEFORE any scroll/visibility
// handler can overwrite the stored value with a fresh 0 (the page starts // handler can overwrite the stored value with a fresh 0 (the page starts
// scrolled to top on load). `null` means "not yet captured". // scrolled to top on load). `null` means "not yet captured".
const initialTargetRef = useRef<number | null>(null); const initialTargetRef = useRef<number | null>(null);
// Set once the reader shows unambiguous scroll intent; restore must never yank // Guards so restore runs at most once per page mount.
// a reader who has already started scrolling. const hasRestoredRef = useRef(false);
const userInteractedRef = useRef(false);
// Holds the in-flight restore poll timer so the cleanup can cancel it: without // Holds the in-flight restore poll timer so the cleanup can cancel it: without
// this, a fast SPA navigation away mid-poll would let the old page's poll fire // this, a fast SPA navigation away mid-poll would let the old page's poll fire
// window.scrollTo against the NEW page's document (visible wrong-page scroll). // window.scrollTo against the NEW page's document (visible wrong-page scroll).
const pollTimerRef = useRef<number | null>(null); const pollTimerRef = useRef<number | null>(null);
// Timestamp of the FIRST restore attempt so re-triggers (e.g. the static→live
// editor swap) share ONE bounded timeout budget instead of restarting it.
const restoreStartRef = useRef<number | null>(null);
// Capture the previously-saved value synchronously during render, before the // Capture the previously-saved value synchronously during render, before the
// effect below registers handlers that would persist the current (0) scrollY. // effect below registers handlers that would persist the current (0) scrollY.
@@ -135,43 +114,14 @@ export function useScrollPosition(pageId: string): {
} }
}; };
// User scroll-intent signals. wheel and touch are unconditional scroll
// intent; keydown is filtered to actual scroll keys only (SCROLL_KEYS) so
// shortcuts, lone modifiers, and typing do not abort restore. Our own
// window.scrollTo does NOT emit these, so restore can never self-abort via
// them. Once the reader shows intent we mark it and cancel any in-flight
// restore poll so restore can never yank them back. (Scrollbar-drag via
// pointer is an accepted small gap — it is not covered here.)
const onUserIntent = (event: Event) => {
// wheel/touchstart are unambiguous scroll intent; for keydown, only real
// scroll keys count — a shortcut or typing must not abort restore.
if (
event.type === "keydown" &&
!SCROLL_KEYS.has((event as KeyboardEvent).key)
) {
return;
}
userInteractedRef.current = true;
if (pollTimerRef.current !== null) {
window.clearTimeout(pollTimerRef.current);
pollTimerRef.current = null;
}
};
window.addEventListener("scroll", onScroll, { passive: true }); window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("pagehide", onPageHide); window.addEventListener("pagehide", onPageHide);
document.addEventListener("visibilitychange", onVisibilityChange); document.addEventListener("visibilitychange", onVisibilityChange);
window.addEventListener("wheel", onUserIntent, { passive: true });
window.addEventListener("touchstart", onUserIntent, { passive: true });
window.addEventListener("keydown", onUserIntent);
return () => { return () => {
window.removeEventListener("scroll", onScroll); window.removeEventListener("scroll", onScroll);
window.removeEventListener("pagehide", onPageHide); window.removeEventListener("pagehide", onPageHide);
document.removeEventListener("visibilitychange", onVisibilityChange); document.removeEventListener("visibilitychange", onVisibilityChange);
window.removeEventListener("wheel", onUserIntent);
window.removeEventListener("touchstart", onUserIntent);
window.removeEventListener("keydown", onUserIntent);
if (throttleTimer !== null) { if (throttleTimer !== null) {
window.clearTimeout(throttleTimer); window.clearTimeout(throttleTimer);
throttleTimer = null; throttleTimer = null;
@@ -187,8 +137,9 @@ export function useScrollPosition(pageId: string): {
}, [pageId]); }, [pageId]);
const restoreScrollPosition = useCallback(() => { const restoreScrollPosition = useCallback(() => {
// The reader took over — never yank them back. // Run at most once per page mount.
if (userInteractedRef.current) return; if (hasRestoredRef.current) return;
hasRestoredRef.current = true;
// Anchor priority: a `#hash` in the URL is handled by useEditorScroll. // Anchor priority: a `#hash` in the URL is handled by useEditorScroll.
if (window.location.hash) return; if (window.location.hash) return;
@@ -197,26 +148,9 @@ export function useScrollPosition(pageId: string): {
// Nothing meaningful to restore to. // Nothing meaningful to restore to.
if (targetY <= 0) return; if (targetY <= 0) return;
// Cancel any in-flight poll before (re)starting, so overlapping triggers can const start = Date.now();
// never run two concurrent polls against the same target.
if (pollTimerRef.current !== null) {
window.clearTimeout(pollTimerRef.current);
pollTimerRef.current = null;
}
// Share one timeout budget across re-triggers instead of restarting it.
if (restoreStartRef.current === null) {
restoreStartRef.current = Date.now();
}
const start = restoreStartRef.current;
const tryRestore = () => { const tryRestore = () => {
// Bail mid-poll if the reader started scrolling while we were waiting.
if (userInteractedRef.current) {
pollTimerRef.current = null;
return;
}
const maxScroll = const maxScroll =
document.documentElement.scrollHeight - window.innerHeight; document.documentElement.scrollHeight - window.innerHeight;
const timedOut = Date.now() - start >= MAX_RESTORE_WAIT_MS; const timedOut = Date.now() - start >= MAX_RESTORE_WAIT_MS;
@@ -224,12 +158,10 @@ export function useScrollPosition(pageId: string): {
// Restore once the content is tall enough to reach the target, or bail out // Restore once the content is tall enough to reach the target, or bail out
// after the timeout and scroll as far as currently possible. // after the timeout and scroll as far as currently possible.
if (maxScroll >= targetY || timedOut) { if (maxScroll >= targetY || timedOut) {
const top = Math.min(targetY, Math.max(maxScroll, 0)); window.scrollTo({
// Redundancy guard: re-asserting the SAME target when already positioned top: Math.min(targetY, Math.max(maxScroll, 0)),
// is a no-op, so this hook can be called from multiple triggers safely. behavior: "auto",
if (Math.abs(window.scrollY - top) > 1) { });
window.scrollTo({ top, behavior: "auto" });
}
pollTimerRef.current = null; pollTimerRef.current = null;
return; return;
} }
@@ -2,7 +2,6 @@ import "@/features/editor/styles/index.css";
import React, { import React, {
useCallback, useCallback,
useEffect, useEffect,
useLayoutEffect,
useMemo, useMemo,
useRef, useRef,
useState, useState,
@@ -483,17 +482,8 @@ export default function PageEditor({
} }
}, [yjsConnectionStatus, isSynced]); }, [yjsConnectionStatus, isSynced]);
// Restore as early as the static (cached) content is laid out, before paint, // Restore the saved reading position once the live content is laid out.
// so the reader's position is applied without a visible jump. Aborts itself if useEffect(() => {
// the reader has already started scrolling (handled inside the hook).
useLayoutEffect(() => {
restoreScrollPosition();
}, [restoreScrollPosition]);
// Re-assert once after the static -> live editor swap in case the swap reset
// the window scroll. Idempotent: a no-op when the position is already correct,
// and a no-op after the reader has interacted.
useLayoutEffect(() => {
if (!showStatic && editor) restoreScrollPosition(); if (!showStatic && editor) restoreScrollPosition();
}, [showStatic, editor, restoreScrollPosition]); }, [showStatic, editor, restoreScrollPosition]);
@@ -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;
}
@@ -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),
+11 -5
View File
@@ -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 `![x](url)` /
// `[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:
'![x](https://attacker.example/t.png) 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 `![x](` image and `[click](` link markers are gone. (We
// deliberately do NOT assert `not.toContain('](https://')`: 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('![x](');
expect(md).not.toContain('[click](');
// The brackets are backslash-escaped, so `[text](url)`/`![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 `![text](url)`
* images, so a cross-user title like `![x](http://evil)` 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 `![x](url)` / `[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
+3 -1
View File
@@ -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";