Compare commits

...

5 Commits

Author SHA1 Message Date
claude code agent 227
67312a3753 fix(#262): keep polling the reindex counter past the stale pre-reindex snapshot
After "Reindex now" the "Indexed X of Y" counter froze at 0 until a manual
reload. Root cause is purely client-side: right after the mutation the
client still holds the PRE-reindex settings snapshot, which for an already
fully-indexed workspace reads reindexing=false, indexed>=total. The
deadline-clearing effect evaluated isReindexComplete() against that stale
snapshot, read it as "done", and cleared the poll deadline before the first
post-reindex poll ever landed — so polling never ran and the counter stayed
at 0 (a reload just fetched one fresh snapshot).

Gate completion on having actually observed the active run: a
reindexSeenActiveRef, reset on each new reindex (mutation onSuccess, before
setting the deadline) and latched true once a poll reports reindexing=true.
isReindexComplete(status, seenActive) and nextReindexPollInterval now require
seenActive, so the stale fully-indexed snapshot no longer reads as finished.
The server pre-seeds reindexing=true from enqueue time, so seenActive latches
early and a genuine completion still stops polling promptly; the
REINDEX_POLL_CAP_MS cap is checked first and always wins, so polling can
never run away. closes #262

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:12:15 +03:00
claude_code
96b9ec11d6 ci: use mirror.gcr.io for postgres and redis
Update GitHub workflow services to pull PostgreSQL and Redis images from `mirror.gcr.io` instead of Docker Hub. This avoids anonymous pull rate‑limit failures on shared GitHub runner IPs by using the Docker Hub pull‑through cache.
2026-06-30 08:50:00 +03:00
claude_code
f8d26420eb test(mcp): add stashPage to HOST_CONTRACT_METHODS (fix drift-guard)
stashPage is declared in the server's DocmostClientLike interface and
shipped as the stash_page MCP tool (client.ts, tool-specs.ts, index.ts),
but the hand-maintained HOST_CONTRACT_METHODS mirror in the contract test
was never updated — so the drift-guard test failed and broke CI's
unit-test job. Add the missing name; both directions now agree.
2026-06-30 03:44:29 +03:00
claude_code
5c1187b864 feat(editor): add Clear formatting button to bubble menu
The floating bubble menu had no way to clear formatting, so in the
default configuration (fixed toolbar disabled) users could not reset
inline formatting at all. Mirror the fixed-toolbar action into the
bubble menu: a new "Clear formatting" item running unsetAllMarks().

- bubble-menu.tsx: import IconClearFormatting; append a non-toggle
  "Clear formatting" item (isActive: () => false) to the items array.
- No i18n changes — the "Clear formatting" key already exists in all
  locales.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 03:26:17 +03:00
claude_code
14f83abe78 fix(editor-ext): remove duplicate escapeHtmlAttr (TS2393, broken CI)
Merging the image-captions (#221) and lossless-export branches each added
its own escapeHtmlAttr in turndown.utils.ts, producing two implementations
of the same function and breaking `tsc --build` (TS2393) — which failed the
Build editor-ext step across all CI jobs.

Drop the lighter image-captions duplicate (escapes & and ") and keep the
fuller version (escapes & " < >). It is a strict superset: both call sites
(serializeAttrs, the image rule) place the value inside a double-quoted HTML
attribute, where extra < > escaping is harmless and idempotent on re-import.
Verified: editor-ext builds; turndown.dataloss + image-markdown tests pass.
2026-06-30 02:51:20 +03:00
7 changed files with 133 additions and 38 deletions

View File

@@ -75,7 +75,9 @@ jobs:
APP_URL: http://localhost:3000
services:
postgres:
image: pgvector/pgvector:pg18
# via mirror.gcr.io (Docker Hub pull-through cache; avoids Hub anonymous
# pull rate-limit that randomly fails on shared GitHub runner IPs).
image: mirror.gcr.io/pgvector/pgvector:pg18
env:
POSTGRES_DB: docmost
POSTGRES_USER: docmost
@@ -88,7 +90,8 @@ jobs:
--health-timeout 5s
--health-retries 20
redis:
image: redis:7
# via mirror.gcr.io (see postgres note above).
image: mirror.gcr.io/library/redis:7
ports:
- 6379:6379
options: >-
@@ -135,7 +138,9 @@ jobs:
NODE_ENV: production
services:
postgres:
image: pgvector/pgvector:pg18
# via mirror.gcr.io (Docker Hub pull-through cache; avoids Hub anonymous
# pull rate-limit that randomly fails on shared GitHub runner IPs).
image: mirror.gcr.io/pgvector/pgvector:pg18
env:
POSTGRES_DB: docmost
POSTGRES_USER: docmost
@@ -148,7 +153,8 @@ jobs:
--health-timeout 5s
--health-retries 20
redis:
image: redis:7
# via mirror.gcr.io (see postgres note above).
image: mirror.gcr.io/library/redis:7
ports:
- 6379:6379
options: >-

View File

@@ -27,7 +27,9 @@ jobs:
# TEST_*_URL overrides are needed.
services:
postgres:
image: pgvector/pgvector:pg18
# via mirror.gcr.io (Docker Hub pull-through cache; avoids Hub anonymous
# pull rate-limit that randomly fails on shared GitHub runner IPs).
image: mirror.gcr.io/pgvector/pgvector:pg18
env:
POSTGRES_USER: docmost
POSTGRES_PASSWORD: docmost_dev_pw
@@ -40,7 +42,8 @@ jobs:
--health-timeout 5s
--health-retries 5
redis:
image: redis:7
# via mirror.gcr.io (see postgres note above).
image: mirror.gcr.io/library/redis:7
ports:
- 6379:6379
options: >-

View File

@@ -10,6 +10,7 @@ import {
IconUnderline,
IconMessage,
IconEyeOff,
IconClearFormatting,
} from "@tabler/icons-react";
import clsx from "clsx";
import classes from "./bubble-menu.module.css";
@@ -117,6 +118,14 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
command: () => props.editor.chain().focus().toggleSpoiler().run(),
icon: IconEyeOff,
},
{
name: "Clear formatting",
// Action, not a toggle — never show an active/highlighted state.
isActive: () => false,
// Mirror the fixed-toolbar behavior: strip all inline marks from the selection.
command: () => props.editor.chain().focus().unsetAllMarks().run(),
icon: IconClearFormatting,
},
];
const commentItem: BubbleMenuItem = {

View File

@@ -77,7 +77,9 @@ describe('resolveKeyField (write-only key payload)', () => {
describe('nextReindexPollInterval', () => {
const INTERVAL = 5000;
const base = { now: 1_000, intervalMs: INTERVAL };
// `seenActive: true` is the steady state for most of a run — a poll has
// observed `reindexing === true` (the server pre-seeds it from enqueue time).
const base = { now: 1_000, intervalMs: INTERVAL, seenActive: true };
it('does not poll when no reindex deadline is set', () => {
expect(
@@ -111,7 +113,7 @@ describe('nextReindexPollInterval', () => {
).toBe(INTERVAL);
});
it('stops once the run is finished AND fully indexed', () => {
it('stops once the run is finished AND fully indexed (after having been active)', () => {
expect(
nextReindexPollInterval({
...base,
@@ -121,11 +123,29 @@ describe('nextReindexPollInterval', () => {
).toBe(false);
});
it('does NOT stop on the stale pre-reindex snapshot (fully indexed, never seen active)', () => {
// Regression for #262: right after "Reindex now" the client still holds the
// PRE-reindex settings (an already fully-indexed workspace reads as
// reindexing=false, indexed>=total). Without the seenActive gate this looked
// "done" and stopped polling on the very first tick, freezing the counter at
// 0 until a manual reload. The fresh window has not observed the active run,
// so polling must continue until the first real poll lands.
expect(
nextReindexPollInterval({
...base,
seenActive: false,
deadline: 10_000,
status: { reindexing: false, indexedPages: 478, totalPages: 478 },
}),
).toBe(INTERVAL);
});
it('keeps polling within the deadline when not yet done and no active flag', () => {
// First poll right after enqueue, before the worker publishes progress.
expect(
nextReindexPollInterval({
...base,
seenActive: false,
deadline: 10_000,
status: { reindexing: false, indexedPages: 0, totalPages: 478 },
}),
@@ -138,12 +158,15 @@ describe('nextReindexPollInterval', () => {
deadline: 1_000,
now: 2_000, // past the deadline
intervalMs: INTERVAL,
seenActive: true,
status: { reindexing: true, indexedPages: 200, totalPages: 478 },
}),
).toBe(false);
});
it('stops on an empty workspace (0 of 0) once the run is finished', () => {
// The pre-seed publishes reindexing=true even for 0 pages, so a poll sees the
// run active before the worker clears -> seenActive latches true.
expect(
nextReindexPollInterval({
...base,
@@ -156,26 +179,46 @@ describe('nextReindexPollInterval', () => {
describe('isReindexComplete', () => {
it('false when no status yet', () => {
expect(isReindexComplete(undefined)).toBe(false);
expect(isReindexComplete(undefined, true)).toBe(false);
});
it('false while a run is still active (even at indexed==total)', () => {
expect(
isReindexComplete({ reindexing: true, indexedPages: 478, totalPages: 478 }),
isReindexComplete(
{ reindexing: true, indexedPages: 478, totalPages: 478 },
true,
),
).toBe(false);
});
it('false when finished but not yet fully indexed', () => {
expect(
isReindexComplete({ reindexing: false, indexedPages: 120, totalPages: 478 }),
isReindexComplete(
{ reindexing: false, indexedPages: 120, totalPages: 478 },
true,
),
).toBe(false);
});
it('true once finished and fully indexed', () => {
it('true once finished and fully indexed (after having been active)', () => {
expect(
isReindexComplete({ reindexing: false, indexedPages: 478, totalPages: 478 }),
isReindexComplete(
{ reindexing: false, indexedPages: 478, totalPages: 478 },
true,
),
).toBe(true);
});
it('false on the stale pre-reindex snapshot: finished+fully indexed but never seen active', () => {
// The just-started edge: the gate keeps this from clearing the poll deadline
// before the first post-reindex poll arrives.
expect(
isReindexComplete(
{ reindexing: false, indexedPages: 478, totalPages: 478 },
false,
),
).toBe(false);
});
});
describe('isReindexButtonLoading', () => {

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { z } from "zod/v4";
import {
ActionIcon,
@@ -185,14 +185,23 @@ type ReindexStatus = Pick<
* has finished AND everything is indexed (server cleared its progress record and
* fell back to the DB coverage count), or the deadline cap is hit — the cap
* always wins so a stuck/never-clearing progress record can't poll forever.
*
* `seenActive` guards the just-started window: right after "Reindex now" the
* client still holds the PRE-reindex settings snapshot, which for an already
* fully-indexed workspace reads as `reindexing=false, indexed>=total`. Treating
* that stale snapshot as "done" would stop polling before the first post-reindex
* poll ever lands (counter frozen at 0). So completion is only honored once a
* poll has actually observed the active run (the enqueue-time pre-seed makes
* `reindexing=true` visible from the first poll until the run truly clears).
*/
export function nextReindexPollInterval(args: {
deadline: number | null;
now: number;
intervalMs: number;
status?: ReindexStatus;
seenActive: boolean;
}): number | false {
const { deadline, now, intervalMs, status } = args;
const { deadline, now, intervalMs, status, seenActive } = args;
if (deadline === null) return false;
// Cap always wins.
if (now > deadline) return false;
@@ -200,20 +209,33 @@ export function nextReindexPollInterval(args: {
if (status?.reindexing) return intervalMs;
// Finished and fully indexed (incl. an empty workspace, 0 >= 0) → stop. Reuse
// isReindexComplete so the completeness check lives in exactly one place.
if (isReindexComplete(status)) return false;
if (isReindexComplete(status, seenActive)) return false;
// Within the deadline and not yet done → keep polling.
return intervalMs;
}
/**
* Whether the reindex poll deadline should be cleared: the server reports no
* active run AND the count is complete. The single source of truth for the
* "reindex finished" check — `nextReindexPollInterval` reuses it for its stop
* condition (sans the cap, which the effect handles via time).
* Whether the reindex poll deadline should be cleared: a poll has observed the
* active run (`seenActive`) AND the server now reports no active run AND the
* count is complete. The single source of truth for the "reindex finished"
* check — `nextReindexPollInterval` reuses it for its stop condition (sans the
* cap, which the effect handles via time).
*
* The `seenActive` requirement is what keeps the STALE pre-reindex snapshot
* (already fully indexed → `reindexing=false, indexed>=total`) from being read
* as "finished" in the window before the first post-reindex poll arrives. Once
* a poll has seen `reindexing=true` (guaranteed by the server's enqueue-time
* pre-seed for the whole run), this flips to a genuine completion check.
*/
export function isReindexComplete(status?: ReindexStatus): boolean {
export function isReindexComplete(
status: ReindexStatus | undefined,
seenActive: boolean,
): boolean {
return (
!!status && !status.reindexing && status.indexedPages >= status.totalPages
seenActive &&
!!status &&
!status.reindexing &&
status.indexedPages >= status.totalPages
);
}
@@ -290,6 +312,14 @@ export default function AiProviderSettings() {
const REINDEX_POLL_INTERVAL = 5000; // ms between refetches while indexing
const REINDEX_POLL_CAP_MS = 120000; // ~2 min hard cap
const [reindexDeadline, setReindexDeadline] = useState<number | null>(null);
// Whether any poll in the CURRENT window has actually observed the active run
// (`reindexing === true`). Reset when a new reindex is kicked off. Gates the
// completion check so the STALE pre-reindex snapshot (an already fully-indexed
// workspace reads as `reindexing=false, indexed>=total`) can't be mistaken for
// "finished" before the first post-reindex poll lands — which would freeze the
// counter at 0 until a manual reload. A ref (not state) because it must not
// trigger a render and is only ever read where `reindexing` is already false.
const reindexSeenActiveRef = useRef(false);
// Only admins may read the (masked) AI settings; the server enforces this too.
const { data: settings, isLoading } = useAiSettingsQuery(isAdmin, (query) =>
@@ -298,6 +328,7 @@ export default function AiProviderSettings() {
now: Date.now(),
intervalMs: REINDEX_POLL_INTERVAL,
status: query.state.data,
seenActive: reindexSeenActiveRef.current,
}),
);
@@ -305,12 +336,17 @@ export default function AiProviderSettings() {
// unmount because the deadline state goes away with the component.
useEffect(() => {
if (reindexDeadline === null) return;
// "Done" matches the refetchInterval stop condition: the server reports no
// active run AND the count is complete (indexed >= total, incl. an empty
// workspace 0 >= 0), so the deadline clears promptly instead of waiting out
// the cap. While `reindexing` is still true we keep the deadline so polling
// continues for the whole run.
if (isReindexComplete(settings)) {
// Latch "we have seen the active run" the moment a poll reports it, so the
// completion check below (and the refetchInterval's) only fires once the run
// has genuinely started — never on the stale pre-reindex snapshot.
if (settings?.reindexing) reindexSeenActiveRef.current = true;
// "Done" matches the refetchInterval stop condition: a poll has observed the
// active run AND the server now reports no active run AND the count is
// complete (indexed >= total, incl. an empty workspace 0 >= 0), so the
// deadline clears promptly instead of waiting out the cap. While `reindexing`
// is still true (or no poll has seen it active yet) we keep the deadline so
// polling continues for the whole run.
if (isReindexComplete(settings, reindexSeenActiveRef.current)) {
setReindexDeadline(null);
return;
}
@@ -1117,8 +1153,13 @@ export default function AiProviderSettings() {
reindexMutation.mutate(undefined, {
// Begin bounded polling so the counter climbs as the async
// background job indexes (it does not update on its own).
onSuccess: () =>
setReindexDeadline(Date.now() + REINDEX_POLL_CAP_MS),
// Clear the "seen active" latch first so this fresh window
// doesn't inherit a previous run's completion state and stop
// immediately.
onSuccess: () => {
reindexSeenActiveRef.current = false;
setReindexDeadline(Date.now() + REINDEX_POLL_CAP_MS);
},
})
}
>

View File

@@ -12,14 +12,6 @@ function sanitizeMdLinkText(value: string): string {
.replace(/[\r\n]+/g, ' ');
}
// Escape a value placed inside a double-quoted HTML attribute (img src/alt/
// data-caption in the raw-HTML image fallback). Only & and " are special in
// that context; escaping them is idempotent because parse5/marked decode them
// back on re-import.
function escapeHtmlAttr(value: string): string {
return value.replace(/&/g, '&amp;').replace(/"/g, '&quot;');
}
// Tags turndown treats as void (self-closing). Footnote references render as an
// empty <sup data-footnote-ref> whose meaning lives entirely in its data-id;
// without marking it void, turndown's blank-node removal drops it before our

View File

@@ -74,6 +74,7 @@ const HOST_CONTRACT_METHODS = [
"unsharePage",
"restorePageVersion",
"transformPage",
"stashPage",
// write (comment)
"createComment",
"resolveComment",