Architecture & design: - Arch A: introduce resolveProvenance() as the single source of truth for deriving a write's actor/aiChatId from the SIGNED identity, and wire it into BOTH transport seams — the REST jwt.strategy and the collab authentication.extension. Previously the collab seam derived actor from the token claim alone and ignored user.isAgent, so a flagged service account's page-content edits over the websocket persisted as lastUpdatedSource='user', drifting from REST. The seams now share one resolver and can't diverge. - Arch B: drop AiAgentBadge's page-history coupling. The generic ui/ badge no longer imports historyAtoms; it exposes an onActivate callback fired after the deep-link, and the history row passes onActivate to close its own modal. Suggestions/warnings: - S1: soften the jwt.strategy provenance comment (applies to every REST write). - S2/suggestion-3: drop the redundant comment-list-item null-aiChatId test (covered by ai-agent-badge.test.tsx). - S3: de-duplicate jwt.strategy.spec test #3 (the no-claim→'user' half duplicated test #2); keep only the signed actor='agent' claim assertion. - W2: add keyboard-activation tests for the badge (Enter/Space, unrelated key). - W3: flip the design doc status to "реализовано (#143)". Tests: - new auth-provenance.decorator.spec.ts unit-tests resolveProvenance + agentSourceFields. - new collab-seam test: is_agent user with no claim → actor='agent' (Arch A regression guard). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
100 lines
3.2 KiB
TypeScript
100 lines
3.2 KiB
TypeScript
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;
|