Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 17003fbbc1 | |||
| 8f95c5808e | |||
| 88d96c41b5 | |||
| ef16743406 | |||
| 6c208a965f | |||
| 86c1307ed2 | |||
| 0968ea97d2 | |||
| 4af21494af |
@@ -1222,8 +1222,8 @@
|
||||
"Commented": "Commented",
|
||||
"Resolved comment": "Resolved comment",
|
||||
"Ran tool {{name}}": "Ran tool {{name}}",
|
||||
"AI-agent": "AI-agent",
|
||||
"Edited by AI agent on behalf of {{name}}": "Edited by AI agent on behalf of {{name}}",
|
||||
"AI agent «{{role}}» on behalf of {{person}}": "AI agent «{{role}}» on behalf of {{person}}",
|
||||
"AI agent {{name}}": "AI agent {{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,7 +724,8 @@
|
||||
"Shown as used / total in the chat header. Leave empty to hide the limit.": "Показывается в шапке чата как использовано / всего. Пусто — лимит скрыт.",
|
||||
"Delete this chat?": "Удалить этот чат?",
|
||||
"Deleted successfully": "Успешно удалено",
|
||||
"Edited by AI agent on behalf of {{name}}": "Отредактировано AI-агентом от имени {{name}}",
|
||||
"AI agent «{{role}}» on behalf of {{person}}": "AI-агент «{{role}}» от имени {{person}}",
|
||||
"AI agent {{name}}": "AI-агент {{name}}",
|
||||
"Failed to delete chat": "Не удалось удалить чат",
|
||||
"Failed to rename chat": "Не удалось переименовать чат",
|
||||
"Failed": "Ошибка",
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
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("showName=false: renders only the avatars, no inline name label", () => {
|
||||
renderStack({
|
||||
agent: { name: "Researcher", emoji: "🔬", avatarUrl: null },
|
||||
launcher: { name: "Alice", avatarUrl: null },
|
||||
aiChatId: "chat-1",
|
||||
showName: false,
|
||||
});
|
||||
|
||||
// The agent glyph is still rendered...
|
||||
expect(screen.getByText("🔬")).toBeDefined();
|
||||
// ...but neither the agent NOR the launcher inline name label is rendered
|
||||
// (they live only in the hover tooltip, which is not mounted in the initial
|
||||
// DOM) — guards against suppressing only the agent name and leaking the
|
||||
// launcher name.
|
||||
expect(screen.queryByText("Researcher")).toBeNull();
|
||||
expect(screen.queryByText("Alice")).toBeNull();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
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;
|
||||
// How far the launcher avatar sticks out past the agent's bottom-right corner, so
|
||||
// the "human behind" reads as behind (lower z-index) yet stays clearly visible.
|
||||
const LAUNCHER_OVERHANG = 8;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
// Whether to render the inline name label next to the avatars (default true).
|
||||
// Set false when the caller renders the name itself (e.g. the comment row).
|
||||
showName?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
showName = true,
|
||||
}: 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 });
|
||||
|
||||
// The container is only enlarged when there is a launcher to overhang; with no
|
||||
// human behind it stays tight at the agent glyph size.
|
||||
const stackSize = launcher ? GLYPH_SIZE + LAUNCHER_OVERHANG : GLYPH_SIZE;
|
||||
|
||||
const stack = (
|
||||
<Box
|
||||
pos="relative"
|
||||
style={{
|
||||
width: stackSize,
|
||||
height: stackSize,
|
||||
flexShrink: 0,
|
||||
// Center the (in-flow) agent glyph vertically so it lines up with its
|
||||
// name label; the absolutely-positioned launcher is unaffected by flex.
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
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>
|
||||
)}
|
||||
{/* Pin the agent glyph to the top-left at its own size; the launcher then
|
||||
overhangs it by LAUNCHER_OVERHANG at the bottom-right and stays visible. */}
|
||||
<Box
|
||||
style={{
|
||||
position: "relative",
|
||||
zIndex: 1,
|
||||
width: GLYPH_SIZE,
|
||||
height: GLYPH_SIZE,
|
||||
}}
|
||||
>
|
||||
<AgentGlyph agent={agent} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Tooltip label={tooltip} withArrow>
|
||||
{stack}
|
||||
</Tooltip>
|
||||
{showName && (
|
||||
<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;
|
||||
@@ -1,96 +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 { 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();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,99 +0,0 @@
|
||||
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,20 +40,50 @@ function renderItem(comment: IComment) {
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
describe("CommentListItem — agent avatar stack", () => {
|
||||
it('flips the hierarchy for an agent comment: agent primary, launcher shown once', () => {
|
||||
// Internal-chat shape with DISTINCT names so absence-of-duplication is
|
||||
// assertable: creator is the human "Alice", the acting agent is "Researcher".
|
||||
renderItem(
|
||||
baseComment({
|
||||
creator: { id: "user-1", name: "Alice", avatarUrl: null } as any,
|
||||
createdSource: "agent",
|
||||
aiChatId: "chat-1",
|
||||
agent: { name: "Researcher", emoji: "🔬", avatarUrl: null },
|
||||
launcher: { name: "Alice", avatarUrl: null },
|
||||
}),
|
||||
);
|
||||
// The AGENT is the primary label (the flipped hierarchy).
|
||||
expect(screen.getByText("Researcher")).toBeDefined();
|
||||
// The human launcher name shows exactly once — it is no longer duplicated as
|
||||
// a separate creator name (that duplication is the bug this fixes).
|
||||
expect(screen.getAllByText("Alice").length).toBe(1);
|
||||
});
|
||||
|
||||
it('external MCP agent comment (no launcher): shows the agent name, no separator', () => {
|
||||
// aiChatId null => external MCP: the agent IS the account, no human behind.
|
||||
renderItem(
|
||||
baseComment({
|
||||
creator: { id: "bot-1", name: "MCP Bot", avatarUrl: null } as any,
|
||||
createdSource: "agent",
|
||||
aiChatId: null,
|
||||
agent: { name: "MCP Bot", avatarUrl: null },
|
||||
launcher: null,
|
||||
}),
|
||||
);
|
||||
expect(screen.getByText("MCP Bot")).toBeDefined();
|
||||
// No launcher => no dimmed "·" separator in the header.
|
||||
expect(screen.queryByText("·")).toBeNull();
|
||||
});
|
||||
|
||||
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();
|
||||
expect(screen.getByText("Service Bot")).toBeDefined();
|
||||
});
|
||||
|
||||
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).
|
||||
// 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).
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Group, Text, Box } from "@mantine/core";
|
||||
import { AiAgentBadge } from "@/components/ui/ai-agent-badge.tsx";
|
||||
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import classes from "./comment.module.css";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
@@ -119,24 +119,44 @@ function CommentListItem({
|
||||
return (
|
||||
<Box ref={ref} pb={6}>
|
||||
<Group gap="xs">
|
||||
<CustomAvatar
|
||||
size="sm"
|
||||
avatarUrl={comment.creator.avatarUrl}
|
||||
name={comment.creator.name}
|
||||
/>
|
||||
{comment.createdSource === "agent" && comment.agent ? (
|
||||
<AgentAvatarStack
|
||||
agent={comment.agent}
|
||||
launcher={comment.launcher}
|
||||
aiChatId={comment.aiChatId}
|
||||
showName={false}
|
||||
/>
|
||||
) : (
|
||||
<CustomAvatar
|
||||
size="sm"
|
||||
avatarUrl={comment.creator.avatarUrl}
|
||||
name={comment.creator.name}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Text size="xs" fw={500} lineClamp={1} lh={1.2}>
|
||||
{comment.creator.name}
|
||||
</Text>
|
||||
|
||||
{comment.createdSource === "agent" && (
|
||||
<AiAgentBadge
|
||||
authorName={comment.creator?.name}
|
||||
aiChatId={comment.aiChatId}
|
||||
/>
|
||||
{comment.createdSource === "agent" && comment.agent ? (
|
||||
<>
|
||||
<Text size="xs" fw={600} lineClamp={1} lh={1.2}>
|
||||
{comment.agent.name}
|
||||
</Text>
|
||||
{comment.launcher && (
|
||||
<>
|
||||
<Text size="xs" c="dimmed" fw={400} aria-hidden>
|
||||
·
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" fw={400} lineClamp={1} lh={1.2}>
|
||||
{comment.launcher.name}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Text size="xs" fw={500} lineClamp={1} lh={1.2}>
|
||||
{comment.creator.name}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
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;
|
||||
@@ -24,6 +28,11 @@ 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,164 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import type { RefObject } from "react";
|
||||
import { useSwapHeightReservation } from "./use-swap-height-reservation";
|
||||
|
||||
// Controllable fake requestAnimationFrame. jsdom's rAF is timer-driven and hard
|
||||
// to step deterministically, so we install a manual queue: `tickRaf()` drains the
|
||||
// callbacks scheduled so far (a callback that reschedules enqueues a new one for
|
||||
// the NEXT tick), letting each test advance the release loop frame by frame.
|
||||
let rafQueue: Array<{ id: number; cb: FrameRequestCallback }> = [];
|
||||
let nextRafId = 1;
|
||||
let realRaf: typeof globalThis.requestAnimationFrame;
|
||||
let realCancel: typeof globalThis.cancelAnimationFrame;
|
||||
|
||||
function tickRaf(): void {
|
||||
const current = rafQueue;
|
||||
rafQueue = [];
|
||||
for (const { cb } of current) cb(0);
|
||||
}
|
||||
|
||||
// A mutable stand-in for the live-content container. The hook only reads
|
||||
// `scrollHeight`, so tests drive the release condition by mutating this.
|
||||
function makeMenuRef(): {
|
||||
ref: RefObject<HTMLElement | null>;
|
||||
setScrollHeight: (h: number) => void;
|
||||
} {
|
||||
const el = { scrollHeight: 0 };
|
||||
return {
|
||||
ref: { current: el } as unknown as RefObject<HTMLElement | null>,
|
||||
setScrollHeight: (h: number) => {
|
||||
el.scrollHeight = h;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const H = 1000;
|
||||
|
||||
describe("useSwapHeightReservation", () => {
|
||||
beforeEach(() => {
|
||||
rafQueue = [];
|
||||
nextRafId = 1;
|
||||
realRaf = globalThis.requestAnimationFrame;
|
||||
realCancel = globalThis.cancelAnimationFrame;
|
||||
globalThis.requestAnimationFrame = ((cb: FrameRequestCallback) => {
|
||||
const id = nextRafId++;
|
||||
rafQueue.push({ id, cb });
|
||||
return id;
|
||||
}) as typeof globalThis.requestAnimationFrame;
|
||||
globalThis.cancelAnimationFrame = ((id: number) => {
|
||||
rafQueue = rafQueue.filter((e) => e.id !== id);
|
||||
}) as typeof globalThis.cancelAnimationFrame;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.requestAnimationFrame = realRaf;
|
||||
globalThis.cancelAnimationFrame = realCancel;
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// (a) reserve-on-swap: the captured height becomes `reservedHeight`, the value
|
||||
// that drives the swap wrapper's minHeight. Captured while static is still up,
|
||||
// then the swap flips showStatic; before any release frame runs the reservation
|
||||
// is held at exactly H.
|
||||
it("(a) holds the captured height as reservedHeight after the swap (drives minHeight)", () => {
|
||||
const { ref, setScrollHeight } = makeMenuRef();
|
||||
setScrollHeight(0); // live content not laid out yet -> release cannot fire.
|
||||
const { result, rerender } = renderHook(
|
||||
({ showStatic }) => useSwapHeightReservation(showStatic, ref),
|
||||
{ initialProps: { showStatic: true } },
|
||||
);
|
||||
|
||||
// Capture happens synchronously at the swap point (static still shown).
|
||||
act(() => {
|
||||
result.current.captureReservation(H);
|
||||
});
|
||||
// The swap flips to the live branch.
|
||||
rerender({ showStatic: false });
|
||||
|
||||
expect(result.current.reservedHeight).toBe(H);
|
||||
});
|
||||
|
||||
// (b) release when the live content is tall enough. Guard is `>=`: with
|
||||
// liveHeight === H the reservation releases. This FAILS if the guard direction
|
||||
// were `<` (liveHeight === H is not `< H`, so it would never release).
|
||||
it("(b) releases once live content reaches the reserved height", () => {
|
||||
const { ref, setScrollHeight } = makeMenuRef();
|
||||
setScrollHeight(0);
|
||||
const { result, rerender } = renderHook(
|
||||
({ showStatic }) => useSwapHeightReservation(showStatic, ref),
|
||||
{ initialProps: { showStatic: true } },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.captureReservation(H);
|
||||
});
|
||||
rerender({ showStatic: false });
|
||||
expect(result.current.reservedHeight).toBe(H); // still reserved (short live doc)
|
||||
|
||||
// Live editor finishes laying out to the reserved height.
|
||||
setScrollHeight(H);
|
||||
act(() => {
|
||||
tickRaf();
|
||||
});
|
||||
|
||||
expect(result.current.reservedHeight).toBeNull();
|
||||
});
|
||||
|
||||
// (c) cap escape: the live content never reaches the reserved height, so the
|
||||
// height match never fires; the reservation must still release at the 4000ms
|
||||
// cap (no stuck reservation / dead space). This FAILS if there were no cap: the
|
||||
// loop would poll forever while scrollHeight stays below H.
|
||||
it("(c) releases at the 4000ms cap when live content stays too short", () => {
|
||||
// Only fake Date so `Date.now()` (the cap clock) is controllable; leave our
|
||||
// manual rAF queue in place (default fake timers would replace it).
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(0);
|
||||
const { ref, setScrollHeight } = makeMenuRef();
|
||||
setScrollHeight(H - 100); // always shorter than reserved -> height match never fires.
|
||||
const { result, rerender } = renderHook(
|
||||
({ showStatic }) => useSwapHeightReservation(showStatic, ref),
|
||||
{ initialProps: { showStatic: true } },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.captureReservation(H);
|
||||
});
|
||||
rerender({ showStatic: false });
|
||||
|
||||
// A few frames pass but time has not reached the cap: still reserved.
|
||||
act(() => {
|
||||
tickRaf();
|
||||
});
|
||||
act(() => {
|
||||
tickRaf();
|
||||
});
|
||||
expect(result.current.reservedHeight).toBe(H);
|
||||
|
||||
// Advance past the cap; the next frame releases even though the live content
|
||||
// is still shorter than the reservation.
|
||||
vi.setSystemTime(4001);
|
||||
act(() => {
|
||||
tickRaf();
|
||||
});
|
||||
|
||||
expect(result.current.reservedHeight).toBeNull();
|
||||
});
|
||||
|
||||
// (d) non-swap: without a capture (and while static is shown) there is no
|
||||
// reservation and the release loop never arms, so no rAF is scheduled.
|
||||
it("(d) reserves nothing and arms no loop when the swap never happens", () => {
|
||||
const { ref } = makeMenuRef();
|
||||
const { result } = renderHook(() =>
|
||||
useSwapHeightReservation(true, ref),
|
||||
);
|
||||
|
||||
expect(result.current.reservedHeight).toBeNull();
|
||||
expect(rafQueue.length).toBe(0); // release loop never armed
|
||||
act(() => {
|
||||
tickRaf();
|
||||
});
|
||||
expect(result.current.reservedHeight).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { RefObject, useCallback, useEffect, useState } from "react";
|
||||
|
||||
// Last-resort release deadline. The primary release is the live-content height
|
||||
// match below; this cap only exists so a slow/short live doc can never pin the
|
||||
// reservation forever. It is generous (well past when the live content normally
|
||||
// reaches the reserved height — it renders the SAME content as the static copy)
|
||||
// so a slow load doesn't release mid-render and reintroduce the collapse.
|
||||
const RELEASE_CAP_MS = 4000;
|
||||
|
||||
/**
|
||||
* Reserves the document height across the static -> live editor swap.
|
||||
*
|
||||
* The live editor lays out its content over a few frames, so replacing the
|
||||
* (full-height) static copy with it momentarily shrinks the document; the
|
||||
* browser then clamps window scroll to the top, which yanked the reader off
|
||||
* their restored reading position (and threw their scroll to 0 if they were
|
||||
* scrolling at that moment). Pinning a min-height on the swap wrapper keeps the
|
||||
* document tall through the swap so the scroll position simply survives (#266).
|
||||
* `reservedHeight === null` means no reservation is active.
|
||||
*
|
||||
* The capture is intentionally a CALLBACK the page editor invokes, NOT something
|
||||
* this hook derives by watching `showStatic`. The height MUST be read
|
||||
* synchronously while the static content is still mounted (full natural height),
|
||||
* right before the flip to the live branch. By the time any post-transition
|
||||
* effect here could run, `showStatic` is already false and the wrapper shows the
|
||||
* live/collapsed content, so `offsetHeight` would be wrong. So page-editor calls
|
||||
* `captureReservation(wrapper.offsetHeight)` inside its collab-sync effect,
|
||||
* before `setShowStatic(false)`, preserving that exact timing.
|
||||
*
|
||||
* @param showStatic whether the static (cached) content is still shown.
|
||||
* @param menuContainerRef the live-branch content container. It is a descendant
|
||||
* of the swap wrapper inside the live branch, so its `scrollHeight` is the live
|
||||
* content height (not inflated by the ancestor min-height reservation).
|
||||
*/
|
||||
export function useSwapHeightReservation(
|
||||
showStatic: boolean,
|
||||
menuContainerRef: RefObject<HTMLElement | null>,
|
||||
): {
|
||||
reservedHeight: number | null;
|
||||
captureReservation: (height: number | null) => void;
|
||||
} {
|
||||
const [reservedHeight, setReservedHeight] = useState<number | null>(null);
|
||||
|
||||
// Capture the current (static, full-height) content height BEFORE the swap so
|
||||
// the wrapper can reserve it while the live editor lays out — otherwise the
|
||||
// transient shrink clamps window scroll to the top. The caller reads
|
||||
// `offsetHeight` synchronously at the swap point and hands it here.
|
||||
const captureReservation = useCallback(
|
||||
(height: number | null) => setReservedHeight(height),
|
||||
[],
|
||||
);
|
||||
|
||||
// Release the reserved height once the live editor's content has laid out to
|
||||
// at least the reserved height (so removing the reservation cannot collapse
|
||||
// the document). The primary release is that height match; the cap is only a
|
||||
// last-resort so we never pin forever. A shorter-than-reserved live doc (rare:
|
||||
// stale/longer cache) releases at the cap, leaving only harmless bottom dead
|
||||
// space until then.
|
||||
useEffect(() => {
|
||||
if (showStatic || reservedHeight == null) return;
|
||||
let raf = 0;
|
||||
const startedAt = Date.now();
|
||||
const check = () => {
|
||||
const liveHeight = menuContainerRef.current?.scrollHeight ?? 0;
|
||||
if (
|
||||
liveHeight >= reservedHeight ||
|
||||
Date.now() - startedAt > RELEASE_CAP_MS
|
||||
) {
|
||||
setReservedHeight(null);
|
||||
return;
|
||||
}
|
||||
raf = requestAnimationFrame(check);
|
||||
};
|
||||
raf = requestAnimationFrame(check);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [showStatic, reservedHeight, menuContainerRef]);
|
||||
|
||||
return { reservedHeight, captureReservation };
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -79,6 +79,7 @@ import { jwtDecode } from "jwt-decode";
|
||||
import { searchSpotlight } from "@/features/search/constants.ts";
|
||||
import { useEditorScroll } from "./hooks/use-editor-scroll";
|
||||
import { useScrollRestoreOnSwap } from "./hooks/use-scroll-position";
|
||||
import { useSwapHeightReservation } from "./hooks/use-swap-height-reservation";
|
||||
import { EditorLinkMenu } from "@/features/editor/components/link/link-menu";
|
||||
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
|
||||
import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context";
|
||||
@@ -449,6 +450,22 @@ export default function PageEditor({
|
||||
const hasConnectedOnceRef = useRef(false);
|
||||
const [showStatic, setShowStatic] = useState(true);
|
||||
|
||||
// Reserved height held across the static -> live editor swap. The live editor
|
||||
// lays out its content over a few frames, so replacing the (full-height) static
|
||||
// copy with it momentarily shrinks the document; the browser then clamps window
|
||||
// scroll to the top, which yanked the reader off their restored reading position
|
||||
// (and threw their scroll to 0 if they were scrolling at that moment). Pinning a
|
||||
// min-height on the swap wrapper keeps the document tall through the swap so the
|
||||
// scroll position simply survives. `null` = no reservation active.
|
||||
const swapWrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
// Reserve/release wiring lives in the hook so its capture trigger and release
|
||||
// guard/cap are directly unit-testable. Capture stays synchronous at the swap
|
||||
// point (see the collab-sync effect below); the hook only owns the release.
|
||||
const { reservedHeight, captureReservation } = useSwapHeightReservation(
|
||||
showStatic,
|
||||
menuContainerRef,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (yjsConnectionStatus === WebSocketStatus.Connecting || !isSynced) {
|
||||
@@ -477,6 +494,10 @@ export default function PageEditor({
|
||||
isCollabSynced(yjsConnectionStatus, isSynced)
|
||||
) {
|
||||
hasConnectedOnceRef.current = true;
|
||||
// Capture the current (static, full-height) content height BEFORE the swap
|
||||
// so the wrapper can reserve it while the live editor lays out — otherwise
|
||||
// the transient shrink clamps window scroll to the top.
|
||||
captureReservation(swapWrapperRef.current?.offsetHeight ?? null);
|
||||
setShowStatic(false);
|
||||
}
|
||||
}, [yjsConnectionStatus, isSynced]);
|
||||
@@ -490,6 +511,12 @@ export default function PageEditor({
|
||||
<TransclusionLookupProvider>
|
||||
<PageEmbedLookupProvider>
|
||||
<PageEmbedAncestryProvider hostPageId={pageId}>
|
||||
<div
|
||||
ref={swapWrapperRef}
|
||||
style={
|
||||
reservedHeight != null ? { minHeight: reservedHeight } : undefined
|
||||
}
|
||||
>
|
||||
{showStatic ? (
|
||||
<div style={{ position: "relative" }}>
|
||||
{/* Surface the pre-sync read-only window so edits typed before the
|
||||
@@ -577,6 +604,7 @@ export default function PageEditor({
|
||||
></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageEmbedAncestryProvider>
|
||||
</PageEmbedLookupProvider>
|
||||
</TransclusionLookupProvider>
|
||||
|
||||
@@ -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 { AiAgentBadge } from "@/components/ui/ai-agent-badge.tsx";
|
||||
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
|
||||
import { formattedDate } from "@/lib/time";
|
||||
import classes from "./css/history.module.css";
|
||||
import clsx from "clsx";
|
||||
@@ -99,12 +99,13 @@ const HistoryItem = memo(function HistoryItem({
|
||||
</>
|
||||
)}
|
||||
|
||||
{isAgentEdit && (
|
||||
<AiAgentBadge
|
||||
authorName={historyItem.lastUpdatedBy?.name}
|
||||
{isAgentEdit && historyItem.agent && (
|
||||
<AgentAvatarStack
|
||||
agent={historyItem.agent}
|
||||
launcher={historyItem.launcher}
|
||||
aiChatId={historyItem.lastUpdatedAiChatId}
|
||||
// The history row owns the modal: close it when the badge deep-links
|
||||
// into the chat (the badge no longer reaches into page-history).
|
||||
// The history row owns the modal: close it when the stack deep-links
|
||||
// into the chat (the stack no longer reaches into page-history).
|
||||
onActivate={() => setHistoryModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import type {
|
||||
AgentInfo,
|
||||
LauncherInfo,
|
||||
} from "@/components/ui/agent-avatar-stack.tsx";
|
||||
|
||||
interface IPageHistoryUser {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -24,4 +29,9 @@ 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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
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,17 +207,27 @@ export class CommentService {
|
||||
false,
|
||||
);
|
||||
|
||||
comment.content = commentContent;
|
||||
comment.editedAt = editedAt;
|
||||
comment.updatedAt = editedAt;
|
||||
// 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,
|
||||
});
|
||||
|
||||
this.wsService.emitCommentEvent(comment.spaceId, comment.pageId, {
|
||||
operation: 'commentUpdated',
|
||||
pageId: comment.pageId,
|
||||
comment,
|
||||
comment: updatedComment,
|
||||
});
|
||||
|
||||
return comment;
|
||||
return updatedComment;
|
||||
}
|
||||
|
||||
async resolveComment(
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
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,6 +12,24 @@ 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 {
|
||||
@@ -22,13 +40,30 @@ export class CommentRepo {
|
||||
commentId: string,
|
||||
opts?: { includeCreator: boolean; includeResolvedBy: boolean },
|
||||
): Promise<Comment> {
|
||||
return await this.db
|
||||
const comment = 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) {
|
||||
@@ -37,15 +72,18 @@ export class CommentRepo {
|
||||
.selectAll('comments')
|
||||
.select((eb) => this.withCreator(eb))
|
||||
.select((eb) => this.withResolvedBy(eb))
|
||||
.select((eb) => this.withAgentRole(eb))
|
||||
.where('pageId', '=', pageId);
|
||||
|
||||
return executeWithCursorPagination(query, {
|
||||
const result = await 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(
|
||||
@@ -82,6 +120,12 @@ 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
|
||||
@@ -116,3 +160,30 @@ 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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
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,6 +12,25 @@ 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 {
|
||||
@@ -94,15 +113,18 @@ export class PageHistoryRepo {
|
||||
.select(this.baseFields)
|
||||
.select((eb) => this.withLastUpdatedBy(eb))
|
||||
.select((eb) => this.withContributors(eb))
|
||||
.select((eb) => this.withAgentRole(eb))
|
||||
.where('pageId', '=', pageId);
|
||||
|
||||
return executeWithCursorPagination(query, {
|
||||
const result = await 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(
|
||||
@@ -138,6 +160,12 @@ 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
|
||||
@@ -151,3 +179,30 @@ 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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
node_modules/node_modules
|
||||
Reference in New Issue
Block a user