diff --git a/.github/workflows/develop.yml b/.github/workflows/develop.yml
index c4df52b8..8f6dce1f 100644
--- a/.github/workflows/develop.yml
+++ b/.github/workflows/develop.yml
@@ -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: >-
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 92eea23e..1d9ca3ad 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -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: >-
diff --git a/AGENTS.md b/AGENTS.md
index 70a382f7..b2e1efcc 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -197,6 +197,12 @@ pnpm workspace (`pnpm@10.4.0`) orchestrated by **Nx**. Four workspace packages:
Run from the repo root unless noted. The dev workflow needs **Postgres (with the `pgvector` extension) and Redis** reachable per `.env` (copy `.env.example` → `.env`).
+> **Bringing up a full local stand** (API + client + the separate realtime
+> collaboration process) has several non-obvious gotchas — a missing collab
+> server, `APP_SECRET` mismatch between processes, a stale `editor-ext` white-
+> screening the client, LAN exposure. See **[docs/dev-stand.md](docs/dev-stand.md)**
+> for the step-by-step and the traps.
+
```bash
pnpm install # install all workspaces (uses pnpm patches; see package.json `pnpm.patchedDependencies`)
pnpm dev # client (Vite) + server (Nest watch) concurrently — primary dev loop
@@ -241,6 +247,8 @@ Migration files live in `apps/server/src/database/migrations/` and are named `YY
- **API server** — `dist/main` (`apps/server/src/main.ts`), the Fastify HTTP app (`AppModule`).
- **Collaboration server** — `dist/collaboration/server/collab-main` (`pnpm collab`), a Hocuspocus/Yjs WebSocket server (`apps/server/src/collaboration/`) handling real-time document editing, persistence, and page-history snapshots. It listens on `COLLAB_PORT` (default `3001`), separate from the API server's `PORT` (default `3000`), and shares state with the API server through Redis.
+`pnpm dev` starts **only** the API server + client — the collaboration process is separate and must be started too, or the editor never connects. See **[docs/dev-stand.md](docs/dev-stand.md)** for running both locally (and why `APP_SECRET` must match between them).
+
The API server is a Fastify app with a global `/api` prefix (`main.ts` excludes `robots.txt`, public share pages, and `mcp` from the prefix). A `preHandler` hook enforces that a resolved `workspaceId` exists for most `/api` routes (multi-tenant by hostname/subdomain via `DomainMiddleware`). `GET /api/sb/:id` (the anonymous blob-sandbox read route) is listed in that preHandler's `excludedPaths`, so it is exempt from workspace resolution and carries no session auth at all (its capability is the unguessable UUID + TTL + TLS) — unlike `/api/files/public/...`, which still resolves a workspace and requires a workspace-bound attachment JWT. Auth is JWT (cookie + bearer); authorization is **CASL** (`core/casl`) — every data access is scoped to the user's abilities.
### Module structure (server)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c9c83b1c..1779a14f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- **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
+ images as a row that wraps onto the next line on narrow screens. 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
+ values.
+
- **Editable captions for images.** Images gain an optional caption shown
below them, edited inline from the image bubble menu and stored as a `caption` attribute. Captions round-trip
losslessly through markdown as a `data-caption` attribute on the image, so
@@ -537,6 +544,7 @@ knowledge layer, an embedded MCP server, and the Gitmost rebrand.
- Build: drop the private EE submodule, retarget CI to GHCR, and update the
Docker image to the GHCR registry.
-[Unreleased]: https://github.com/vvzvlad/gitmost/compare/v0.93.0...HEAD
+[Unreleased]: https://github.com/vvzvlad/gitmost/compare/v0.94.0...HEAD
+[0.94.0]: https://github.com/vvzvlad/gitmost/compare/v0.93.0...v0.94.0
[0.93.0]: https://github.com/vvzvlad/gitmost/compare/v0.91.0...v0.93.0
[0.91.0]: https://github.com/vvzvlad/gitmost/compare/v0.90.1...v0.91.0
diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json
index 01d3e56b..346c6162 100644
--- a/apps/client/public/locales/en-US/translation.json
+++ b/apps/client/public/locales/en-US/translation.json
@@ -257,6 +257,8 @@
"Copy": "Copy",
"Copy to space": "Copy to space",
"Copy chat": "Copy chat",
+ "Dock to sidebar": "Dock to sidebar",
+ "Undock": "Undock",
"Copied": "Copied",
"Failed to export chat": "Failed to export chat",
"Duplicate": "Duplicate",
@@ -356,6 +358,7 @@
"Strike": "Strike",
"Code": "Code",
"Spoiler": "Spoiler",
+ "Stress": "Stress",
"Comment": "Comment",
"Text": "Text",
"Heading 1": "Heading 1",
@@ -1334,6 +1337,7 @@
"Move to space": "Move to space",
"Float left (wrap text)": "Float left (wrap text)",
"Float right (wrap text)": "Float right (wrap text)",
+ "Inline (side by side)": "Inline (side by side)",
"Switch to tree": "Switch to tree",
"Switch to flat list": "Switch to flat list",
"Toggle subpages display mode": "Toggle subpages display mode",
diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json
index 48d79e35..517abebe 100644
--- a/apps/client/public/locales/ru-RU/translation.json
+++ b/apps/client/public/locales/ru-RU/translation.json
@@ -352,6 +352,7 @@
"Strike": "Перечёркнутый",
"Code": "Код",
"Spoiler": "Спойлер",
+ "Stress": "Ударение",
"Comment": "Комментарий",
"Text": "Текст",
"Heading 1": "Заголовок 1",
@@ -727,6 +728,8 @@
"Ask the AI agent anything about your workspace.": "Спросите AI-агента о чём угодно по вашему рабочему пространству.",
"Ask the AI agent…": "Спросите AI-агента…",
"Copy chat": "Копировать чат",
+ "Dock to sidebar": "Закрепить в боковой панели",
+ "Undock": "Открепить",
"Created successfully": "Успешно создано",
"Context size / model limit": "Размер контекста / лимит модели",
"Context window (tokens)": "Окно контекста (токены)",
@@ -1187,6 +1190,7 @@
"Spoken language hint sent to the transcription model. Auto-detect lets the model decide.": "Подсказка языка речи для модели транскрипции. «Автоопределение» оставляет выбор за моделью.",
"Float left (wrap text)": "Обтекание слева",
"Float right (wrap text)": "Обтекание справа",
+ "Inline (side by side)": "В ряд",
"Switch to tree": "Переключить на дерево",
"Switch to flat list": "Переключить на плоский список",
"Toggle subpages display mode": "Переключить режим отображения подстраниц",
diff --git a/apps/client/src/components/layouts/global/global-app-shell.tsx b/apps/client/src/components/layouts/global/global-app-shell.tsx
index b756bdde..41d3886f 100644
--- a/apps/client/src/components/layouts/global/global-app-shell.tsx
+++ b/apps/client/src/components/layouts/global/global-app-shell.tsx
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
import SettingsSidebar from "@/components/settings/settings-sidebar.tsx";
import { useAtom } from "jotai";
import {
+ APP_NAVBAR_ID,
asideStateAtom,
desktopSidebarAtom,
mobileSidebarAtom,
@@ -106,6 +107,7 @@ export default function GlobalAppShell({
). Declared here —
+// alongside the sidebar atoms — rather than in the chat window so the AI chat
+// window can reference the navbar by id without importing the app shell (which
+// would create a shell -> chat-window -> shell import cycle).
+export const APP_NAVBAR_ID = "app-shell-navbar";
+
export const mobileSidebarAtom = atom(false);
export const desktopSidebarAtom = atomWithWebStorage(
diff --git a/apps/client/src/features/ai-chat/atoms/ai-chat-atom.ts b/apps/client/src/features/ai-chat/atoms/ai-chat-atom.ts
index abf63729..3b56a30a 100644
--- a/apps/client/src/features/ai-chat/atoms/ai-chat-atom.ts
+++ b/apps/client/src/features/ai-chat/atoms/ai-chat-atom.ts
@@ -18,6 +18,18 @@ export const aiChatWindowGeomAtom = atomWithStorage(
null,
);
+/**
+ * Whether the AI chat window is docked into the sidebar (page-tree navbar).
+ * Persisted to localStorage so the docked/floating mode survives a full page
+ * reload and close/reopen. `false` = the default floating window. When docked,
+ * the SAME window instance pins itself to the live bounding rect of the app
+ * navbar (see AiChatWindow), overlaying the page tree.
+ */
+export const aiChatWindowDockedAtom = atomWithStorage(
+ "ai-chat-window-docked",
+ false,
+);
+
/**
* The currently selected chat id. `null` means a fresh (not-yet-created) chat:
* the server creates the chat row on the first streamed message and echoes its
diff --git a/apps/client/src/features/ai-chat/components/ai-chat-window.module.css b/apps/client/src/features/ai-chat/components/ai-chat-window.module.css
index 5758a018..659ca8cb 100644
--- a/apps/client/src/features/ai-chat/components/ai-chat-window.module.css
+++ b/apps/client/src/features/ai-chat/components/ai-chat-window.module.css
@@ -35,6 +35,35 @@
background: transparent;
}
+/* Docked into the sidebar: the window pins itself to the live navbar rect
+ (position/size supplied inline). It sits flush inside the navbar area, so we
+ drop the floating chrome — no border-radius, drop shadow or user resize — and
+ remove the floating min/max clamps so the size is driven ENTIRELY by the
+ inline navbar rect (which may be narrower than the floating min-width of
+ 300px, e.g. the 220px navbar minimum). z-index 105 keeps it above the page
+ tree (navbar 101) but below the header and Mantine overlays. */
+.docked {
+ border-radius: 0;
+ box-shadow: none;
+ resize: none;
+ min-width: 0;
+ min-height: 0;
+ max-width: none;
+ max-height: none;
+}
+
+/* Drop-zone highlight shown over the navbar bounds while a floating window is
+ dragged onto the sidebar. Sits just above the docked window (106) so the cue
+ is visible; purely decorative, so it never intercepts pointer events. */
+.dockHighlight {
+ position: fixed;
+ z-index: 106;
+ border: 2px dashed light-dark(var(--mantine-color-blue-5), var(--mantine-color-blue-4));
+ background: light-dark(rgba(34, 139, 230, 0.08), rgba(34, 139, 230, 0.14));
+ border-radius: var(--mantine-radius-sm);
+ pointer-events: none;
+}
+
/* When minimized the window collapses to the header only: auto height, no
resize. Width/height inline values are overridden. */
.minimized {
diff --git a/apps/client/src/features/ai-chat/components/ai-chat-window.tsx b/apps/client/src/features/ai-chat/components/ai-chat-window.tsx
index 3df60ddb..c26bfa2d 100644
--- a/apps/client/src/features/ai-chat/components/ai-chat-window.tsx
+++ b/apps/client/src/features/ai-chat/components/ai-chat-window.tsx
@@ -13,21 +13,29 @@ import {
IconChevronDown,
IconCopy,
IconGripVertical,
+ IconLayoutSidebarLeftCollapse,
+ IconLayoutSidebarLeftExpand,
IconMinus,
IconPlus,
IconX,
} from "@tabler/icons-react";
import { useAtom, useSetAtom } from "jotai";
-import { useMatch } from "react-router-dom";
+import { useLocation, useMatch } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import {
activeAiChatIdAtom,
aiChatWindowOpenAtom,
aiChatWindowGeomAtom,
+ aiChatWindowDockedAtom,
aiChatDraftAtom,
selectedAiRoleIdAtom,
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
+import {
+ APP_NAVBAR_ID,
+ desktopSidebarAtom,
+ mobileSidebarAtom,
+} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { extractPageSlugId } from "@/lib";
import {
@@ -46,6 +54,11 @@ import {
isHeaderClick,
} from "@/features/ai-chat/utils/collapse-helpers.ts";
import { selectContextBadge } from "@/features/ai-chat/utils/context-badge.ts";
+import {
+ isPointWithinRect,
+ isNavbarRectVisible,
+ type NavbarRect,
+} from "@/features/ai-chat/utils/dock-helpers.ts";
import { useClipboard } from "@/hooks/use-clipboard";
import { notifications } from "@mantine/notifications";
import classes from "@/features/ai-chat/components/ai-chat-window.module.css";
@@ -112,6 +125,28 @@ function clampGeom(g: {
};
}
+// Live bounding rect of the app-shell navbar (the page-tree sidebar), by its
+// stable id. Returns null when the navbar is absent OR collapsed: Mantine
+// collapses the navbar by translating it off-screen (its right edge lands at or
+// left of the viewport), so a zero-size or off-screen rect is treated as "no
+// navbar" — the docked window then falls back to floating instead of pinning to
+// an off-screen box. Reads the DOM, so call it inside effects / handlers only.
+function getNavbarRect(): NavbarRect | null {
+ const el = document.getElementById(APP_NAVBAR_ID);
+ if (!el) return null;
+ const r = el.getBoundingClientRect();
+ // Off-screen/collapsed navbar (visibility predicate extracted + unit-tested).
+ if (!isNavbarRectVisible(r)) return null;
+ return { left: r.left, top: r.top, width: r.width, height: r.height };
+}
+
+// Whether a viewport point falls within the (visible) navbar bounds. Used to
+// decide dock-on-drop and undock-on-drag-out. The point-in-rect math is the pure
+// isPointWithinRect helper (unit-tested); this only supplies the live rect.
+function isPointerOverNavbar(x: number, y: number): boolean {
+ return isPointWithinRect(x, y, getNavbarRect());
+}
+
/**
* Floating, draggable, resizable, minimizable AI chat window. Replaces the
* former right-aside `AiChatPanel`: it owns ALL chat orchestration (active
@@ -138,6 +173,43 @@ export default function AiChatWindow() {
const minimizedRef = useRef(minimized);
minimizedRef.current = minimized;
+ // Docked-into-sidebar mode (#276). Persisted so it survives reload + reopen.
+ // When docked the SAME window instance pins itself to the navbar rect below.
+ const [docked, setDocked] = useAtom(aiChatWindowDockedAtom);
+ // Mirror for the useCallback([]) drag handlers (same reason as minimizedRef).
+ const dockedRef = useRef(docked);
+ dockedRef.current = docked;
+ // Live navbar rect the docked window is pinned to; synced before paint by the
+ // layout effect below. null = navbar absent/collapsed -> floating fallback.
+ const [dockRect, setDockRect] = useState(null);
+ // While dragging a FLOATING window over the navbar: show the drop-zone hint.
+ const [dockHint, setDockHint] = useState(false);
+ // Live window position during a drag. Normally the drag is fully imperative
+ // (el.style updated per mousemove, no re-render — matching the pre-#276
+ // behavior), so this stays null. It is set ONLY at a navbar-boundary crossing:
+ // that crossing already forces a re-render (dockHint flips), which would
+ // otherwise re-apply the committed geom and snap the box back for a frame — so
+ // we hand the render the live position at that instant instead. Cleared on drop.
+ const [dragPos, setDragPos] = useState<{ left: number; top: number } | null>(
+ null,
+ );
+
+ // Subscribed (read-only) so this component re-renders — and the dockRect-sync
+ // effect below re-runs — when the sidebar is collapsed/expanded via the header
+ // toggle. Mantine collapses the navbar with a transform (width/border-box
+ // unchanged), so the navbar's ResizeObserver never fires; these deps + the
+ // navbar `transitionend` listener are what re-measure the rect on toggle.
+ const [desktopSidebarOpen] = useAtom(desktopSidebarAtom);
+ const [mobileSidebarOpen] = useAtom(mobileSidebarAtom);
+
+ // Dock mode is only EFFECTIVE when a navbar rect is available. When docked but
+ // the navbar is absent/collapsed (dockRect === null) the window falls back to
+ // the floating look, so effects gated on "is docked" must use this — not the
+ // raw `docked` flag — or a fallback-floating window would behave half-docked.
+ const useDock = docked && dockRect !== null;
+
+ const location = useLocation();
+
const winRef = useRef(null);
// Live window geometry (position + size); persisted to localStorage so a
// drag/resize survives a full page reload (and close/reopen). `null` means
@@ -325,6 +397,47 @@ export default function AiChatWindow() {
setMinimized(false);
}, [windowOpen]);
+ // While docked, keep the window pinned to the navbar's LIVE rect. useLayoutEffect
+ // (not useEffect) so dockRect is measured/committed before the browser paints,
+ // avoiding a first-frame jump. Re-measures on: navbar size changes (manual
+ // sidebar resize -> ResizeObserver), viewport resize (window `resize`), and
+ // route changes that swap the navbar width (space <-> shared/global sidebar are
+ // 300px vs sidebarWidth -> re-run on location.pathname). If the navbar is
+ // absent/collapsed, getNavbarRect() returns null and the render falls back to
+ // the floating look (the window does NOT vanish).
+ useLayoutEffect(() => {
+ if (!windowOpen || !docked) return;
+ const sync = () => setDockRect(getNavbarRect());
+ sync();
+ const navbar = document.getElementById(APP_NAVBAR_ID);
+ let ro: ResizeObserver | null = null;
+ if (navbar) {
+ ro = new ResizeObserver(sync);
+ ro.observe(navbar);
+ // Collapsing/expanding the sidebar translates the navbar off-screen WITHOUT
+ // changing its width/border-box, so the ResizeObserver never fires and the
+ // effect's initial sync() may measure mid-transition (stale). Re-measure at
+ // transitionend so getNavbarRect() sees the final position: null once the
+ // navbar is translated off (right <= 0) -> fall back to floating; the real
+ // rect once it slides back -> re-dock. The sidebar-state deps below force
+ // this effect (and the immediate sync) to re-run on each toggle, covering
+ // the reduced-motion case where no transition -> no transitionend.
+ navbar.addEventListener("transitionend", sync);
+ }
+ window.addEventListener("resize", sync);
+ return () => {
+ ro?.disconnect();
+ navbar?.removeEventListener("transitionend", sync);
+ window.removeEventListener("resize", sync);
+ };
+ }, [
+ windowOpen,
+ docked,
+ location.pathname,
+ desktopSidebarOpen,
+ mobileSidebarOpen,
+ ]);
+
// Auto-collapse the window into its header as soon as the user interacts with
// anything outside it (clicks the page/editor). Armed ONLY while the window is
// open and expanded, so it never fires repeatedly and never collapses on the
@@ -333,7 +446,12 @@ export default function AiChatWindow() {
// (shouldCollapseOnOutsidePointer) prevent false collapses from clicks inside
// the window or inside Mantine portals (kebab menu, delete-confirm modal).
useEffect(() => {
- if (!windowOpen || minimized) return;
+ // Disabled while EFFECTIVELY docked: a docked window intentionally overlays
+ // the page tree, so a click on the surrounding page must NOT auto-collapse
+ // it. Gated on useDock (not raw `docked`) so a fallback-floating window
+ // (docked but navbar absent/collapsed) still auto-collapses like a normal
+ // floating window.
+ if (!windowOpen || minimized || useDock) return;
const onPointerDown = (e: MouseEvent): void => {
if (shouldCollapseOnOutsidePointer(e.target, winRef.current)) {
setMinimized(true);
@@ -341,13 +459,18 @@ export default function AiChatWindow() {
};
document.addEventListener("mousedown", onPointerDown, true);
return () => document.removeEventListener("mousedown", onPointerDown, true);
- }, [windowOpen, minimized]);
+ }, [windowOpen, minimized, useDock]);
// Persist the user's resize into state so it survives close/reopen. Skipped
// while minimized so the collapsed (auto) height is never captured. The
// equality guard avoids an update loop.
useEffect(() => {
- if (!windowOpen || minimized) return;
+ // Disabled while EFFECTIVELY docked: in dock mode the size is driven by the
+ // navbar rect, not a user resize, so we must not capture the navbar-sized box
+ // into the persisted floating geom (it would clobber the remembered floating
+ // size). Gated on useDock so a fallback-floating window (docked but navbar
+ // absent) still persists user resizes like a normal floating window.
+ if (!windowOpen || minimized || useDock) return;
const el = winRef.current;
// `geom` is in the deps so this re-runs once geometry is settled and the
// window is actually rendered (on the first open `geom` is still null on the
@@ -365,18 +488,30 @@ export default function AiChatWindow() {
});
ro.observe(el);
return () => ro.disconnect();
- }, [windowOpen, minimized, geom !== null]);
+ }, [windowOpen, minimized, useDock, geom !== null]);
const startDrag = useCallback((e: React.MouseEvent): void => {
- // Ignore drags that originate on a button (minimize/close/new chat).
+ // Ignore drags that originate on a button (dock/minimize/close/new chat).
if ((e.target as HTMLElement).closest("button")) return;
const el = winRef.current;
if (!el) return;
const sx = e.clientX;
const sy = e.clientY;
+ // Starting position: the element's current inline left/top, whether it was
+ // placed by the floating geom or pinned to the navbar rect (both render as
+ // "px"). getBoundingClientRect would work too, but the inline values keep
+ // the drag math identical to the pre-#276 floating behavior.
const ol = parseFloat(el.style.left) || 0;
const ot = parseFloat(el.style.top) || 0;
+ // Freeze the box size for the drag: a docked window keeps its navbar size
+ // while being pulled out, a floating window keeps its own size.
+ const dragW = el.offsetWidth;
+ const dragH = el.offsetHeight;
+
+ // Latch for the drop-zone hint so setState fires only when the pointer
+ // actually crosses the navbar boundary, not on every mousemove.
+ let overNavbar = false;
const move = (ev: MouseEvent): void => {
let nl = ol + (ev.clientX - sx);
@@ -385,20 +520,58 @@ export default function AiChatWindow() {
// with position: fixed) with an 8px margin.
nl = Math.max(
EDGE_MARGIN,
- Math.min(nl, window.innerWidth - el.offsetWidth - EDGE_MARGIN),
+ Math.min(nl, window.innerWidth - dragW - EDGE_MARGIN),
);
nt = Math.max(
EDGE_MARGIN,
- Math.min(nt, window.innerHeight - el.offsetHeight - EDGE_MARGIN),
+ Math.min(nt, window.innerHeight - dragH - EDGE_MARGIN),
);
el.style.left = `${nl}px`;
el.style.top = `${nt}px`;
+ // Drop-zone highlight: only meaningful when dragging a FLOATING window in
+ // to dock it (a docked window is already over the navbar).
+ if (!dockedRef.current) {
+ const nowOver = isPointerOverNavbar(ev.clientX, ev.clientY);
+ if (nowOver !== overNavbar) {
+ overNavbar = nowOver;
+ // This re-render would re-apply the committed geom; hand it the live
+ // position so the box does not snap back for a frame.
+ setDragPos({ left: nl, top: nt });
+ setDockHint(nowOver);
+ }
+ }
};
const up = (ev: MouseEvent): void => {
document.removeEventListener("mousemove", move);
document.removeEventListener("mouseup", up);
document.body.style.userSelect = "";
+ setDragPos(null);
+ setDockHint(false);
+ const overNavbarNow = isPointerOverNavbar(ev.clientX, ev.clientY);
+
+ if (dockedRef.current) {
+ // Docked window: releasing OUTSIDE the navbar pops it out as a floating
+ // window at the drop point (clamped to the viewport). Released over the
+ // navbar -> stays docked (a header click is a no-op here). The response
+ // stream is untouched — only the mode flag / geom change.
+ if (!overNavbarNow) {
+ const el2 = winRef.current;
+ const dropLeft = el2 ? parseFloat(el2.style.left) || 0 : 0;
+ const dropTop = el2 ? parseFloat(el2.style.top) || 0 : 0;
+ setGeom((prev) =>
+ clampGeom({
+ ...(prev ?? computeInitialGeom()),
+ left: dropLeft,
+ top: dropTop,
+ }),
+ );
+ setDocked(false);
+ }
+ return;
+ }
+
+ // Floating window.
// Treat a near-zero-movement press as a click (not a drag). When the
// window is minimized, a header click expands it; nothing to persist
// because the position did not change. minimizedRef avoids the stale
@@ -410,6 +583,13 @@ export default function AiChatWindow() {
setMinimized(false);
return;
}
+ // Released over the navbar -> dock. The layout effect then pins the window
+ // to the navbar rect; the last floating geom is left untouched so a later
+ // undock/close restores the remembered floating placement.
+ if (overNavbarNow) {
+ setDocked(true);
+ return;
+ }
const el2 = winRef.current;
// Persist the final position back into state (preserving the size) so
// re-renders keep it.
@@ -432,6 +612,20 @@ export default function AiChatWindow() {
e.preventDefault();
}, []);
+ // Dock/undock via the header button. Docking pins the window to the navbar;
+ // undocking restores the floating window at its last remembered geom. On
+ // undock we re-clamp that geom to the current viewport (matching drag-undock's
+ // clampGeom) so a viewport shrink while docked can't leave the popped-out
+ // window partly off-screen. The chat thread stays mounted across the toggle,
+ // so a live stream is intact. dockedRef gives the live value inside this
+ // useCallback([]) handler.
+ const toggleDock = useCallback((): void => {
+ if (dockedRef.current) {
+ setGeom((prev) => (prev ? clampGeom(prev) : prev));
+ }
+ setDocked((d) => !d);
+ }, [setDocked, setGeom]);
+
// Just toggle the flag. The `.minimized` CSS handles the collapsed height and
// disables resize, and `.minimized .content` hides the body while keeping
// ChatThread mounted (so an in-flight stream is not aborted).
@@ -441,17 +635,45 @@ export default function AiChatWindow() {
if (!windowOpen || !geom) return null;
- return (
-
+
{/* drag bar / header. Mouse users expand a minimized window by clicking
anywhere on the bar (the click-vs-drag logic in startDrag, which
@@ -471,11 +693,11 @@ export default function AiChatWindow() {
is a plain, non-focusable label. */}
{
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
@@ -531,15 +753,39 @@ export default function AiChatWindow() {
)}
)}
+ {/* Dock/undock toggle. Effectively docked -> "Undock" (expand icon) pops
+ the window back out to floating; floating -> "Dock to sidebar"
+ (collapse icon) pins it into the navbar. The LABEL/icon reflect the
+ EFFECTIVE state (useDock), consistent with the Minimize gate: when
+ docked but the navbar is absent/collapsed the window renders floating,
+ so an "Undock" label there would misdescribe a floating window. The
+ action still toggles the raw `docked` atom. */}
+ {/* Minimize (collapse to header) makes no sense while docked — the
+ window fills the navbar — so it is hidden in dock mode. */}
+ {!useDock && (
+
+ )}
- {/* resize affordance icon (drawn manually; native resizer is hidden) */}
- {!minimized && (
+ {/* resize affordance icon (drawn manually; native resizer is hidden).
+ Hidden while docked — the docked size follows the navbar, not a manual
+ resize. */}
+ {!showMinimized && !useDock && (
)}
+ {/* Drop-zone highlight over the navbar while dragging a floating window in
+ to dock it. Sibling of the window (position: fixed) so it tracks the
+ navbar bounds, not the moving window. */}
+ {hintRect && (
+
+ )}
+ >
);
}
diff --git a/apps/client/src/features/ai-chat/utils/dock-helpers.test.ts b/apps/client/src/features/ai-chat/utils/dock-helpers.test.ts
new file mode 100644
index 00000000..e4f4d3a2
--- /dev/null
+++ b/apps/client/src/features/ai-chat/utils/dock-helpers.test.ts
@@ -0,0 +1,58 @@
+import { describe, it, expect } from "vitest";
+import {
+ isPointWithinRect,
+ isNavbarRectVisible,
+ type NavbarRect,
+} from "./dock-helpers.ts";
+
+const NAVBAR: NavbarRect = { left: 0, top: 45, width: 300, height: 800 };
+
+describe("isPointWithinRect", () => {
+ it("returns true for a point inside the navbar", () => {
+ expect(isPointWithinRect(150, 400, NAVBAR)).toBe(true);
+ });
+
+ it("treats the boundary edges as inside (drop exactly on the edge docks)", () => {
+ // Top-left corner and bottom-right corner are both inclusive.
+ expect(isPointWithinRect(0, 45, NAVBAR)).toBe(true);
+ expect(isPointWithinRect(300, 845, NAVBAR)).toBe(true);
+ });
+
+ it("returns false for a point in the content area (to the right)", () => {
+ expect(isPointWithinRect(500, 400, NAVBAR)).toBe(false);
+ });
+
+ it("returns false above the navbar (in the header band)", () => {
+ expect(isPointWithinRect(150, 10, NAVBAR)).toBe(false);
+ });
+
+ it("returns false when the navbar rect is null (absent/collapsed)", () => {
+ expect(isPointWithinRect(150, 400, null)).toBe(false);
+ });
+});
+
+describe("isNavbarRectVisible", () => {
+ it("returns true for a normal on-screen navbar rect", () => {
+ expect(isNavbarRectVisible({ width: 300, height: 800, right: 300 })).toBe(
+ true,
+ );
+ });
+
+ it("returns false for a zero-size rect (width or height 0)", () => {
+ expect(isNavbarRectVisible({ width: 0, height: 800, right: 300 })).toBe(
+ false,
+ );
+ expect(isNavbarRectVisible({ width: 300, height: 0, right: 300 })).toBe(
+ false,
+ );
+ });
+
+ it("returns false when the navbar is translated off-screen (right <= 0)", () => {
+ expect(isNavbarRectVisible({ width: 300, height: 800, right: 0 })).toBe(
+ false,
+ );
+ expect(isNavbarRectVisible({ width: 300, height: 800, right: -50 })).toBe(
+ false,
+ );
+ });
+});
diff --git a/apps/client/src/features/ai-chat/utils/dock-helpers.ts b/apps/client/src/features/ai-chat/utils/dock-helpers.ts
new file mode 100644
index 00000000..9dc3fa7d
--- /dev/null
+++ b/apps/client/src/features/ai-chat/utils/dock-helpers.ts
@@ -0,0 +1,48 @@
+// Pure geometry helper for the AI chat window dock/undock decision (#276). Kept
+// free of React and the DOM so it can be unit-tested in isolation (see
+// dock-helpers.test.ts). The DOM-reading getNavbarRect() lives in the window
+// component; this is only the point-in-rect math that decides dock-on-drop and
+// undock-on-drag-out from the measured navbar rect.
+
+export type NavbarRect = {
+ left: number;
+ top: number;
+ width: number;
+ height: number;
+};
+
+/**
+ * Whether a viewport point (x, y) falls within `rect`. Edges are inclusive so a
+ * drop exactly on the navbar boundary counts as "over the navbar". Returns false
+ * when the rect is null (navbar absent/collapsed) so the caller falls back to the
+ * floating behavior.
+ */
+export function isPointWithinRect(
+ x: number,
+ y: number,
+ rect: NavbarRect | null,
+): boolean {
+ if (!rect) return false;
+ return (
+ x >= rect.left &&
+ x <= rect.left + rect.width &&
+ y >= rect.top &&
+ y <= rect.top + rect.height
+ );
+}
+
+/**
+ * Whether a measured navbar rect represents a VISIBLE navbar. Mantine collapses
+ * the navbar by translating it off-screen (its right edge lands at or left of the
+ * viewport) without changing its width/border-box, so a zero-size or off-screen
+ * rect means "no navbar" — the docked window then falls back to floating instead
+ * of pinning to an invisible box. Pure (no DOM) so it can be unit-tested; the
+ * DOM-reading getNavbarRect() in the window component supplies the rect.
+ */
+export function isNavbarRectVisible(r: {
+ width: number;
+ height: number;
+ right: number;
+}): boolean {
+ return !(r.width === 0 || r.height === 0 || r.right <= 0);
+}
diff --git a/apps/client/src/features/comment/components/comment-hover-preview.test.tsx b/apps/client/src/features/comment/components/comment-hover-preview.test.tsx
new file mode 100644
index 00000000..235fc141
--- /dev/null
+++ b/apps/client/src/features/comment/components/comment-hover-preview.test.tsx
@@ -0,0 +1,434 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { render, screen, act } from "@testing-library/react";
+import { useRef } from "react";
+import { MantineProvider } from "@mantine/core";
+import { IComment } from "@/features/comment/types/comment.types";
+
+// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
+
+// Stub the comments query so the component renders without react-query/network.
+const mockUseCommentsQuery = vi.fn();
+vi.mock("@/features/comment/queries/comment-query", () => ({
+ useCommentsQuery: (params: { pageId: string }) =>
+ mockUseCommentsQuery(params),
+}));
+
+import CommentHoverPreview from "./comment-hover-preview";
+import { commentContentToText } from "@/features/comment/utils/comment-content-to-text";
+
+const doc = (text: string) =>
+ JSON.stringify({
+ type: "doc",
+ content: [{ type: "paragraph", content: [{ type: "text", text }] }],
+ });
+
+const comment = (over?: Partial): IComment =>
+ ({
+ id: "c-1",
+ content: doc("Hello world"),
+ creatorId: "u-1",
+ pageId: "page-1",
+ workspaceId: "ws-1",
+ createdAt: new Date(),
+ creator: { id: "u-1", name: "User", avatarUrl: null } as any,
+ ...over,
+ }) as IComment;
+
+function setComments(items: IComment[]) {
+ mockUseCommentsQuery.mockReturnValue({
+ data: { items, meta: {} },
+ isLoading: false,
+ isError: false,
+ });
+}
+
+// Test harness: owns the container ref, hosts a comment-mark span and the
+// preview component, mirroring how page-editor mounts it next to EditorContent.
+function Harness({
+ spanAttrs = { "data-comment-id": "c-1" },
+ pageId = "page-1",
+}: {
+ spanAttrs?: Record;
+ pageId?: string;
+}) {
+ const containerRef = useRef(null);
+ return (
+
+
+
+ marked text
+
+
+
+
+ );
+}
+
+function hoverMark() {
+ const span = screen.getByTestId("mark");
+ act(() => {
+ span.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
+ });
+}
+
+function leaveMark() {
+ const span = screen.getByTestId("mark");
+ act(() => {
+ span.dispatchEvent(new MouseEvent("mouseout", { bubbles: true }));
+ });
+}
+
+describe("commentContentToText", () => {
+ it("flattens a multi-node ProseMirror doc to plain text", () => {
+ const content = JSON.stringify({
+ type: "doc",
+ content: [
+ {
+ type: "paragraph",
+ content: [
+ { type: "text", text: "Hello " },
+ { type: "text", text: "world" },
+ ],
+ },
+ { type: "paragraph", content: [{ type: "text", text: "Second line" }] },
+ ],
+ });
+ expect(commentContentToText(content)).toBe("Hello world\nSecond line");
+ });
+
+ it("joins nested block structures (lists) on block boundaries", () => {
+ const content = {
+ type: "doc",
+ content: [
+ {
+ type: "bulletList",
+ content: [
+ {
+ type: "listItem",
+ content: [
+ { type: "paragraph", content: [{ type: "text", text: "one" }] },
+ ],
+ },
+ {
+ type: "listItem",
+ content: [
+ { type: "paragraph", content: [{ type: "text", text: "two" }] },
+ ],
+ },
+ ],
+ },
+ ],
+ };
+ expect(commentContentToText(content)).toBe("one\ntwo");
+ });
+
+ it("accepts an already-parsed object", () => {
+ expect(commentContentToText({ type: "doc", content: [] })).toBe("");
+ });
+
+ it("returns '' for empty / missing / malformed content", () => {
+ expect(commentContentToText("")).toBe("");
+ expect(commentContentToText(" ")).toBe("");
+ expect(commentContentToText(undefined)).toBe("");
+ expect(commentContentToText(null)).toBe("");
+ expect(commentContentToText(JSON.stringify({ type: "doc", content: [] }))).toBe(
+ "",
+ );
+ });
+
+ it("falls back to the raw string when content is not JSON", () => {
+ expect(commentContentToText("plain text")).toBe("plain text");
+ });
+
+ it("preserves a hardBreak inside a paragraph as a newline", () => {
+ const content = JSON.stringify({
+ type: "doc",
+ content: [
+ {
+ type: "paragraph",
+ content: [
+ { type: "text", text: "line1" },
+ { type: "hardBreak" },
+ { type: "text", text: "line2" },
+ ],
+ },
+ ],
+ });
+ expect(commentContentToText(content)).toBe("line1\nline2");
+ });
+});
+
+describe("CommentHoverPreview — hover behaviour", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ mockUseCommentsQuery.mockReset();
+ });
+
+ afterEach(() => {
+ vi.runOnlyPendingTimers();
+ vi.useRealTimers();
+ });
+
+ it("shows the parent comment text and author after the open delay", () => {
+ setComments([
+ comment({
+ content: doc("Hello world"),
+ creator: { id: "u-1", name: "Alice", avatarUrl: null } as any,
+ }),
+ ]);
+ render();
+
+ hoverMark();
+ // Before the delay elapses there is no card.
+ expect(screen.queryByTestId("comment-hover-preview")).toBeNull();
+
+ act(() => {
+ vi.advanceTimersByTime(350);
+ });
+ const card = screen.getByTestId("comment-hover-preview");
+ // The line shows "Author: text" — both the author name and the comment text.
+ expect(card.textContent).toContain("Alice:");
+ expect(card.textContent).toContain("Hello world");
+ // The card MUST NOT intercept the mark's click (which opens the side panel):
+ // pointer-events:none is the single property guaranteeing that — lock it so
+ // a regression dropping it from the style object fails here.
+ expect(card.style.pointerEvents).toBe("none");
+ });
+
+ it("renders the whole thread: parent plus replies, each with its author", () => {
+ setComments([
+ comment({
+ id: "c-1",
+ content: doc("Parent comment"),
+ createdAt: new Date("2026-01-01T10:00:00Z"),
+ creator: { id: "u-1", name: "Alice", avatarUrl: null } as any,
+ }),
+ comment({
+ id: "c-3",
+ content: doc("Second reply"),
+ parentCommentId: "c-1",
+ createdAt: new Date("2026-01-01T12:00:00Z"),
+ creator: { id: "u-3", name: "Carol", avatarUrl: null } as any,
+ }),
+ comment({
+ id: "c-2",
+ content: doc("First reply"),
+ parentCommentId: "c-1",
+ createdAt: new Date("2026-01-01T11:00:00Z"),
+ creator: { id: "u-2", name: "Bob", avatarUrl: null } as any,
+ }),
+ ]);
+ render();
+
+ hoverMark();
+ act(() => {
+ vi.advanceTimersByTime(350);
+ });
+ const card = screen.getByTestId("comment-hover-preview");
+
+ // Parent and both replies are present, each as "Author: text".
+ const body = card.textContent ?? "";
+ expect(body).toContain("Alice: Parent comment");
+ expect(body).toContain("Bob: First reply");
+ expect(body).toContain("Carol: Second reply");
+
+ // Replies are ordered by createdAt ascending after the parent
+ // (Parent -> First reply -> Second reply), even though the input was
+ // out of order (Second reply's comment came before First reply's).
+ expect(body.indexOf("Parent comment")).toBeLessThan(
+ body.indexOf("First reply"),
+ );
+ expect(body.indexOf("First reply")).toBeLessThan(
+ body.indexOf("Second reply"),
+ );
+ });
+
+ it("shows the thread even when the parent text is empty but it has replies", () => {
+ setComments([
+ comment({
+ id: "c-1",
+ content: JSON.stringify({ type: "doc", content: [] }),
+ creator: { id: "u-1", name: "Alice", avatarUrl: null } as any,
+ }),
+ comment({
+ id: "c-2",
+ content: doc("A reply"),
+ parentCommentId: "c-1",
+ createdAt: new Date(),
+ creator: { id: "u-2", name: "Bob", avatarUrl: null } as any,
+ }),
+ ]);
+ render();
+
+ hoverMark();
+ act(() => {
+ vi.advanceTimersByTime(350);
+ });
+ const card = screen.getByTestId("comment-hover-preview");
+ expect(card.textContent).toContain("Bob: A reply");
+ });
+
+ it("shows nothing when neither the parent nor its reply has any text", () => {
+ // The card is gated on rows-with-text (not thread length), so a text-less
+ // root whose only reply is also text-less must NOT open an empty card.
+ const emptyDoc = JSON.stringify({ type: "doc", content: [] });
+ setComments([
+ comment({
+ id: "c-1",
+ content: emptyDoc,
+ creator: { id: "u-1", name: "Alice", avatarUrl: null } as any,
+ }),
+ comment({
+ id: "c-2",
+ content: emptyDoc,
+ parentCommentId: "c-1",
+ createdAt: new Date(),
+ creator: { id: "u-2", name: "Bob", avatarUrl: null } as any,
+ }),
+ ]);
+ render();
+
+ hoverMark();
+ act(() => {
+ vi.advanceTimersByTime(350);
+ });
+ expect(screen.queryByTestId("comment-hover-preview")).toBeNull();
+ });
+
+ it("hides on mouseout", () => {
+ setComments([comment()]);
+ render();
+
+ hoverMark();
+ act(() => {
+ vi.advanceTimersByTime(350);
+ });
+ expect(
+ screen.getByTestId("comment-hover-preview").textContent,
+ ).toContain("Hello world");
+
+ leaveMark();
+ expect(screen.queryByTestId("comment-hover-preview")).toBeNull();
+ });
+
+ it("does not show a card for a resolved comment (data-resolved)", () => {
+ setComments([comment()]);
+ render(
+ ,
+ );
+
+ hoverMark();
+ act(() => {
+ vi.advanceTimersByTime(200);
+ });
+ expect(screen.queryByTestId("comment-hover-preview")).toBeNull();
+ });
+
+ it("does not show a card for a resolved comment (resolvedAt set)", () => {
+ setComments([comment({ resolvedAt: new Date() })]);
+ render();
+
+ hoverMark();
+ act(() => {
+ vi.advanceTimersByTime(200);
+ });
+ expect(screen.queryByTestId("comment-hover-preview")).toBeNull();
+ });
+
+ it("does not show a card for an unknown comment id", () => {
+ setComments([comment()]);
+ render();
+
+ hoverMark();
+ act(() => {
+ vi.advanceTimersByTime(200);
+ });
+ expect(screen.queryByTestId("comment-hover-preview")).toBeNull();
+ });
+
+ it("does not show a card when the comment text is empty", () => {
+ setComments([comment({ content: JSON.stringify({ type: "doc", content: [] }) })]);
+ render();
+
+ hoverMark();
+ act(() => {
+ vi.advanceTimersByTime(200);
+ });
+ expect(screen.queryByTestId("comment-hover-preview")).toBeNull();
+ });
+
+ it("hides on scroll", () => {
+ setComments([comment()]);
+ render();
+
+ hoverMark();
+ act(() => {
+ vi.advanceTimersByTime(350);
+ });
+ expect(
+ screen.getByTestId("comment-hover-preview").textContent,
+ ).toContain("Hello world");
+
+ act(() => {
+ window.dispatchEvent(new Event("scroll"));
+ });
+ expect(screen.queryByTestId("comment-hover-preview")).toBeNull();
+ });
+
+ it("hides on mousedown (clicking the mark to open the panel dismisses the card)", () => {
+ setComments([comment()]);
+ render();
+
+ hoverMark();
+ act(() => {
+ vi.advanceTimersByTime(350);
+ });
+ expect(
+ screen.getByTestId("comment-hover-preview").textContent,
+ ).toContain("Hello world");
+
+ const span = screen.getByTestId("mark");
+ act(() => {
+ span.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
+ });
+ expect(screen.queryByTestId("comment-hover-preview")).toBeNull();
+ });
+
+ it("does not hide when the pointer moves WITHIN the same span (anti-flicker)", () => {
+ setComments([comment()]);
+ render();
+
+ hoverMark();
+ act(() => {
+ vi.advanceTimersByTime(350);
+ });
+ expect(screen.queryByTestId("comment-hover-preview")).not.toBeNull();
+
+ // mouseout whose relatedTarget is still inside the span must NOT hide.
+ const span = screen.getByTestId("mark");
+ act(() => {
+ span.dispatchEvent(
+ new MouseEvent("mouseout", { bubbles: true, relatedTarget: span }),
+ );
+ });
+ expect(screen.queryByTestId("comment-hover-preview")).not.toBeNull();
+ });
+
+ it("hides when the page changes", () => {
+ setComments([comment()]);
+ const { rerender } = render();
+
+ hoverMark();
+ act(() => {
+ vi.advanceTimersByTime(350);
+ });
+ expect(screen.queryByTestId("comment-hover-preview")).not.toBeNull();
+
+ act(() => {
+ rerender();
+ });
+ expect(screen.queryByTestId("comment-hover-preview")).toBeNull();
+ });
+});
diff --git a/apps/client/src/features/comment/components/comment-hover-preview.tsx b/apps/client/src/features/comment/components/comment-hover-preview.tsx
new file mode 100644
index 00000000..7ebcc066
--- /dev/null
+++ b/apps/client/src/features/comment/components/comment-hover-preview.tsx
@@ -0,0 +1,267 @@
+import React, { useEffect, useMemo, useRef, useState } from "react";
+import { createPortal } from "react-dom";
+import { Paper, Text } from "@mantine/core";
+import { useCommentsQuery } from "@/features/comment/queries/comment-query";
+import { IComment } from "@/features/comment/types/comment.types";
+import { commentContentToText } from "@/features/comment/utils/comment-content-to-text";
+
+interface CommentHoverPreviewProps {
+ pageId: string;
+ containerRef: React.RefObject;
+}
+
+// Delay before the card appears, to avoid flicker when the pointer quickly
+// passes over comment marks (kept generous so it does not pop up on a passing
+// glance).
+const OPEN_DELAY_MS = 350;
+const CARD_MAX_WIDTH = 360;
+const CARD_MAX_HEIGHT = 300;
+const GAP = 6;
+// Reserve roughly this much room below the span; flip above when it doesn't fit.
+// Match CARD_MAX_HEIGHT so the flip-above decision reserves the real worst-case
+// height — otherwise a tall thread placed below near the viewport bottom passes
+// the "fits below" check and then overflows off-screen (clipped, no scroll).
+const ESTIMATED_CARD_HEIGHT = 300;
+
+// One rendered line of the thread: the author and the comment's plain text,
+// pre-computed at hover time so render stays cheap. Shown as "Author: text".
+interface ThreadRow {
+ id: string;
+ name: string;
+ text: string;
+}
+
+interface HoverState {
+ thread: ThreadRow[];
+ rect: { top: number; bottom: number; left: number };
+}
+
+function isResolved(comment: IComment): boolean {
+ return comment.resolvedAt != null || comment.resolvedById != null;
+}
+
+// Build the thread for a root (parent) comment: the root first, followed by its
+// replies sorted by createdAt ascending. Reads every comment from the map.
+function buildThread(
+ commentMap: Map,
+ root: IComment,
+): ThreadRow[] {
+ const replies: IComment[] = [];
+ commentMap.forEach((comment) => {
+ if (comment.parentCommentId === root.id) replies.push(comment);
+ });
+ replies.sort(
+ (a, b) =>
+ new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
+ );
+
+ return [root, ...replies].map((comment) => ({
+ id: comment.id,
+ name: comment.creator?.name ?? "",
+ text: commentContentToText(comment.content),
+ }));
+}
+
+/**
+ * Shows a small floating card when the user hovers a `.comment-mark` span in the
+ * main editor: the parent comment plus all its replies, one per line as
+ * "Author: text" (plain — no avatars or timestamps). Read-only:
+ * `pointer-events: none` so it never intercepts the mark's click (which opens
+ * the side panel via ACTIVE_COMMENT_EVENT). Resolved/unknown marks show nothing.
+ */
+export default function CommentHoverPreview({
+ pageId,
+ containerRef,
+}: CommentHoverPreviewProps) {
+ const { data } = useCommentsQuery({ pageId });
+
+ // Map of commentId -> comment. The map indexes every comment (parents and
+ // replies) so a thread can be assembled from a single source.
+ const commentMap = useMemo(() => {
+ const map = new Map();
+ data?.items?.forEach((comment) => map.set(comment.id, comment));
+ return map;
+ }, [data]);
+
+ // Read the latest map from the delegated listeners without re-attaching them
+ // every time the comments query refreshes.
+ const commentMapRef = useRef(commentMap);
+ useEffect(() => {
+ commentMapRef.current = commentMap;
+ }, [commentMap]);
+
+ const [hover, setHover] = useState(null);
+ const openTimerRef = useRef | null>(null);
+ const activeSpanRef = useRef(null);
+
+ const clearOpenTimer = () => {
+ if (openTimerRef.current !== null) {
+ clearTimeout(openTimerRef.current);
+ openTimerRef.current = null;
+ }
+ };
+
+ const hide = () => {
+ clearOpenTimer();
+ activeSpanRef.current = null;
+ setHover(null);
+ };
+
+ // Hide and reset when the page changes (the comment set belongs to a page):
+ // the cleanup runs on every pageId change before the effect re-runs.
+ useEffect(() => {
+ return () => hide();
+ }, [pageId]);
+
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container) return;
+
+ const handleMouseOver = (event: MouseEvent) => {
+ const target = event.target as HTMLElement | null;
+ const span = target?.closest(
+ ".comment-mark[data-comment-id]",
+ );
+ if (!span) return;
+
+ const commentId = span.getAttribute("data-comment-id");
+ if (!commentId) return;
+
+ const comment = commentMapRef.current.get(commentId);
+ // Unknown (not loaded yet) or resolved -> no tooltip. Resolved marks also
+ // carry data-resolved="true"; check both the data attribute and the model.
+ if (
+ !comment ||
+ span.hasAttribute("data-resolved") ||
+ isResolved(comment)
+ ) {
+ return;
+ }
+
+ // Already tracking this span: nothing to do (avoids re-building the thread
+ // on every intra-span mousemove).
+ if (span === activeSpanRef.current) return;
+
+ const thread = buildThread(commentMapRef.current, comment);
+ // Show the card only when SOME comment has text. Gating on thread length
+ // could open an empty card (a text-less root whose only reply is also
+ // text-less), since the render filters out empty-text rows.
+ const hasContent = thread.some((row) => row.text.length > 0);
+ if (!hasContent) return;
+
+ activeSpanRef.current = span;
+
+ clearOpenTimer();
+ openTimerRef.current = setTimeout(() => {
+ openTimerRef.current = null;
+ if (activeSpanRef.current !== span || !span.isConnected) return;
+ const rect = span.getBoundingClientRect();
+ setHover({
+ thread,
+ rect: { top: rect.top, bottom: rect.bottom, left: rect.left },
+ });
+ }, OPEN_DELAY_MS);
+ };
+
+ const handleMouseOut = (event: MouseEvent) => {
+ const target = event.target as HTMLElement | null;
+ const span = target?.closest(
+ ".comment-mark[data-comment-id]",
+ );
+ if (!span) return;
+
+ // Ignore moves that stay within the same comment-mark span.
+ const related = event.relatedTarget as HTMLElement | null;
+ if (related && span.contains(related)) return;
+
+ if (span === activeSpanRef.current) hide();
+ };
+
+ // Scroll uses capture so it also catches scrolling inside nested containers.
+ const handleScroll = () => hide();
+ const handleResize = () => hide();
+ // Dismiss on press: clicking a mark opens the side panel, and the card
+ // would otherwise linger (no mouseout fires while the pointer stays put).
+ const handleMouseDown = () => hide();
+
+ container.addEventListener("mouseover", handleMouseOver);
+ container.addEventListener("mouseout", handleMouseOut);
+ container.addEventListener("mousedown", handleMouseDown);
+ window.addEventListener("scroll", handleScroll, true);
+ window.addEventListener("resize", handleResize);
+
+ return () => {
+ container.removeEventListener("mouseover", handleMouseOver);
+ container.removeEventListener("mouseout", handleMouseOut);
+ container.removeEventListener("mousedown", handleMouseDown);
+ window.removeEventListener("scroll", handleScroll, true);
+ window.removeEventListener("resize", handleResize);
+ clearOpenTimer();
+ };
+ }, [containerRef]);
+
+ if (!hover) return null;
+
+ const viewportWidth = window.innerWidth;
+ const viewportHeight = window.innerHeight;
+ // Flip above when there isn't enough room below the span.
+ const placeAbove =
+ hover.rect.bottom + ESTIMATED_CARD_HEIGHT > viewportHeight &&
+ hover.rect.top > ESTIMATED_CARD_HEIGHT;
+
+ const left = Math.max(
+ 8,
+ Math.min(hover.rect.left, viewportWidth - CARD_MAX_WIDTH - 8),
+ );
+
+ const positionStyle: React.CSSProperties = placeAbove
+ ? { bottom: viewportHeight - hover.rect.top + GAP }
+ : { top: hover.rect.bottom + GAP };
+
+ return createPortal(
+
+ {hover.thread
+ // A comment with no plain text (e.g. an image-only reply) adds nothing
+ // to a text preview — skip its line.
+ .filter((row) => row.text.length > 0)
+ .map((row) => (
+
+ {/* "Author: text" — one line per comment, parent then replies. */}
+
+ {row.name}:
+ {" "}
+ {row.text}
+
+ ))}
+ ,
+ document.body,
+ );
+}
diff --git a/apps/client/src/features/comment/utils/comment-content-to-text.ts b/apps/client/src/features/comment/utils/comment-content-to-text.ts
new file mode 100644
index 00000000..97c682ba
--- /dev/null
+++ b/apps/client/src/features/comment/utils/comment-content-to-text.ts
@@ -0,0 +1,71 @@
+/**
+ * Flatten a comment's ProseMirror JSON document to plain text.
+ *
+ * `IComment.content` is stored as a stringified ProseMirror doc, but this also
+ * accepts an already-parsed object. Walks the node tree, concatenating `text`
+ * leaves and joining text-bearing blocks with newlines. Missing, empty or
+ * malformed content yields an empty string (never throws).
+ */
+export function commentContentToText(content: unknown): string {
+ let doc: any = content;
+
+ if (typeof content === "string") {
+ const trimmed = content.trim();
+ if (!trimmed) return "";
+ try {
+ doc = JSON.parse(trimmed);
+ } catch {
+ // Not JSON — fall back to treating the raw string as plain text.
+ return trimmed;
+ }
+ }
+
+ if (!doc || typeof doc !== "object") return "";
+
+ const blocks: string[] = [];
+
+ const walk = (node: any): void => {
+ if (!node || typeof node !== "object") return;
+
+ if (typeof node.text === "string") {
+ // Inline text leaf: append to the current block line.
+ if (blocks.length === 0) blocks.push("");
+ blocks[blocks.length - 1] += node.text;
+ return;
+ }
+
+ if (node.type === "hardBreak") {
+ // A soft line break inside a block: keep the newline so the two halves
+ // do not run together.
+ if (blocks.length === 0) blocks.push("");
+ blocks[blocks.length - 1] += "\n";
+ return;
+ }
+
+ const children = Array.isArray(node.content) ? node.content : [];
+ const containsText = children.some(
+ (child: any) =>
+ child && typeof child === "object" && typeof child.text === "string",
+ );
+
+ if (containsText) {
+ // Text-bearing block (paragraph, heading, ...): start a fresh line, then
+ // collect its inline text.
+ blocks.push("");
+ children.forEach(walk);
+ return;
+ }
+
+ // Structural container (doc, list, blockquote, ...): recurse so each nested
+ // text block becomes its own line.
+ children.forEach(walk);
+ };
+
+ walk(doc);
+
+ return blocks
+ .map((block) => block.trim())
+ .filter((block) => block.length > 0)
+ .join("\n")
+ .trim();
+}
diff --git a/apps/client/src/features/dictation/hooks/use-streaming-dictation.test.tsx b/apps/client/src/features/dictation/hooks/use-streaming-dictation.test.tsx
new file mode 100644
index 00000000..48f7fb25
--- /dev/null
+++ b/apps/client/src/features/dictation/hooks/use-streaming-dictation.test.tsx
@@ -0,0 +1,206 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { renderHook, act } from "@testing-library/react";
+
+// Shared, hoisted test state the module mocks write into. `onSpeechEnd` is the
+// VAD callback the hook registers on MicVAD.new — capturing it lets us drive
+// "a speech segment ended" deterministically. `pending` collects the deferred
+// transcription promises so the test controls their resolution order, which is
+// the whole point: out-of-order HTTP responses must NOT scramble the emitted
+// text (the in-order emitter under test).
+const h = vi.hoisted(() => {
+ return {
+ onSpeechEnd: null as null | ((audio: Float32Array) => void),
+ pending: [] as { resolve: (s: string) => void; reject: (e: unknown) => void }[],
+ notify: null as null | ReturnType,
+ };
+});
+
+// Lazy-imported VAD: capture the onSpeechEnd handler and hand back a no-op
+// instance (start/pause/destroy all resolve).
+vi.mock("@ricky0123/vad-web", () => ({
+ MicVAD: {
+ new: vi.fn(async (opts: { onSpeechEnd: (a: Float32Array) => void }) => {
+ h.onSpeechEnd = opts.onSpeechEnd;
+ return {
+ start: vi.fn(async () => {}),
+ pause: vi.fn(async () => {}),
+ destroy: vi.fn(async () => {}),
+ };
+ }),
+ },
+}));
+
+// Each transcribeAudio call returns a promise we resolve/reject by index.
+vi.mock("@/features/dictation/services/dictation-service", () => ({
+ transcribeAudio: vi.fn(
+ () =>
+ new Promise((resolve, reject) => {
+ h.pending.push({ resolve, reject });
+ }),
+ ),
+}));
+
+// Avoid real WAV encoding; the segment payload is irrelevant to ordering.
+vi.mock("@/features/dictation/utils/encode-wav", () => ({
+ encodeWavPcm16: vi.fn(() => new Blob()),
+}));
+
+const notifyShow = vi.fn();
+vi.mock("@mantine/notifications", () => ({
+ notifications: { show: (...args: unknown[]) => notifyShow(...args) },
+}));
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({ t: (s: string) => s }),
+}));
+
+import { useStreamingDictation } from "./use-streaming-dictation";
+
+// jsdom has no AudioContext; the hook constructs one and calls resume(). A
+// trivial stub is enough — the real audio path is irrelevant to ordering.
+class FakeAudioContext {
+ state = "running";
+ resume() {
+ return Promise.resolve();
+ }
+ close() {
+ this.state = "closed";
+ return Promise.resolve();
+ }
+}
+
+async function startRecording(onText: (t: string) => void) {
+ const hook = renderHook(() => useStreamingDictation({ onText }));
+ await act(async () => {
+ await hook.result.current.start();
+ });
+ // The VAD registered its onSpeechEnd and start() resolved into "recording".
+ expect(h.onSpeechEnd).toBeTypeOf("function");
+ expect(hook.result.current.status).toBe("recording");
+ return hook;
+}
+
+// Fire N ended speech segments (seq 0..N-1), each kicking off one transcription.
+async function emitSegments(n: number) {
+ await act(async () => {
+ for (let i = 0; i < n; i++) h.onSpeechEnd!(new Float32Array(8));
+ });
+}
+
+describe("useStreamingDictation — in-order segment emitter", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ h.onSpeechEnd = null;
+ h.pending = [];
+ notifyShow.mockClear();
+ (window as unknown as { AudioContext: unknown }).AudioContext =
+ FakeAudioContext;
+ });
+
+ it("emits transcriptions in segment order even when responses resolve out of order", async () => {
+ const emitted: string[] = [];
+ await startRecording((t) => emitted.push(t));
+ await emitSegments(3);
+ expect(h.pending).toHaveLength(3);
+
+ // Resolve seq 1 FIRST: it must be buffered, not emitted, because seq 0 is
+ // still outstanding (nextEmit == 0).
+ await act(async () => {
+ h.pending[1].resolve("second");
+ });
+ expect(emitted).toEqual([]);
+
+ // Resolve seq 0: this unblocks the buffer and flushes 0 then 1 in order.
+ await act(async () => {
+ h.pending[0].resolve("first");
+ });
+ expect(emitted).toEqual(["first", "second"]);
+
+ // seq 2 resolves last and flushes immediately (it is now next).
+ await act(async () => {
+ h.pending[2].resolve("third");
+ });
+ expect(emitted).toEqual(["first", "second", "third"]);
+ });
+
+ it("trims whitespace and drops empty/whitespace-only transcriptions while still advancing", async () => {
+ const emitted: string[] = [];
+ await startRecording((t) => emitted.push(t));
+ await emitSegments(3);
+
+ await act(async () => {
+ h.pending[0].resolve(" hello "); // leading/trailing space trimmed
+ h.pending[1].resolve(" "); // whitespace-only -> not emitted, but seq advances
+ h.pending[2].resolve("world");
+ });
+
+ expect(emitted).toEqual(["hello", "world"]);
+ });
+
+ it("a failed segment shows one notification and is skipped so later segments still flush in order", async () => {
+ const emitted: string[] = [];
+ await startRecording((t) => emitted.push(t));
+ await emitSegments(2);
+
+ // seq 0 fails: the user sees a notification and the emitter advances past it.
+ await act(async () => {
+ h.pending[0].reject({ message: "boom" });
+ });
+ expect(notifyShow).toHaveBeenCalledTimes(1);
+ expect(emitted).toEqual([]);
+
+ // seq 1 still flushes (it is now next), proving one failure did not stall.
+ await act(async () => {
+ h.pending[1].resolve("survivor");
+ });
+ expect(emitted).toEqual(["survivor"]);
+ });
+
+ it("an OUT-OF-ORDER failed segment is buffered as empty and skipped without stalling later text", async () => {
+ const emitted: string[] = [];
+ await startRecording((t) => emitted.push(t));
+ await emitSegments(3);
+
+ // seq 1 (NOT next-to-emit) fails first: it takes the else branch — an empty
+ // placeholder is buffered (resultsRef.set(seq, "")) so the emitter can later
+ // skip it. One notification, nothing emitted yet (seq 0 still gates).
+ await act(async () => {
+ h.pending[1].reject({ message: "boom" });
+ });
+ expect(notifyShow).toHaveBeenCalledTimes(1);
+ expect(emitted).toEqual([]);
+
+ // seq 0 flushes; the drain then reaches the buffered empty seq 1 and SKIPS
+ // past it to seq 2.
+ await act(async () => {
+ h.pending[0].resolve("alpha");
+ });
+ expect(emitted).toEqual(["alpha"]);
+
+ // seq 2 emits — proving the empty placeholder let the emitter advance past
+ // the failed seq 1. Without the else branch's placeholder the drain would
+ // stall at the missing seq 1 and "gamma" would never flush.
+ await act(async () => {
+ h.pending[2].resolve("gamma");
+ });
+ expect(emitted).toEqual(["alpha", "gamma"]);
+ });
+
+ it("ignores a transcription that resolves AFTER cancel() (stale epoch — no emit)", async () => {
+ const emitted: string[] = [];
+ const hook = await startRecording((t) => emitted.push(t));
+ await emitSegments(1);
+
+ // Hard discard the session: the in-flight request is now stale.
+ act(() => {
+ hook.result.current.cancel();
+ });
+ expect(hook.result.current.status).toBe("idle");
+
+ // Its late resolution must be dropped (no emit into the new/empty session).
+ await act(async () => {
+ h.pending[0].resolve("late");
+ });
+ expect(emitted).toEqual([]);
+ });
+});
diff --git a/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx b/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx
index 651cb2f6..be7e311e 100644
--- a/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx
+++ b/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx
@@ -1,7 +1,14 @@
import { BubbleMenu, BubbleMenuProps } from "@tiptap/react/menus";
import { isNodeSelection, useEditorState } from "@tiptap/react";
import type { Editor } from "@tiptap/react";
-import { FC, useEffect, useRef, useState } from "react";
+import {
+ ComponentType,
+ CSSProperties,
+ FC,
+ useEffect,
+ useRef,
+ useState,
+} from "react";
import {
IconBold,
IconCode,
@@ -10,6 +17,7 @@ import {
IconUnderline,
IconMessage,
IconEyeOff,
+ IconClearFormatting,
} from "@tabler/icons-react";
import clsx from "clsx";
import classes from "./bubble-menu.module.css";
@@ -28,12 +36,46 @@ import { LinkSelector } from "@/features/editor/components/bubble-menu/link-sele
import { useTranslation } from "react-i18next";
import { showLinkMenuAtom } from "@/features/editor/atoms/editor-atoms";
import { userAtom } from "@/features/user/atoms/current-user-atom";
+import {
+ hasStressAfterSelection,
+ toggleStressAccent,
+} from "./stress-accent";
+
+// Tabler has no acute-accent glyph (IconGrave is a tombstone), so we ship a
+// tiny local icon that mirrors the Tabler icon API ({ style, stroke }).
+function IconStress({
+ style,
+ stroke = 2,
+}: {
+ style?: React.CSSProperties;
+ stroke?: string | number;
+}) {
+ return (
+
+ );
+}
export interface BubbleMenuItem {
name: string;
isActive: () => boolean;
command: () => void;
- icon: typeof IconBold;
+ // Rendered as , so the real contract is
+ // just { style?, stroke? }. stroke is string|number to match Tabler's own prop
+ // type; Tabler icons and the local IconStress both satisfy it (no cast needed).
+ icon: ComponentType<{ style?: CSSProperties; stroke?: string | number }>;
}
type EditorBubbleMenuProps = Omit & {
@@ -76,6 +118,8 @@ export const EditorBubbleMenu: FC = (props) => {
isCode: ctx.editor.isActive("code"),
isComment: ctx.editor.isActive("comment"),
isSpoiler: ctx.editor.isActive("spoiler"),
+ // A stress accent already sits right after the selection end.
+ isStress: hasStressAfterSelection(ctx.editor.state),
};
},
});
@@ -117,6 +161,26 @@ export const EditorBubbleMenu: FC = (props) => {
command: () => props.editor.chain().focus().toggleSpoiler().run(),
icon: IconEyeOff,
},
+ {
+ name: "Stress",
+ isActive: () => editorState?.isStress,
+ // Toggle the U+0301 combining accent right after the selected letter.
+ // The whole toggle is a single transaction, so one Ctrl+Z reverts it.
+ command: () => {
+ const editor = props.editor;
+ editor.view.dispatch(toggleStressAccent(editor.state));
+ editor.view.focus();
+ },
+ icon: IconStress,
+ },
+ {
+ 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 = {
diff --git a/apps/client/src/features/editor/components/bubble-menu/stress-accent.test.ts b/apps/client/src/features/editor/components/bubble-menu/stress-accent.test.ts
new file mode 100644
index 00000000..db6203a0
--- /dev/null
+++ b/apps/client/src/features/editor/components/bubble-menu/stress-accent.test.ts
@@ -0,0 +1,94 @@
+import { describe, expect, it } from "vitest";
+import { Schema } from "@tiptap/pm/model";
+import { EditorState, TextSelection } from "@tiptap/pm/state";
+import {
+ STRESS_ACCENT,
+ hasStressAfterSelection,
+ toggleStressAccent,
+} from "./stress-accent";
+
+// Minimal ProseMirror schema: paragraph of text with a single `bold` mark.
+const schema = new Schema({
+ nodes: {
+ doc: { content: "block+" },
+ paragraph: {
+ group: "block",
+ content: "text*",
+ toDOM: () => ["p", 0],
+ },
+ text: { group: "inline" },
+ },
+ marks: {
+ bold: { toDOM: () => ["strong", 0] },
+ },
+});
+
+function makeState(
+ text: string,
+ from: number,
+ to: number,
+ marked = false,
+): EditorState {
+ const marks = marked ? [schema.marks.bold.create()] : [];
+ const textNode = schema.text(text, marks);
+ const doc = schema.node("doc", null, [
+ schema.node("paragraph", null, [textNode]),
+ ]);
+ const state = EditorState.create({ schema, doc });
+ return state.apply(
+ state.tr.setSelection(TextSelection.create(state.doc, from, to)),
+ );
+}
+
+describe("stress-accent", () => {
+ it("uses U+0301 as the combining accent", () => {
+ expect(STRESS_ACCENT).toHaveLength(1);
+ expect(STRESS_ACCENT.codePointAt(0)).toBe(0x0301);
+ });
+
+ it("inserts the accent right after the selected vowel", () => {
+ // "кот", select "о" (positions 2..3).
+ const state = makeState("кот", 2, 3);
+ expect(hasStressAfterSelection(state)).toBe(false);
+
+ const next = state.apply(toggleStressAccent(state));
+ expect(next.doc.textContent).toBe(`ко${STRESS_ACCENT}т`);
+ // Selection is preserved on the letter, so the button reads active.
+ expect(next.selection.from).toBe(2);
+ expect(next.selection.to).toBe(3);
+ expect(hasStressAfterSelection(next)).toBe(true);
+ });
+
+ it("removes the accent on a second toggle (round-trips to original)", () => {
+ const state = makeState("кот", 2, 3);
+ const inserted = state.apply(toggleStressAccent(state));
+ const removed = inserted.apply(toggleStressAccent(inserted));
+
+ expect(removed.doc.textContent).toBe("кот");
+ expect(hasStressAfterSelection(removed)).toBe(false);
+ expect(removed.selection.from).toBe(2);
+ expect(removed.selection.to).toBe(3);
+ });
+
+ it("inherits the letter's marks so the accent stays bold", () => {
+ // Whole word is bold; select "о".
+ const state = makeState("кот", 2, 3, true);
+ const next = state.apply(toggleStressAccent(state));
+
+ // The accent lands at positions 3..4 (right after "о")...
+ expect(next.doc.textBetween(3, 4)).toBe(STRESS_ACCENT);
+ // ...inside a bold text node, so it inherits the letter's bold mark.
+ const accentNode = next.doc.nodeAt(3);
+ expect(accentNode?.marks.some((m) => m.type.name === "bold")).toBe(true);
+ });
+
+ it("handles a selection at the end of the doc without throwing", () => {
+ // "а" is the whole paragraph; select it (1..2), end of content.
+ const state = makeState("а", 1, 2);
+ expect(hasStressAfterSelection(state)).toBe(false);
+
+ const next = state.apply(toggleStressAccent(state));
+ expect(next.doc.textContent).toBe(`а${STRESS_ACCENT}`);
+ expect(hasStressAfterSelection(next)).toBe(true);
+ });
+});
diff --git a/apps/client/src/features/editor/components/bubble-menu/stress-accent.ts b/apps/client/src/features/editor/components/bubble-menu/stress-accent.ts
new file mode 100644
index 00000000..b8e76a32
--- /dev/null
+++ b/apps/client/src/features/editor/components/bubble-menu/stress-accent.ts
@@ -0,0 +1,41 @@
+import { EditorState, TextSelection, Transaction } from "@tiptap/pm/state";
+
+// U+0301 COMBINING ACUTE ACCENT — a plain Unicode combining char inserted
+// right after a vowel to render a Russian-style stress accent over it.
+// It is stored as literal text (not a TipTap mark), so it survives HTML/
+// Markdown export, full-text search and public share with zero server or
+// converter changes.
+export const STRESS_ACCENT = "́";
+
+// True when a stress accent already sits immediately after the selection end
+// (the single char following the selection). Used both for the toolbar
+// active state and to decide the toggle direction.
+export function hasStressAfterSelection(state: EditorState): boolean {
+ const { to } = state.selection;
+ const docSize = state.doc.content.size;
+ // Clamp to the doc size so a selection at the very end never reads past it.
+ const afterChar = state.doc.textBetween(to, Math.min(to + 1, docSize));
+ return afterChar === STRESS_ACCENT;
+}
+
+// Build a single transaction that toggles the stress accent after the
+// selection. One transaction => one undo step (Ctrl+Z reverts the toggle).
+export function toggleStressAccent(state: EditorState): Transaction {
+ const { from, to } = state.selection;
+ const tr = state.tr;
+
+ if (hasStressAfterSelection(state)) {
+ // Toggle off: drop the accent that immediately follows the letter.
+ tr.delete(to, to + 1);
+ } else {
+ // Toggle on: insertText inherits the marks at `to`, so the accent lands
+ // in the same text node as the letter and renders over it even when the
+ // letter is bold / italic / colored.
+ tr.insertText(STRESS_ACCENT, to);
+ }
+
+ // Restore the original selection so the accented letter stays highlighted
+ // and a re-click toggles the accent back off.
+ tr.setSelection(TextSelection.create(tr.doc, from, to));
+ return tr;
+}
diff --git a/apps/client/src/features/editor/components/code-block/code-block-view.test.tsx b/apps/client/src/features/editor/components/code-block/code-block-view.test.tsx
new file mode 100644
index 00000000..0048b45c
--- /dev/null
+++ b/apps/client/src/features/editor/components/code-block/code-block-view.test.tsx
@@ -0,0 +1,68 @@
+import { describe, it, expect, vi } from "vitest";
+import { render } from "@testing-library/react";
+
+// Covers the read-only render branch (PR #278): the language