Compare commits

..

1 Commits

Author SHA1 Message Date
claude_code 4af21494af fix(editor): stop the title auto-focus from yanking scroll on reload (#266)
Root cause (confirmed via Chrome DevTools on the live app): the reading-position
restore jittered on reload — it landed at the saved spot, jumped to the top, then
back. The jump was NOT a height collapse: the title editor auto-focuses ~300ms
after mount, and TipTap's focus scrolls the focused node into view. Since the
title sits at the top of the page, that yanked window scroll to the top.

Minimal fix (the fast restore mechanism is left unchanged):
- Focus the title with { scrollIntoView: false } so placing the caret no longer
  moves the viewport.
- Skip the title auto-focus entirely when a saved reading position will be
  restored (otherwise the caret lands in the now-off-screen title). Exported
  hasSavedReadingPosition() as the single source of truth.
- Extracted the decision into a testable useTitleAutofocus hook (which also adds
  a clearTimeout cleanup, fixing a pre-existing uncancelled/destroyed-editor
  timer), and covered it + hasSavedReadingPosition with unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 05:15:28 +03:00
25 changed files with 358 additions and 1185 deletions
@@ -1222,8 +1222,8 @@
"Commented": "Commented",
"Resolved comment": "Resolved comment",
"Ran tool {{name}}": "Ran tool {{name}}",
"AI agent «{{role}}» on behalf of {{person}}": "AI agent «{{role}}» on behalf of {{person}}",
"AI agent {{name}}": "AI agent {{name}}",
"AI-agent": "AI-agent",
"Edited by AI agent on behalf of {{name}}": "Edited by AI agent on behalf of {{name}}",
"Endpoints": "Endpoints",
"where we fetch models": "where we fetch models",
"All endpoints are OpenAI-compatible. Point the Base URL at OpenAI, OpenRouter, a local Ollama, or any self-hosted server.": "All endpoints are OpenAI-compatible. Point the Base URL at OpenAI, OpenRouter, a local Ollama, or any self-hosted server.",
@@ -724,8 +724,7 @@
"Shown as used / total in the chat header. Leave empty to hide the limit.": "Показывается в шапке чата как использовано / всего. Пусто — лимит скрыт.",
"Delete this chat?": "Удалить этот чат?",
"Deleted successfully": "Успешно удалено",
"AI agent «{{role}}» on behalf of {{person}}": "AI-агент «{{role}}» от имени {{person}}",
"AI agent {{name}}": "AI-агент {{name}}",
"Edited by AI agent on behalf of {{name}}": "Отредактировано AI-агентом от имени {{name}}",
"Failed to delete chat": "Не удалось удалить чат",
"Failed to rename chat": "Не удалось переименовать чат",
"Failed": "Ошибка",
@@ -1,101 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { Provider, createStore } from "jotai";
import { AgentAvatarStack } from "./agent-avatar-stack";
import {
activeAiChatIdAtom,
aiChatWindowOpenAtom,
aiChatDraftAtom,
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
type Props = React.ComponentProps<typeof AgentAvatarStack>;
function renderStack(props: Props) {
const store = createStore();
store.set(aiChatDraftAtom, "leftover draft from another chat");
const utils = render(
<Provider store={store}>
<MantineProvider>
<AgentAvatarStack {...props} />
</MantineProvider>
</Provider>,
);
return { store, ...utils };
}
describe("AgentAvatarStack", () => {
it("internal chat WITH role: emoji glyph in front + human launcher behind", () => {
const { container } = renderStack({
agent: { name: "Researcher", emoji: "🔬", avatarUrl: null },
launcher: { name: "Alice", avatarUrl: null },
aiChatId: "chat-1",
});
// Emoji is used as the glyph (priority 2), NOT the sparkles fallback.
expect(screen.getByText("🔬")).toBeDefined();
expect(container.querySelector(".tabler-icon-sparkles")).toBeNull();
// Label: bold role name + dimmed "· launcher".
expect(screen.getByText("Researcher")).toBeDefined();
expect(screen.getByText(/·/)).toBeDefined();
expect(screen.getByText("Alice")).toBeDefined();
});
it("internal chat WITHOUT role: sparkles fallback + 'AI agent' + launcher", () => {
const { container } = renderStack({
agent: { name: "AI agent", avatarUrl: null },
launcher: { name: "Bob", avatarUrl: null },
aiChatId: "chat-2",
});
// No avatarUrl and no emoji => sparkles glyph (priority 3).
expect(container.querySelector(".tabler-icon-sparkles")).not.toBeNull();
expect(screen.getByText("AI agent")).toBeDefined();
expect(screen.getByText("Bob")).toBeDefined();
});
it("external MCP: agent avatar in front, NO launcher behind", () => {
const { container } = renderStack({
agent: { name: "MCP Bot", avatarUrl: "http://example.test/a.png" },
launcher: null,
aiChatId: null,
});
// avatarUrl provided (priority 1) => not the sparkles fallback.
expect(container.querySelector(".tabler-icon-sparkles")).toBeNull();
expect(screen.getByText("MCP Bot")).toBeDefined();
// No human behind => no "·" separator is rendered.
expect(screen.queryByText(/·/)).toBeNull();
// No internal chat => the stack is not an interactive deep-link button.
expect(screen.queryByRole("button")).toBeNull();
});
it("click deep-links into the chat when aiChatId is present", () => {
const { store } = renderStack({
agent: { name: "Researcher", emoji: "🔬", avatarUrl: null },
launcher: { name: "Alice", avatarUrl: null },
aiChatId: "chat-1",
});
const button = screen.getByRole("button");
fireEvent.click(button);
expect(store.get(activeAiChatIdAtom)).toBe("chat-1");
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
expect(store.get(aiChatDraftAtom)).toBe(""); // draft cleared on switch
});
it("click is a no-op / not interactive without a chat target", () => {
const onActivate = vi.fn();
renderStack({
agent: { name: "MCP Bot", avatarUrl: "http://example.test/a.png" },
launcher: null,
aiChatId: null,
onActivate,
});
expect(screen.queryByRole("button")).toBeNull();
expect(onActivate).not.toHaveBeenCalled();
});
});
@@ -1,183 +0,0 @@
import { Avatar, Box, Group, Text, Tooltip } from "@mantine/core";
import { IconSparkles } from "@tabler/icons-react";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useSetAtom } from "jotai";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import {
activeAiChatIdAtom,
aiChatWindowOpenAtom,
aiChatDraftAtom,
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
// The FRONT identity (the acting agent) and the BEHIND identity (the human who
// launched it). Both are computed server-side (#300) so the client never branches
// on the internal-vs-MCP provenance — it just renders whatever it is handed.
export interface AgentInfo {
name: string;
emoji?: string | null;
avatarUrl?: string | null;
}
export interface LauncherInfo {
name: string;
avatarUrl?: string | null;
}
// Same violet token as the former AiAgentBadge (which used color="violet").
const AGENT_COLOR = "violet";
const GLYPH_SIZE = 38;
const LAUNCHER_SIZE = 22;
/**
* The front avatar. Image-source priority (#300):
* 1. agent.avatarUrl -> a real avatar image (external MCP agent account).
* 2. agent.emoji -> the role emoji on a violet circle.
* 3. otherwise -> the IconSparkles glyph on a violet circle (fallback).
*/
function AgentGlyph({ agent }: { agent: AgentInfo }) {
if (agent.avatarUrl) {
return (
<CustomAvatar
size={GLYPH_SIZE}
avatarUrl={agent.avatarUrl}
name={agent.name}
/>
);
}
if (agent.emoji) {
return (
<Avatar size={GLYPH_SIZE} radius="xl" color={AGENT_COLOR} variant="filled">
<span style={{ fontSize: Math.round(GLYPH_SIZE * 0.5) }} aria-hidden>
{agent.emoji}
</span>
</Avatar>
);
}
return (
<Avatar size={GLYPH_SIZE} radius="xl" color={AGENT_COLOR} variant="filled">
<IconSparkles size={Math.round(GLYPH_SIZE * 0.55)} stroke={2} />
</Avatar>
);
}
export interface AgentAvatarStackProps {
agent: AgentInfo;
// null/absent => external MCP (front agent avatar only, no human behind).
launcher?: LauncherInfo | null;
// Deep-links into the internal AI chat when present (null for external MCP).
aiChatId?: string | null;
// Fired after the stack deep-links into its chat, so the caller can react
// (e.g. the page-history row closes the history modal). Keeps this ui/ primitive
// free of cross-feature coupling (inherited from the old AiAgentBadge, #143).
onActivate?: () => void;
}
/**
* The "agent avatar stack" (#300): the AGENT glyph in front, and — for an
* internal AI chat — the HUMAN who launched it as a smaller avatar offset behind.
* Replaces the old text `AI-agent` badge. When the item carries an `aiChatId` the
* whole stack is a deep-link into that chat (the click the old badge owned moved
* here); the click is contained (stopPropagation) so it does not also trigger an
* enclosing row handler.
*/
export function AgentAvatarStack({
agent,
launcher,
aiChatId,
onActivate,
}: AgentAvatarStackProps) {
const { t } = useTranslation();
const setAiChatWindowOpen = useSetAtom(aiChatWindowOpenAtom);
const setActiveChatId = useSetAtom(activeAiChatIdAtom);
const setDraft = useSetAtom(aiChatDraftAtom);
const clickable = !!aiChatId;
const openChat = useCallback(
(event: React.SyntheticEvent) => {
event.stopPropagation();
if (!aiChatId) return;
setActiveChatId(aiChatId);
// Switching chats must start with a clean composer — clear any unsent draft
// so it does not leak from the previously open chat.
setDraft("");
setAiChatWindowOpen(true);
onActivate?.();
},
[aiChatId, setActiveChatId, setDraft, setAiChatWindowOpen, onActivate],
);
// Internal chat => "role on behalf of person"; external MCP => just the agent.
const tooltip = launcher
? t("AI agent «{{role}}» on behalf of {{person}}", {
role: agent.name,
person: launcher.name,
})
: t("AI agent {{name}}", { name: agent.name });
const stack = (
<Box
pos="relative"
style={{
width: GLYPH_SIZE,
height: GLYPH_SIZE,
flexShrink: 0,
cursor: clickable ? "pointer" : undefined,
}}
{...(clickable
? {
role: "button",
tabIndex: 0,
onClick: openChat,
onKeyDown: (event: React.KeyboardEvent) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openChat(event);
}
},
}
: {})}
>
{launcher && (
<Box pos="absolute" bottom={0} right={0} style={{ zIndex: 0 }}>
<CustomAvatar
size={LAUNCHER_SIZE}
avatarUrl={launcher.avatarUrl}
name={launcher.name}
style={{ border: "2px solid var(--mantine-color-body)" }}
/>
</Box>
)}
<Box pos="relative" style={{ zIndex: 1 }}>
<AgentGlyph agent={agent} />
</Box>
</Box>
);
return (
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
<Tooltip label={tooltip} withArrow>
{stack}
</Tooltip>
<Group gap={4} wrap="nowrap" style={{ minWidth: 0 }}>
<Text size="xs" fw={600} lineClamp={1} lh={1.2}>
{agent.name}
</Text>
{launcher && (
<>
<Text size="xs" c="dimmed" fw={400} aria-hidden>
·
</Text>
<Text size="xs" c="dimmed" fw={400} lineClamp={1} lh={1.2}>
{launcher.name}
</Text>
</>
)}
</Group>
</Group>
);
}
export default AgentAvatarStack;
@@ -0,0 +1,96 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { Provider, createStore } from "jotai";
import { AiAgentBadge } from "./ai-agent-badge";
import {
activeAiChatIdAtom,
aiChatWindowOpenAtom,
aiChatDraftAtom,
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
function renderBadge(props: { authorName?: string; aiChatId?: string | null }) {
return render(
<MantineProvider>
<AiAgentBadge {...props} />
</MantineProvider>,
);
}
// Render a clickable badge inside an explicit jotai store, with a leftover draft
// and an onActivate + parent-click spy, so the deep-link side effects are
// assertable. Returns the store and spies.
function setupClickable() {
const store = createStore();
store.set(aiChatDraftAtom, "leftover draft from another chat");
const onActivate = vi.fn();
const onParentClick = vi.fn();
render(
<Provider store={store}>
<MantineProvider>
<div onClick={onParentClick}>
<AiAgentBadge authorName="Bot" aiChatId="chat-1" onActivate={onActivate} />
</div>
</MantineProvider>
</Provider>,
);
return { store, onActivate, onParentClick, badge: screen.getByRole("button") };
}
function expectDeepLinked(store: ReturnType<typeof createStore>, onActivate: ReturnType<typeof vi.fn>) {
expect(store.get(activeAiChatIdAtom)).toBe("chat-1");
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
expect(store.get(aiChatDraftAtom)).toBe(""); // draft cleared
expect(onActivate).toHaveBeenCalledTimes(1); // caller closes its own modal etc.
}
describe("AiAgentBadge", () => {
it("renders the AI-agent label", () => {
renderBadge({ authorName: "Bot" });
expect(screen.getByText("AI-agent")).toBeDefined();
});
it("is clickable (accessible button) when aiChatId is present", () => {
renderBadge({ authorName: "Bot", aiChatId: "chat-1" });
const badge = screen.getByRole("button");
expect(badge).toBeDefined();
expect(badge.textContent).toContain("AI-agent");
});
it("click deep-links: sets active chat, clears draft, opens window, fires onActivate, stops propagation", () => {
const { store, onActivate, onParentClick, badge } = setupClickable();
fireEvent.click(badge);
expectDeepLinked(store, onActivate);
expect(onParentClick).not.toHaveBeenCalled(); // stopPropagation contained the click
});
it.each(["Enter", " "])(
"keyboard %j activates the deep-link (same side effects as click)",
(key) => {
const { store, onActivate, badge } = setupClickable();
fireEvent.keyDown(badge, { key });
expectDeepLinked(store, onActivate);
},
);
it("an unrelated key does NOT activate the badge", () => {
const { store, onActivate, badge } = setupClickable();
fireEvent.keyDown(badge, { key: "Tab" });
expect(store.get(activeAiChatIdAtom)).toBeNull();
expect(store.get(aiChatWindowOpenAtom)).toBe(false);
expect(store.get(aiChatDraftAtom)).toBe("leftover draft from another chat");
expect(onActivate).not.toHaveBeenCalled();
});
it.each([{ aiChatId: null }, {}])(
"is a plain non-clickable label without a chat target (%o)",
(props) => {
renderBadge({ authorName: "Bot", ...props });
expect(screen.getByText("AI-agent")).toBeDefined();
// No interactive role is exposed when there is no chat to deep-link into.
expect(screen.queryByRole("button")).toBeNull();
},
);
});
@@ -0,0 +1,99 @@
import { Badge, Tooltip } from "@mantine/core";
import { IconSparkles } from "@tabler/icons-react";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useSetAtom } from "jotai";
import {
activeAiChatIdAtom,
aiChatWindowOpenAtom,
aiChatDraftAtom,
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
interface AiAgentBadgeProps {
authorName?: string;
aiChatId?: string | null;
// Fired after the badge deep-links into its chat. The caller handles its own
// context (e.g. the page-history row closes the history modal) so this generic
// ui/ primitive stays free of cross-feature coupling (#143 review Arch B).
onActivate?: () => void;
}
/**
* Badge marking content written by the AI agent (provenance C3 / §7.4). It is
* ADDITIVE — shown next to the human author, never replacing them. Reused by the
* page-history list and the comments sidebar.
*
* When the item carries an `aiChatId` (an internal AI-chat edit), clicking the
* badge deep-links into that chat: it sets the active-chat atom and opens the
* floating AI-chat window, then invokes `onActivate` so the caller can react
* (e.g. the history modal closes itself). When `aiChatId` is null/absent (an
* external MCP write with no internal ai_chats row), the badge is a plain
* non-clickable label. The click is contained (stopPropagation) so it does not
* also trigger an enclosing row's click handler.
*/
export function AiAgentBadge({
authorName,
aiChatId,
onActivate,
}: AiAgentBadgeProps) {
const { t } = useTranslation();
const setAiChatWindowOpen = useSetAtom(aiChatWindowOpenAtom);
const setActiveChatId = useSetAtom(activeAiChatIdAtom);
const setDraft = useSetAtom(aiChatDraftAtom);
const tooltip = t("Edited by AI agent on behalf of {{name}}", {
name: authorName ?? "",
});
const openChat = useCallback(
(event: React.SyntheticEvent) => {
event.stopPropagation();
if (!aiChatId) return;
setActiveChatId(aiChatId);
// Switching to another chat must start with a clean composer — clear any
// unsent draft so it does not leak from the previously open chat.
setDraft("");
setAiChatWindowOpen(true);
onActivate?.();
},
[aiChatId, setActiveChatId, setDraft, setAiChatWindowOpen, onActivate],
);
const badge = (
<Badge
size="sm"
variant="light"
color="violet"
radius="sm"
leftSection={<IconSparkles size={12} stroke={2} />}
style={aiChatId ? { cursor: "pointer" } : undefined}
{...(aiChatId
? {
// Keep the default Badge root element (not a <button>) to avoid an
// invalid <button>-in-<button> nesting inside a row's
// UnstyledButton; expose it as an accessible button via
// role/keyboard.
role: "button",
tabIndex: 0,
onClick: openChat,
onKeyDown: (event: React.KeyboardEvent) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openChat(event);
}
},
}
: {})}
>
{t("AI-agent")}
</Badge>
);
return (
<Tooltip label={tooltip} withArrow>
{badge}
</Tooltip>
);
}
export default AiAgentBadge;
@@ -40,30 +40,20 @@ function renderItem(comment: IComment) {
);
}
describe("CommentListItem — agent avatar stack", () => {
it('renders the agent avatar stack when createdSource === "agent"', () => {
// External-MCP shape: agent is the account itself, no launcher behind.
renderItem(
baseComment({
createdSource: "agent",
aiChatId: null,
agent: { name: "Service Bot", avatarUrl: null },
launcher: null,
}),
);
// The stack renders the agent name label (the creator name is also shown in
// the row header, so it appears more than once).
expect(screen.getAllByText("Service Bot").length).toBeGreaterThan(0);
});
it('does NOT render the stack for a normal user comment (createdSource "user")', () => {
const { container } = renderItem(baseComment({ createdSource: "user" }));
// No agent glyph (sparkles) is present for a plain human comment.
expect(container.querySelector(".tabler-icon-sparkles")).toBeNull();
describe("CommentListItem — AI badge", () => {
it('renders the AI-agent badge when createdSource === "agent"', () => {
renderItem(baseComment({ createdSource: "agent", aiChatId: null }));
expect(screen.getByText("AI-agent")).toBeDefined();
expect(screen.getByText("Service Bot")).toBeDefined();
});
// The stack's own behaviors (glyph priority, launcher-behind, deep-link click)
// are covered directly in agent-avatar-stack.test.tsx; this integration suite
// only guards the insertion gate (agent → stack, user → no stack).
it('does NOT render the badge for a normal user comment (createdSource "user")', () => {
renderItem(baseComment({ createdSource: "user" }));
expect(screen.queryByText("AI-agent")).toBeNull();
expect(screen.getByText("Service Bot")).toBeDefined();
});
// The non-clickable (null aiChatId) branch is a property of AiAgentBadge itself
// and is covered in ai-agent-badge.test.tsx; this integration suite only needs
// the insertion gate (agent → badge, user → no badge) above (#143 review).
});
@@ -1,5 +1,5 @@
import { Group, Text, Box } from "@mantine/core";
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
import { AiAgentBadge } from "@/components/ui/ai-agent-badge.tsx";
import React, { useEffect, useRef, useState } from "react";
import classes from "./comment.module.css";
import { useAtom, useAtomValue } from "jotai";
@@ -132,10 +132,9 @@ function CommentListItem({
{comment.creator.name}
</Text>
{comment.createdSource === "agent" && comment.agent && (
<AgentAvatarStack
agent={comment.agent}
launcher={comment.launcher}
{comment.createdSource === "agent" && (
<AiAgentBadge
authorName={comment.creator?.name}
aiChatId={comment.aiChatId}
/>
)}
@@ -1,9 +1,5 @@
import { IUser } from "@/features/user/types/user.types";
import { QueryParams } from "@/lib/types.ts";
import type {
AgentInfo,
LauncherInfo,
} from "@/components/ui/agent-avatar-stack.tsx";
export interface IComment {
id: string;
@@ -28,11 +24,6 @@ export interface IComment {
createdSource?: string;
aiChatId?: string | null;
resolvedSource?: string | null;
// Server-normalized "agent avatar stack" provenance (#300), present only when
// createdSource === "agent": `agent` is the front identity, `launcher` the
// human behind it (null for an external MCP agent).
agent?: AgentInfo | null;
launcher?: LauncherInfo | null;
yjsSelection?: {
anchor: any;
head: any;
@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useScrollPosition } from "./use-scroll-position";
import { useScrollPosition, hasSavedReadingPosition } from "./use-scroll-position";
const KEY_PREFIX = "gitmost:scroll-position:";
@@ -372,3 +372,23 @@ describe("useScrollPosition", () => {
}).not.toThrow();
});
});
describe("hasSavedReadingPosition", () => {
beforeEach(() => {
window.sessionStorage.clear();
});
it("returns false when nothing is saved for the page", () => {
expect(hasSavedReadingPosition("none")).toBe(false);
});
it("returns false when the saved value is 0 (page stays at the top)", () => {
window.sessionStorage.setItem(`${KEY_PREFIX}zero`, "0");
expect(hasSavedReadingPosition("zero")).toBe(false);
});
it("returns true when a positive position is saved", () => {
window.sessionStorage.setItem(`${KEY_PREFIX}deep`, "500");
expect(hasSavedReadingPosition("deep")).toBe(true);
});
});
@@ -57,6 +57,17 @@ function writeStorage(pageId: string, scrollY: number): void {
}
}
/**
* Whether a positive reading position is saved for this page — i.e. the page
* will be scrolled away from the top on load. Used by the title editor to avoid
* auto-focusing (and thus placing the caret in) the now-off-screen title.
* Returns false when nothing is saved or storage is unavailable.
*/
export function hasSavedReadingPosition(pageId: string): boolean {
const y = readStorage(pageId);
return typeof y === "number" && y > 0;
}
/**
* Persists and restores the window scroll position per page so a reader keeps
* their place across a reload (F5) or reopening the document.
@@ -0,0 +1,50 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useTitleAutofocus } from "./use-title-autofocus";
const KEY_PREFIX = "gitmost:scroll-position:";
function fakeEditor(overrides = {}) {
return { isInitialized: true, commands: { focus: vi.fn() }, ...overrides } as any;
}
describe("useTitleAutofocus", () => {
beforeEach(() => {
window.sessionStorage.clear();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("skips auto-focus when a saved reading position exists", () => {
window.sessionStorage.setItem(`${KEY_PREFIX}saved`, "500");
const editor = fakeEditor();
renderHook(() => useTitleAutofocus(editor, "saved"));
act(() => vi.advanceTimersByTime(300));
expect(editor.commands.focus).not.toHaveBeenCalled();
});
it("auto-focuses a new page (no saved position) with scrollIntoView: false", () => {
const editor = fakeEditor();
renderHook(() => useTitleAutofocus(editor, "fresh"));
act(() => vi.advanceTimersByTime(300));
expect(editor.commands.focus).toHaveBeenCalledWith("end", { scrollIntoView: false });
});
it("does not focus before initialization", () => {
const editor = fakeEditor({ isInitialized: false });
renderHook(() => useTitleAutofocus(editor, "fresh2"));
act(() => vi.advanceTimersByTime(300));
expect(editor.commands.focus).not.toHaveBeenCalled();
});
it("cancels the pending focus on unmount", () => {
const editor = fakeEditor();
const { unmount } = renderHook(() => useTitleAutofocus(editor, "fresh3"));
unmount();
act(() => vi.advanceTimersByTime(300));
expect(editor.commands.focus).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,45 @@
import { useEffect, useRef } from "react";
import type { Editor } from "@tiptap/react";
import { hasSavedReadingPosition } from "./use-scroll-position";
// Delay before auto-focusing the title on load — guards a tiptap init race
// ("Cannot access view['hasFocus']" if focused too early).
const TITLE_AUTOFOCUS_DELAY_MS = 300;
/**
* Auto-focus the page title shortly after mount — UNLESS a saved reading position
* will be restored (then the viewport scrolls away from the top, and focusing the
* top-of-page title would drop the caret off-screen). When it does focus, it uses
* `{ scrollIntoView: false }` so placing the caret never moves the viewport
* (tiptap's focus scrolls the focused node into view by default, which otherwise
* yanks the window to the top and fights scroll-position restoration).
*
* Extracted from TitleEditor so this exact decision is unit-testable.
*
* CONTRACT: relies on TitleEditor remounting per page (page.tsx renders
* `<MemoizedFullEditor key={page.id}>`), so `hasSavedScrollRef` is captured fresh
* per page. It is read synchronously on first render, before any scroll-save
* handler can clobber the stored value to 0 — matching `useScrollPosition`'s own
* synchronous capture of `initialTargetRef`.
*/
export function useTitleAutofocus(
titleEditor: Editor | null,
pageId: string,
): void {
const hasSavedScrollRef = useRef<boolean | null>(null);
if (hasSavedScrollRef.current === null) {
hasSavedScrollRef.current = hasSavedReadingPosition(pageId);
}
useEffect(() => {
if (hasSavedScrollRef.current) return;
const timer = setTimeout(() => {
// guard against "Cannot access view['hasFocus']" before init
if (!titleEditor?.isInitialized) return;
titleEditor?.commands?.focus("end", { scrollIntoView: false });
}, TITLE_AUTOFOCUS_DELAY_MS);
// Clear the pending focus if the editor changes or the component unmounts
// (also fixes the previously-uncancelled timer).
return () => clearTimeout(timer);
}, [titleEditor]);
}
@@ -28,6 +28,7 @@ import localEmitter from "@/lib/local-emitter.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts";
import { searchSpotlight } from "@/features/search/constants.ts";
import { platformModifierKey } from "@/lib";
import { useTitleAutofocus } from "@/features/editor/hooks/use-title-autofocus";
export interface TitleEditorProps {
pageId: string;
@@ -167,13 +168,7 @@ export function TitleEditor({
}
}, [pageId, title, titleEditor]);
useEffect(() => {
setTimeout(() => {
// guard against Cannot access view['hasFocus'] error
if (!titleEditor?.isInitialized) return;
titleEditor?.commands?.focus("end");
}, 300);
}, [titleEditor]);
useTitleAutofocus(titleEditor, pageId);
useEffect(() => {
return () => {
@@ -1,6 +1,6 @@
import { Text, Group, UnstyledButton, Avatar, Tooltip } from "@mantine/core";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
import { AiAgentBadge } from "@/components/ui/ai-agent-badge.tsx";
import { formattedDate } from "@/lib/time";
import classes from "./css/history.module.css";
import clsx from "clsx";
@@ -99,13 +99,12 @@ const HistoryItem = memo(function HistoryItem({
</>
)}
{isAgentEdit && historyItem.agent && (
<AgentAvatarStack
agent={historyItem.agent}
launcher={historyItem.launcher}
{isAgentEdit && (
<AiAgentBadge
authorName={historyItem.lastUpdatedBy?.name}
aiChatId={historyItem.lastUpdatedAiChatId}
// The history row owns the modal: close it when the stack deep-links
// into the chat (the stack no longer reaches into page-history).
// The history row owns the modal: close it when the badge deep-links
// into the chat (the badge no longer reaches into page-history).
onActivate={() => setHistoryModalOpen(false)}
/>
)}
@@ -1,8 +1,3 @@
import type {
AgentInfo,
LauncherInfo,
} from "@/components/ui/agent-avatar-stack.tsx";
interface IPageHistoryUser {
id: string;
name: string;
@@ -29,9 +24,4 @@ export interface IPageHistory {
// (when present) deep-links to the chat that produced the edit.
lastUpdatedSource?: string;
lastUpdatedAiChatId?: string | null;
// Server-normalized "agent avatar stack" provenance (#300), present only when
// lastUpdatedSource === "agent": `agent` is the front identity, `launcher` the
// human behind it (null for an external MCP agent).
agent?: AgentInfo | null;
launcher?: LauncherInfo | null;
}
@@ -1,237 +0,0 @@
import { CommentService } from './comment.service';
/**
* Caller-contract coverage for the three live comment broadcasts (#300/#304):
* - commentCreated (create @153)
* - commentUpdated (update @214) ← the fragile path this suite spotlights
* - commentResolved (resolveComment @283)
*
* All three must emit a payload carrying the {agent,launcher} avatar stack for an
* AGENT comment, and NEITHER field for a non-agent comment. The enrichment lives
* in CommentRepo.findById(..., {includeCreator:true}); the service contract these
* tests pin is that every broadcast reads its payload from that enriched
* single-row load rather than from an un-enriched object.
*
* NON-VACUITY for the update path: the service is handed an UN-enriched input
* comment (no agent/launcher), while findById returns the ENRICHED shape. The
* pre-#304 update() re-emitted the caller's object in place, so it would emit the
* un-enriched input and the `agent`/`launcher` assertions would FAIL. The fix
* re-fetches via findById, so the broadcast carries the stack regardless of how
* the caller pre-loaded the comment.
*/
describe('CommentService — broadcast carries the agent avatar stack', () => {
// An enriched agent comment as CommentRepo.findById(..., includeCreator:true)
// returns it: the {agent,launcher} pair is attached and agentRole is stripped.
const enrichedAgentComment = (over?: Record<string, unknown>) => ({
id: 'comment-new',
pageId: 'page-1',
spaceId: 'space-1',
workspaceId: 'ws-1',
content: { type: 'doc', content: [] },
createdSource: 'agent',
agent: { name: 'Researcher', emoji: '🔬', avatarUrl: null },
launcher: { name: 'Alice', avatarUrl: 'a.png' },
...over,
});
// A plain human comment: findById attaches neither agent nor launcher.
const plainHumanComment = (over?: Record<string, unknown>) => ({
id: 'comment-new',
pageId: 'page-1',
spaceId: 'space-1',
workspaceId: 'ws-1',
content: { type: 'doc', content: [] },
createdSource: 'user',
...over,
});
function makeService(findByIdReturn: unknown) {
const commentRepo: any = {
// In these flows findById is only the post-write enriched re-read
// (no parentCommentId is set, so no parent lookup path is taken).
findById: jest.fn(async () => findByIdReturn),
insertComment: jest.fn(async () => ({ id: 'comment-new' })),
updateComment: jest.fn(async () => undefined),
};
const pageRepo: any = {};
const wsService: any = { emitCommentEvent: jest.fn() };
const collaborationGateway: any = {
handleYjsEvent: jest.fn(async () => undefined),
};
const generalQueue: any = { add: jest.fn(() => Promise.resolve()) };
const notificationQueue: any = { add: jest.fn(async () => undefined) };
const service = new CommentService(
commentRepo,
pageRepo,
wsService,
collaborationGateway,
generalQueue,
notificationQueue,
);
return { service, commentRepo, wsService };
}
// Pull the emitted event object (3rd arg of emitCommentEvent) for an operation.
const emittedEvent = (wsService: any, operation: string) =>
wsService.emitCommentEvent.mock.calls
.map((c: any[]) => c[2])
.find((e: any) => e.operation === operation);
const page = { id: 'page-1', spaceId: 'space-1' } as any;
const user = (id = 'user-1') => ({ id }) as any;
const emptyDoc = JSON.stringify({ type: 'doc', content: [] });
describe('commentCreated', () => {
it('emits agent + launcher for an agent comment', async () => {
const { service, wsService } = makeService(enrichedAgentComment());
await service.create(
{ page, workspaceId: 'ws-1', user: user() },
{ content: emptyDoc } as any,
{ actor: 'agent', aiChatId: 'chat-1' },
);
const event = emittedEvent(wsService, 'commentCreated');
expect(event).toBeDefined();
expect(event.comment.agent).toEqual({
name: 'Researcher',
emoji: '🔬',
avatarUrl: null,
});
expect(event.comment.launcher).toEqual({ name: 'Alice', avatarUrl: 'a.png' });
});
it('emits neither field for a non-agent comment', async () => {
const { service, wsService } = makeService(plainHumanComment());
await service.create(
{ page, workspaceId: 'ws-1', user: user() },
{ content: emptyDoc } as any,
);
const event = emittedEvent(wsService, 'commentCreated');
expect(event).toBeDefined();
expect(event.comment).not.toHaveProperty('agent');
expect(event.comment).not.toHaveProperty('launcher');
});
});
describe('commentUpdated — the fragile path (spotlight)', () => {
it('emits agent + launcher even when the caller pre-loaded an UN-enriched comment', async () => {
// findById (the re-fetch) returns the enriched shape...
const { service, wsService, commentRepo } = makeService(
enrichedAgentComment(),
);
// ...but the caller hands in an object with NO agent/launcher. The pre-#304
// update() re-emitted THIS object in place, so this test fails against it;
// the re-fetch fix makes the broadcast independent of the pre-load.
const inputComment: any = {
id: 'comment-new',
creatorId: 'user-1',
pageId: 'page-1',
spaceId: 'space-1',
workspaceId: 'ws-1',
content: { type: 'doc', content: [] },
// deliberately no `agent` / `launcher`
};
await service.update(
inputComment,
{ content: emptyDoc } as any,
user('user-1'),
);
// The broadcast must re-read the enriched row (persisted update, then load).
expect(commentRepo.updateComment).toHaveBeenCalled();
expect(commentRepo.findById).toHaveBeenCalledWith('comment-new', {
includeCreator: true,
includeResolvedBy: true,
});
const event = emittedEvent(wsService, 'commentUpdated');
expect(event).toBeDefined();
expect(event.comment.agent).toEqual({
name: 'Researcher',
emoji: '🔬',
avatarUrl: null,
});
expect(event.comment.launcher).toEqual({ name: 'Alice', avatarUrl: 'a.png' });
});
it('emits neither field for a non-agent comment', async () => {
const { service, wsService } = makeService(plainHumanComment());
const inputComment: any = {
id: 'comment-new',
creatorId: 'user-1',
pageId: 'page-1',
spaceId: 'space-1',
workspaceId: 'ws-1',
content: { type: 'doc', content: [] },
};
await service.update(
inputComment,
{ content: emptyDoc } as any,
user('user-1'),
);
const event = emittedEvent(wsService, 'commentUpdated');
expect(event).toBeDefined();
expect(event.comment).not.toHaveProperty('agent');
expect(event.comment).not.toHaveProperty('launcher');
});
});
describe('commentResolved', () => {
it('emits agent + launcher for an agent comment', async () => {
const { service, wsService } = makeService(enrichedAgentComment());
await service.resolveComment(
{
id: 'comment-new',
creatorId: 'user-1',
pageId: 'page-1',
spaceId: 'space-1',
workspaceId: 'ws-1',
} as any,
true,
user('user-1'),
{ actor: 'agent', aiChatId: 'chat-1' },
);
const event = emittedEvent(wsService, 'commentResolved');
expect(event).toBeDefined();
expect(event.comment.agent).toEqual({
name: 'Researcher',
emoji: '🔬',
avatarUrl: null,
});
expect(event.comment.launcher).toEqual({ name: 'Alice', avatarUrl: 'a.png' });
});
it('emits neither field for a non-agent comment', async () => {
const { service, wsService } = makeService(plainHumanComment());
await service.resolveComment(
{
id: 'comment-new',
creatorId: 'user-1',
pageId: 'page-1',
spaceId: 'space-1',
workspaceId: 'ws-1',
} as any,
true,
user('user-1'),
);
const event = emittedEvent(wsService, 'commentResolved');
expect(event).toBeDefined();
expect(event.comment).not.toHaveProperty('agent');
expect(event.comment).not.toHaveProperty('launcher');
});
});
});
@@ -207,27 +207,17 @@ export class CommentService {
false,
);
// Re-fetch the enriched comment before broadcasting, symmetric with
// create()/resolveComment(). updateComment() above has already persisted the
// new content/timestamps, so this single-row read reflects the edit AND
// carries the same {agent,launcher} avatar stack (via includeCreator) as the
// other two broadcasts. This deliberately does NOT reuse the caller's
// pre-loaded `comment`: relying on the controller happening to load it with
// includeCreator:true is exactly the fragile coupling that let the agent
// stack silently vanish on edit once already (#300/#304) — a future caller
// dropping that flag must not regress the broadcast.
const updatedComment = await this.commentRepo.findById(comment.id, {
includeCreator: true,
includeResolvedBy: true,
});
comment.content = commentContent;
comment.editedAt = editedAt;
comment.updatedAt = editedAt;
this.wsService.emitCommentEvent(comment.spaceId, comment.pageId, {
operation: 'commentUpdated',
pageId: comment.pageId,
comment: updatedComment,
comment,
});
return updatedComment;
return comment;
}
async resolveComment(
@@ -1,129 +0,0 @@
import { resolveAgentProvenance } from './agent-provenance';
import { commentAgentRoleQuery } from './comment/comment.repo';
import { pageHistoryAgentRoleQuery } from './page/page-history.repo';
/**
* The server-authoritative "agent avatar stack" resolver (#300) normalizes the
* two provenance shapes into { agent (front), launcher (behind) } so the client
* never branches. These tests pin the exact resolved shape for the three agent
* cases plus the non-agent pass-through.
*/
describe('resolveAgentProvenance', () => {
const human = { name: 'Alice', avatarUrl: 'a.png' };
it('internal chat WITH role: agent = role (emoji, no avatar), launcher = human', () => {
const result = resolveAgentProvenance({
isAgent: true,
aiChatId: 'chat-1',
creator: human,
agentRole: { name: 'Researcher', emoji: '🔬' },
});
expect(result).toEqual({
agent: { name: 'Researcher', emoji: '🔬', avatarUrl: null },
launcher: { name: 'Alice', avatarUrl: 'a.png' },
});
});
it('internal chat WITHOUT role: agent = "AI agent" fallback, launcher = human', () => {
const result = resolveAgentProvenance({
isAgent: true,
aiChatId: 'chat-1',
creator: human,
agentRole: null,
});
expect(result).toEqual({
agent: { name: 'AI agent', avatarUrl: null },
launcher: { name: 'Alice', avatarUrl: 'a.png' },
});
// The fallback agent carries no emoji (only sparkles glyph on the client).
expect(result?.agent).not.toHaveProperty('emoji');
});
it('external MCP (aiChatId null): agent = the account itself, launcher = null', () => {
const result = resolveAgentProvenance({
isAgent: true,
aiChatId: null,
creator: { name: 'MCP Bot', avatarUrl: 'bot.png' },
agentRole: null,
});
expect(result).toEqual({
agent: { name: 'MCP Bot', avatarUrl: 'bot.png' },
launcher: null,
});
});
it('non-agent content: returns null so the caller omits both fields', () => {
expect(
resolveAgentProvenance({
isAgent: false,
aiChatId: null,
creator: human,
agentRole: null,
}),
).toBeNull();
});
});
/**
* The role-resolution subquery must NOT filter on enabled/deletedAt: historical
* agent content keeps its signature even after the role is disabled or
* soft-deleted (same rule as AiAgentRoleRepo.findById, NOT findLiveEnabled). We
* record the query-builder calls and assert the join binds only id<->roleId and
* that `where` is never called with an enabled/deletedAt filter.
*/
describe('agent role subquery — no live/enabled filter', () => {
function makeRecorder() {
const calls: { method: string; args: unknown[] }[] = [];
const builder = new Proxy(
{},
{
get(_t, prop: string) {
return (...args: unknown[]) => {
calls.push({ method: prop, args });
return builder;
};
},
},
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const eb = { selectFrom: (...args: unknown[]) => (calls.push({ method: 'selectFrom', args }), builder) } as any;
return { eb, calls };
}
function assertNoLiveFilter(
query: (eb: any) => unknown, // eslint-disable-line @typescript-eslint/no-explicit-any
chatIdColumn: string,
) {
const { eb, calls } = makeRecorder();
query(eb);
const innerJoin = calls.find((c) => c.method === 'innerJoin');
expect(innerJoin?.args).toEqual([
'aiAgentRoles',
'aiAgentRoles.id',
'aiChats.roleId',
]);
const whereRef = calls.find((c) => c.method === 'whereRef');
expect(whereRef?.args).toEqual(['aiChats.id', '=', chatIdColumn]);
// The security-narrowing filters used by findLiveEnabled must be ABSENT.
const filtered = calls
.flatMap((c) => c.args)
.filter((a) => a === 'enabled' || a === 'deletedAt');
expect(filtered).toEqual([]);
// No `where(...)` at all (only the join + whereRef).
expect(calls.some((c) => c.method === 'where')).toBe(false);
}
it('comment subquery joins by id only, keyed on comments.aiChatId', () => {
assertNoLiveFilter(commentAgentRoleQuery, 'comments.aiChatId');
});
it('page-history subquery joins by id only, keyed on lastUpdatedAiChatId', () => {
assertNoLiveFilter(
pageHistoryAgentRoleQuery,
'pageHistory.lastUpdatedAiChatId',
);
});
});
@@ -1,93 +0,0 @@
/**
* Server-authoritative "agent avatar stack" provenance (#300).
*
* Agent-authored content (comments / page-history snapshots) is displayed as a
* two-avatar stack: the AGENT in front, and the HUMAN who launched it behind.
* This module normalizes the two provenance shapes the client can encounter into
* the SAME pair of sub-objects so the client never has to branch:
*
* agent — FRONT (the acting agent identity)
* launcher — BEHIND (the human on whose behalf it acted; null when there is none)
*
* The discriminator is purely SERVER-SIDE data (createdSource / lastUpdatedSource
* plus aiChatId) that only the server can set — none of it is read from request
* input, so an external caller cannot spoof an `agent` badge.
*/
/** Front avatar identity. `avatarUrl`/`emoji` feed the glyph source priority. */
export interface AgentInfo {
name: string;
emoji?: string | null;
avatarUrl?: string | null;
}
/** Behind avatar identity — the human who launched the agent (internal chat). */
export interface LauncherInfo {
name: string;
avatarUrl?: string | null;
}
/**
* Inputs to the resolver, drawn entirely from server-side columns:
* - `isAgent` — createdSource/lastUpdatedSource === 'agent'.
* - `aiChatId` — internal-AI-chat discriminator: non-null => internal chat (the
* provenance token was minted for the human, so `creator` is the human and the
* agent identity comes from the chat's role); null => external MCP (the login
* IS a dedicated agent account, so `creator` is the agent, no separate human).
* - `creator` — the row's human author (internal) OR agent account (MCP).
* - `agentRole`— the chat's bound role (name + optional emoji), resolved WITHOUT
* any enabled/deleted filter so historical content keeps its signature even
* after the role is disabled or soft-deleted; null when the chat has no role.
*/
export interface AgentProvenanceInput {
isAgent: boolean;
aiChatId: string | null | undefined;
creator: { name: string; avatarUrl?: string | null } | null | undefined;
agentRole: { name: string; emoji?: string | null } | null | undefined;
}
export interface AgentProvenance {
agent: AgentInfo;
launcher: LauncherInfo | null;
}
/** Fallback display name for an internal agent edit whose chat has no role. */
export const AGENT_FALLBACK_NAME = 'AI agent';
/**
* Resolve the front/behind identities from server-side provenance. Returns
* `null` for non-agent content so the caller can OMIT both fields (the client
* then keeps its plain single-human avatar).
*/
export function resolveAgentProvenance(
input: AgentProvenanceInput,
): AgentProvenance | null {
if (!input.isAgent) return null;
// External MCP: no internal chat row; the login itself is the agent account.
if (input.aiChatId == null) {
return {
agent: {
name: input.creator?.name ?? AGENT_FALLBACK_NAME,
avatarUrl: input.creator?.avatarUrl ?? null,
},
launcher: null,
};
}
// Internal AI chat: the agent identity is the chat's role (or the fallback
// when the chat has no role), and the launcher is the human chat owner.
const agent: AgentInfo = input.agentRole
? {
name: input.agentRole.name,
emoji: input.agentRole.emoji ?? null,
avatarUrl: null,
}
: { name: AGENT_FALLBACK_NAME, avatarUrl: null };
const launcher: LauncherInfo | null = input.creator
? { name: input.creator.name, avatarUrl: input.creator.avatarUrl ?? null }
: null;
return { agent, launcher };
}
@@ -1,124 +0,0 @@
import { CommentRepo } from './comment.repo';
/**
* Enrichment coverage for CommentRepo.findById (#300).
*
* The {agent,launcher} avatar stack must be attached on the SINGLE-ROW read
* path, not only on findPageComments — the live websocket broadcasts
* (commentCreated/commentUpdated/commentResolved) return a comment loaded via
* findById. These tests would FAIL against the previous un-enriched findById
* (which returned the raw row without calling attachCommentAgent and without
* selecting the agent-role subquery).
*
* The Kysely db is replaced by a chainable recorder so the query never touches a
* real database: it records the `.select(...)` args (to prove the agent-role
* subquery is selected on the includeCreator path) and returns a preset row from
* executeTakeFirst (to prove attachCommentAgent maps it into {agent,launcher}).
*/
describe('CommentRepo.findById — agent avatar stack enrichment', () => {
function makeRepo(row: unknown) {
const selectArgs: unknown[] = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const builder: any = {
selectFrom: () => builder,
selectAll: () => builder,
select: (arg: unknown) => {
selectArgs.push(arg);
return builder;
},
// Kysely's $if(condition, cb) invokes cb(qb) only when the condition is
// truthy; mirror that so gating (includeCreator) is exercised faithfully.
$if: (cond: unknown, cb: (qb: unknown) => unknown) => {
if (cond) cb(builder);
return builder;
},
where: () => builder,
executeTakeFirst: async () => row,
};
const db = { selectFrom: () => builder };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const repo = new CommentRepo(db as any);
return { repo, selectArgs };
}
const enrichOpts = { includeCreator: true, includeResolvedBy: true };
it('internal agent chat WITH role: returns agent = role, launcher = creator, and strips agentRole', async () => {
const { repo, selectArgs } = makeRepo({
id: 'c-1',
createdSource: 'agent',
aiChatId: 'chat-1',
creator: { name: 'Alice', avatarUrl: 'a.png' },
agentRole: { name: 'Researcher', emoji: '🔬' },
});
const result: any = await repo.findById('c-1', enrichOpts);
expect(result.agent).toEqual({
name: 'Researcher',
emoji: '🔬',
avatarUrl: null,
});
expect(result.launcher).toEqual({ name: 'Alice', avatarUrl: 'a.png' });
// The internal join column must never leak to the client.
expect(result).not.toHaveProperty('agentRole');
// The enrichment SELECTs the agent-role subquery on the includeCreator path
// (mirrors the list-query proof; absent in the pre-fix findById).
expect(selectArgs).toContain(repo.withAgentRole);
});
it('external MCP agent (aiChatId null): agent = the account, launcher = null', async () => {
const { repo } = makeRepo({
id: 'c-2',
createdSource: 'agent',
aiChatId: null,
creator: { name: 'MCP Bot', avatarUrl: 'bot.png' },
agentRole: null,
});
const result: any = await repo.findById('c-2', enrichOpts);
expect(result.agent).toEqual({ name: 'MCP Bot', avatarUrl: 'bot.png' });
expect(result.launcher).toBeNull();
expect(result).not.toHaveProperty('agentRole');
});
it('non-agent comment: neither agent nor launcher is attached', async () => {
const { repo } = makeRepo({
id: 'c-3',
createdSource: 'user',
aiChatId: null,
creator: { name: 'Bob', avatarUrl: null },
agentRole: null,
});
const result: any = await repo.findById('c-3', enrichOpts);
expect(result).not.toHaveProperty('agent');
expect(result).not.toHaveProperty('launcher');
// A plain human comment still strips the internal join column.
expect(result).not.toHaveProperty('agentRole');
});
it('missing row: returns undefined without crashing the enrichment', async () => {
const { repo } = makeRepo(undefined);
await expect(repo.findById('nope', enrichOpts)).resolves.toBeUndefined();
});
it('non-includeCreator callers keep the plain shape (no enrichment, no agent-role select)', async () => {
const { repo, selectArgs } = makeRepo({
id: 'c-4',
createdSource: 'agent',
aiChatId: 'chat-1',
});
// No opts => the enrichment (and its subquery select) must be skipped, so
// callers doing a bare lookup (parent-comment check, controller findOne)
// are unaffected by the additive fields.
const result: any = await repo.findById('c-4');
expect(result).not.toHaveProperty('agent');
expect(result).not.toHaveProperty('launcher');
expect(selectArgs).not.toContain(repo.withAgentRole);
});
});
@@ -12,24 +12,6 @@ import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagin
import { ExpressionBuilder } from 'kysely';
import { DB } from '@docmost/db/types/db';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { resolveAgentProvenance } from '../agent-provenance';
/**
* Role-resolution subquery for a comment's bound AI chat (#300). Joins
* comments.aiChatId -> ai_chats.role_id -> ai_agent_roles and selects the role's
* name + emoji. NO enabled/deletedAt filter: historical agent content must keep
* its signature even after the role is later disabled or soft-deleted — the same
* "resolve by id, ignore live/enabled" rule as AiAgentRoleRepo.findById (NOT
* findLiveEnabled). Exported so a unit test can assert the join binds only
* id<->roleId and never filters on enabled/deletedAt.
*/
export function commentAgentRoleQuery(eb: ExpressionBuilder<DB, 'comments'>) {
return eb
.selectFrom('aiChats')
.innerJoin('aiAgentRoles', 'aiAgentRoles.id', 'aiChats.roleId')
.select(['aiAgentRoles.name', 'aiAgentRoles.emoji'])
.whereRef('aiChats.id', '=', 'comments.aiChatId');
}
@Injectable()
export class CommentRepo {
@@ -40,30 +22,13 @@ export class CommentRepo {
commentId: string,
opts?: { includeCreator: boolean; includeResolvedBy: boolean },
): Promise<Comment> {
const comment = await this.db
return await this.db
.selectFrom('comments')
.selectAll('comments')
.$if(opts?.includeCreator, (qb) => qb.select(this.withCreator))
.$if(opts?.includeResolvedBy, (qb) => qb.select(this.withResolvedBy))
// #300: enrich the single-row read with the agent-role subquery so the
// {agent,launcher} avatar stack is attached here too — the live websocket
// broadcasts (commentCreated/Updated/Resolved) return a comment loaded via
// findById, and must carry the SAME provenance as the list query
// findPageComments. Without this a freshly created / edited / resolved
// agent comment arrives un-enriched and the client's
// `createdSource === 'agent' && agent` gate drops the stack until a full
// refetch. Gated on includeCreator (mirroring findPageComments, which
// always selects the creator): the internal-chat launcher IS the creator,
// so the resolver needs it, and every broadcast caller passes
// includeCreator: true. Non-includeCreator callers keep the plain shape.
.$if(opts?.includeCreator, (qb) => qb.select(this.withAgentRole))
.where('id', '=', commentId)
.executeTakeFirst();
// Guard a missing row (don't destructure undefined in attachCommentAgent)
// and leave non-enriched callers' shape untouched.
if (!comment || !opts?.includeCreator) return comment;
return attachCommentAgent(comment) as Comment;
}
async findPageComments(pageId: string, pagination: PaginationOptions) {
@@ -72,18 +37,15 @@ export class CommentRepo {
.selectAll('comments')
.select((eb) => this.withCreator(eb))
.select((eb) => this.withResolvedBy(eb))
.select((eb) => this.withAgentRole(eb))
.where('pageId', '=', pageId);
const result = await executeWithCursorPagination(query, {
return executeWithCursorPagination(query, {
perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [{ expression: 'id', direction: 'asc' }],
parseCursor: (cursor) => ({ id: cursor.id }),
});
return { ...result, items: result.items.map(attachCommentAgent) };
}
async updateComment(
@@ -120,12 +82,6 @@ export class CommentRepo {
).as('creator');
}
/** Select the comment's resolved chat role (name + emoji) as `agentRole`, or
* null when the comment has no internal chat / the chat has no role (#300). */
withAgentRole(eb: ExpressionBuilder<DB, 'comments'>) {
return jsonObjectFrom(commentAgentRoleQuery(eb)).as('agentRole');
}
withResolvedBy(eb: ExpressionBuilder<DB, 'comments'>) {
return jsonObjectFrom(
eb
@@ -160,30 +116,3 @@ export class CommentRepo {
return Number(result?.count) > 0;
}
}
/**
* Attach the normalized agent/launcher provenance (#300) to a comment row and
* strip the internal `agentRole` join column. Non-agent rows pass through
* unchanged (neither field added — the client keeps the plain human avatar). The
* human author (`creator`) is the launcher for an internal chat, or the agent
* itself for external MCP; the resolver encodes both cases.
*/
function attachCommentAgent<
R extends {
createdSource?: string | null;
aiChatId?: string | null;
creator?: { name: string; avatarUrl?: string | null } | null;
agentRole?: { name: string; emoji?: string | null } | null;
},
>(row: R) {
const { agentRole, ...rest } = row;
const provenance = resolveAgentProvenance({
isAgent: row.createdSource === 'agent',
aiChatId: row.aiChatId,
creator: row.creator,
agentRole,
});
return provenance
? { ...rest, agent: provenance.agent, launcher: provenance.launcher }
: rest;
}
@@ -1,107 +0,0 @@
import { PageHistoryRepo } from './page-history.repo';
/**
* Enrichment coverage for the page-history agent avatar stack (#300/#304).
*
* attachPageHistoryAgent maps a DIFFERENT column set than comments —
* `lastUpdatedSource` / `lastUpdatedAiChatId` / `lastUpdatedBy` instead of
* `createdSource` / `aiChatId` / `creator` — so it needs its own direct proof
* that the {agent,launcher} pair resolves for each provenance shape and that the
* internal `agentRole` join column is stripped.
*
* The mapping is exercised through findPageHistoryByPageId (the only page-history
* path that enriches). The Kysely db is a chainable recorder: query-builder
* methods return the builder and `.execute()` (called by
* executeWithCursorPagination) yields preset rows, so no real database is
* touched. The `.select((eb) => ...)` callbacks are recorded but never invoked,
* so the preset row stands in for what the DB would have returned.
*
* NON-VACUITY: against an identity mapping (raw row pass-through) the agent-case
* assertions fail — `agent`/`launcher` would be undefined and the internal
* `agentRole` column would leak.
*/
describe('PageHistoryRepo.findPageHistoryByPageId — agent avatar stack enrichment', () => {
function makeRepo(rows: unknown[]) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const builder: any = {
selectFrom: () => builder,
select: () => builder,
where: () => builder,
orderBy: () => builder,
limit: () => builder,
execute: async () => rows,
};
const db = { selectFrom: () => builder };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return new PageHistoryRepo(db as any);
}
// perPage high enough that a single preset row never triggers the extra-row
// "has next page" branch (which would call generateCursor).
const pagination = { limit: 50 } as any;
const firstItem = async (row: Record<string, unknown>) => {
const repo = makeRepo([row]);
const result = await repo.findPageHistoryByPageId('page-1', pagination);
return result.items[0] as any;
};
it('internal chat WITH role: agent = role (emoji, no avatar), launcher = human, agentRole stripped', async () => {
const item = await firstItem({
id: 'ph-1',
lastUpdatedSource: 'agent',
lastUpdatedAiChatId: 'chat-1',
lastUpdatedBy: { name: 'Alice', avatarUrl: 'a.png' },
agentRole: { name: 'Editor', emoji: '✏️' },
});
expect(item.agent).toEqual({ name: 'Editor', emoji: '✏️', avatarUrl: null });
expect(item.launcher).toEqual({ name: 'Alice', avatarUrl: 'a.png' });
// The internal join column must never leak to the client.
expect(item).not.toHaveProperty('agentRole');
});
it('internal chat WITHOUT role: agent = "AI agent" fallback, launcher = human', async () => {
const item = await firstItem({
id: 'ph-2',
lastUpdatedSource: 'agent',
lastUpdatedAiChatId: 'chat-1',
lastUpdatedBy: { name: 'Alice', avatarUrl: 'a.png' },
agentRole: null,
});
expect(item.agent).toEqual({ name: 'AI agent', avatarUrl: null });
expect(item.agent).not.toHaveProperty('emoji');
expect(item.launcher).toEqual({ name: 'Alice', avatarUrl: 'a.png' });
expect(item).not.toHaveProperty('agentRole');
});
it('external MCP (lastUpdatedAiChatId null): agent = the account itself, launcher = null', async () => {
const item = await firstItem({
id: 'ph-3',
lastUpdatedSource: 'agent',
lastUpdatedAiChatId: null,
lastUpdatedBy: { name: 'MCP Bot', avatarUrl: 'bot.png' },
agentRole: null,
});
expect(item.agent).toEqual({ name: 'MCP Bot', avatarUrl: 'bot.png' });
expect(item.launcher).toBeNull();
expect(item).not.toHaveProperty('agentRole');
});
it('non-agent (lastUpdatedSource !== "agent"): neither agent nor launcher, agentRole stripped', async () => {
const item = await firstItem({
id: 'ph-4',
lastUpdatedSource: 'user',
lastUpdatedAiChatId: null,
lastUpdatedBy: { name: 'Bob', avatarUrl: null },
agentRole: null,
});
expect(item).not.toHaveProperty('agent');
expect(item).not.toHaveProperty('launcher');
// A plain human row still strips the internal join column.
expect(item).not.toHaveProperty('agentRole');
});
});
@@ -12,25 +12,6 @@ import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagin
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { ExpressionBuilder, sql } from 'kysely';
import { DB } from '@docmost/db/types/db';
import { resolveAgentProvenance } from '../agent-provenance';
/**
* Role-resolution subquery for a page-history row's bound AI chat (#300). Joins
* pageHistory.lastUpdatedAiChatId -> ai_chats.role_id -> ai_agent_roles and
* selects the role's name + emoji. NO enabled/deletedAt filter: historical agent
* content must keep its signature even after the role is disabled or soft-deleted
* (same rule as AiAgentRoleRepo.findById, NOT findLiveEnabled). Exported so a
* unit test can assert the join never filters on enabled/deletedAt.
*/
export function pageHistoryAgentRoleQuery(
eb: ExpressionBuilder<DB, 'pageHistory'>,
) {
return eb
.selectFrom('aiChats')
.innerJoin('aiAgentRoles', 'aiAgentRoles.id', 'aiChats.roleId')
.select(['aiAgentRoles.name', 'aiAgentRoles.emoji'])
.whereRef('aiChats.id', '=', 'pageHistory.lastUpdatedAiChatId');
}
@Injectable()
export class PageHistoryRepo {
@@ -113,18 +94,15 @@ export class PageHistoryRepo {
.select(this.baseFields)
.select((eb) => this.withLastUpdatedBy(eb))
.select((eb) => this.withContributors(eb))
.select((eb) => this.withAgentRole(eb))
.where('pageId', '=', pageId);
const result = await executeWithCursorPagination(query, {
return executeWithCursorPagination(query, {
perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [{ expression: 'id', direction: 'desc' }],
parseCursor: (cursor) => ({ id: cursor.id }),
});
return { ...result, items: result.items.map(attachPageHistoryAgent) };
}
async findPageLastHistory(
@@ -160,12 +138,6 @@ export class PageHistoryRepo {
).as('lastUpdatedBy');
}
/** Select the row's resolved chat role (name + emoji) as `agentRole`, or null
* when there is no internal chat / the chat has no role (#300). */
withAgentRole(eb: ExpressionBuilder<DB, 'pageHistory'>) {
return jsonObjectFrom(pageHistoryAgentRoleQuery(eb)).as('agentRole');
}
withContributors(eb: ExpressionBuilder<DB, 'pageHistory'>) {
return jsonArrayFrom(
eb
@@ -179,30 +151,3 @@ export class PageHistoryRepo {
).as('contributors');
}
}
/**
* Attach the normalized agent/launcher provenance (#300) to a page-history row
* and strip the internal `agentRole` join column. The trigger is
* `lastUpdatedSource === 'agent'`, the internal-chat discriminator is
* `lastUpdatedAiChatId`, and the human is `lastUpdatedBy`. Non-agent rows pass
* through unchanged (neither field added).
*/
function attachPageHistoryAgent<
R extends {
lastUpdatedSource?: string | null;
lastUpdatedAiChatId?: string | null;
lastUpdatedBy?: { name: string; avatarUrl?: string | null } | null;
agentRole?: { name: string; emoji?: string | null } | null;
},
>(row: R) {
const { agentRole, ...rest } = row;
const provenance = resolveAgentProvenance({
isAgent: row.lastUpdatedSource === 'agent',
aiChatId: row.lastUpdatedAiChatId,
creator: row.lastUpdatedBy,
agentRole,
});
return provenance
? { ...rest, agent: provenance.agent, launcher: provenance.launcher }
: rest;
}
-1
View File
@@ -1 +0,0 @@
node_modules/node_modules