Files
gitmost/apps/client/src/features/ai-chat/components/tool-call-card.tsx
T
claude code agent 227 e0aac5aa04 feat(share): public-share AI chat reuses the internal chat's presentation (#41)
The public-share widget was a separate minimal impl: plain-text answer, static
'Thinking…', no markdown, no tool-cards. Now it renders through the internal
chat's debugged presentational layer (MessageList/MessageItem/TypingIndicator/
ToolCallCard), so a share gets the same incremental streaming, animated typing
indicator, markdown, and tool-call cards. The share keeps its anonymous
transport (useChat + DefaultChatTransport '/api/shares/ai/stream',
credentials:'omit').

The shared components were already prop-driven (UIMessage[] + isStreaming) with
no transport/auth coupling; made the new props additive optionals (emptyState,
showCitations, neutralizeInternalLinks) all defaulting to current behavior, so
the internal chat is unchanged.

Security (review-caught): rendering assistant markdown on the ANONYMOUS share
made internal links (/p/{id}, /settings/...) clickable, which the old plain-text
render didn't. renderChatMarkdown gains neutralizeInternalLinks (true only on
the share): a one-shot DOMPurify afterSanitizeAttributes hook (added/removed by
reference around a single sanitize) strips href from internal/relative/non-http(s)
links (rendered inert) and keeps external http(s) links with
rel=noopener noreferrer nofollow target=_blank. Tests cover both the link
neutralization and the absence of any global-hook leak into internal renders.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:04:18 +03:00

84 lines
2.6 KiB
TypeScript

import { Anchor, Group, Loader, Text, ThemeIcon } from "@mantine/core";
import { IconAlertCircle, IconCheck } from "@tabler/icons-react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
getToolName,
toolCitations,
toolLabelKey,
toolRunState,
ToolUiPart,
} from "@/features/ai-chat/utils/tool-parts.tsx";
import classes from "@/features/ai-chat/components/ai-chat.module.css";
interface ToolCallCardProps {
part: ToolUiPart;
/**
* Whether to render page citation links. Defaults to true (the internal chat,
* where the reader is authenticated and the `/p/{id}` links resolve). The
* public share passes false: an anonymous reader cannot open internal pages,
* so the links would 404/redirect to login. Suppressing them keeps the card
* (the action log itself) while dropping the unusable links.
*/
showCitations?: boolean;
}
/**
* Compact action-log card for a single agent tool invocation. It shows what the
* agent DID (the agent writes without confirmation — D2), its run state
* (running / done / error), and citation link(s) to any referenced page(s).
*/
export default function ToolCallCard({
part,
showCitations = true,
}: ToolCallCardProps) {
const { t } = useTranslation();
const toolName = getToolName(part);
const state = toolRunState(part.state);
const { key, values } = toolLabelKey(toolName);
const citations = showCitations ? toolCitations(part) : [];
return (
<div className={classes.toolCard}>
<Group gap={6} wrap="nowrap" align="center">
{state === "running" && <Loader size={14} />}
{state === "done" && (
<ThemeIcon size={16} radius="xl" color="green" variant="light">
<IconCheck size={12} />
</ThemeIcon>
)}
{state === "error" && (
<ThemeIcon size={16} radius="xl" color="red" variant="light">
<IconAlertCircle size={12} />
</ThemeIcon>
)}
<Text size="sm" fw={500}>
{t(key, values)}
</Text>
</Group>
{state === "error" && part.errorText && (
<Text size="xs" c="red" mt={2}>
{part.errorText}
</Text>
)}
{citations.length > 0 && (
<Group gap={6} mt={4} wrap="wrap">
{citations.map((c) => (
<Anchor
key={c.pageId}
component={Link}
to={c.href}
size="xs"
lineClamp={1}
>
{c.title || t("Open page")}
</Anchor>
))}
</Group>
)}
</div>
);
}