diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index f43e92f4..2a56a9b0 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -1101,6 +1101,7 @@ "Create subpage of {{name}}": "Create subpage of {{name}}", "AI chat": "AI chat", "AI agent": "AI agent", + "AI agent is typing…": "AI agent is typing…", "Send": "Send", "Stop": "Stop", "Chat menu": "Chat menu", @@ -1123,5 +1124,7 @@ "Deleted page (to trash)": "Deleted page (to trash)", "Commented": "Commented", "Resolved comment": "Resolved comment", - "Ran tool {{name}}": "Ran tool {{name}}" + "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}}" } diff --git a/apps/client/src/features/ai-chat/components/ai-chat.module.css b/apps/client/src/features/ai-chat/components/ai-chat.module.css index 6cb47898..419fee2f 100644 --- a/apps/client/src/features/ai-chat/components/ai-chat.module.css +++ b/apps/client/src/features/ai-chat/components/ai-chat.module.css @@ -52,6 +52,52 @@ padding-inline-start: 1.4em; } +/* Animated three-dot "typing" indicator shown while the agent is thinking but + has not yet produced any visible text/tool parts. */ +.typingDots { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.typingDots span { + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--mantine-color-dimmed, var(--mantine-color-gray-5)); + opacity: 0.4; + animation: aiTypingBounce 1.2s infinite ease-in-out; +} + +.typingDots span:nth-child(2) { + animation-delay: 0.2s; +} + +.typingDots span:nth-child(3) { + animation-delay: 0.4s; +} + +@keyframes aiTypingBounce { + 0%, + 80%, + 100% { + transform: translateY(0); + opacity: 0.4; + } + 40% { + transform: translateY(-3px); + opacity: 1; + } +} + +/* Respect reduced-motion preferences: fall back to a static dimmed state. */ +@media (prefers-reduced-motion: reduce) { + .typingDots span { + animation: none; + opacity: 0.6; + } +} + .toolCard { border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); border-radius: var(--mantine-radius-sm); diff --git a/apps/client/src/features/ai-chat/components/message-item.tsx b/apps/client/src/features/ai-chat/components/message-item.tsx index 5dd207a6..5f6b5d0a 100644 --- a/apps/client/src/features/ai-chat/components/message-item.tsx +++ b/apps/client/src/features/ai-chat/components/message-item.tsx @@ -21,6 +21,11 @@ function isToolPart(type: string): boolean { * - `tool-*` / `dynamic-tool` parts -> an action-log card (with citations). * Other part kinds (reasoning, sources, files, step-start) are ignored for v1. * User messages render their text as a right-aligned plain bubble. + * + * This component is intentionally NOT memoized: `useChat` replaces the streaming + * assistant message with a freshly cloned object on every streamed delta, so the + * `message` prop identity (and its `parts`) changes each tick. Re-rendering the + * text parts on each delta is what makes the answer stream in progressively. */ export default function MessageItem({ message }: MessageItemProps) { const { t } = useTranslation(); @@ -47,6 +52,10 @@ export default function MessageItem({ message }: MessageItemProps) { {message.parts.map((part, index) => { if (part.type === "text") { + // Skip empty/whitespace-only text parts (a streaming message often + // starts with an empty text part before the first token arrives); the + // typing indicator covers that gap until real content streams in. + if (!part.text.trim()) return null; const html = renderChatMarkdown(part.text); if (html) { return ( diff --git a/apps/client/src/features/ai-chat/components/message-list.tsx b/apps/client/src/features/ai-chat/components/message-list.tsx index 1c3dd3e0..1c732932 100644 --- a/apps/client/src/features/ai-chat/components/message-list.tsx +++ b/apps/client/src/features/ai-chat/components/message-list.tsx @@ -3,6 +3,7 @@ import { Center, ScrollArea, Stack, Text } from "@mantine/core"; import { useTranslation } from "react-i18next"; import type { UIMessage } from "@ai-sdk/react"; import MessageItem from "@/features/ai-chat/components/message-item.tsx"; +import TypingIndicator from "@/features/ai-chat/components/typing-indicator.tsx"; import classes from "@/features/ai-chat/components/ai-chat.module.css"; interface MessageListProps { @@ -10,20 +11,47 @@ interface MessageListProps { isStreaming: boolean; } +/** True for AI SDK tool parts (static `tool-*` or `dynamic-tool`). */ +function isToolPart(type: string): boolean { + return type.startsWith("tool-") || type === "dynamic-tool"; +} + +/** + * Whether to show the standalone "AI agent is typing…" indicator. It bridges the + * gap between sending and the first streamed content, so it shows only while a + * turn is in flight AND the latest assistant message has nothing visible yet: + * - the last message is still the user's (assistant hasn't started a row), or + * - the last (assistant) message has no non-empty text and no tool part. + * Once any text/tool part arrives, MessageItem renders it and this hides. + */ +function showTypingIndicator(messages: UIMessage[], isStreaming: boolean): boolean { + if (!isStreaming) return false; + const last = messages[messages.length - 1]; + if (!last) return true; // submitted with nothing rendered yet. + if (last.role !== "assistant") return true; // assistant row not started. + const hasVisible = last.parts.some( + (p) => + (p.type === "text" && p.text.trim().length > 0) || isToolPart(p.type), + ); + return !hasVisible; +} + /** * Scrollable transcript. Auto-scrolls to the newest message as it streams in - * (re-runs whenever the message count or the streaming flag changes). + * (re-runs whenever the message count, the streaming flag, or the messages array + * identity changes — the latter updates on every streamed delta). */ export default function MessageList({ messages, isStreaming }: MessageListProps) { const { t } = useTranslation(); const viewportRef = useRef(null); + const typing = showTypingIndicator(messages, isStreaming); useEffect(() => { const el = viewportRef.current; if (el) el.scrollTop = el.scrollHeight; - }, [messages.length, isStreaming, messages]); + }, [messages.length, isStreaming, messages, typing]); - if (messages.length === 0) { + if (messages.length === 0 && !typing) { return (
@@ -39,6 +67,7 @@ export default function MessageList({ messages, isStreaming }: MessageListProps) {messages.map((message) => ( ))} + {typing && } ); diff --git a/apps/client/src/features/ai-chat/components/typing-indicator.tsx b/apps/client/src/features/ai-chat/components/typing-indicator.tsx new file mode 100644 index 00000000..443fe1e1 --- /dev/null +++ b/apps/client/src/features/ai-chat/components/typing-indicator.tsx @@ -0,0 +1,34 @@ +import { Box, Group, Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import classes from "@/features/ai-chat/components/ai-chat.module.css"; + +/** + * Live "AI agent is typing…" placeholder shown while a turn is in flight but the + * latest assistant message has no visible content yet (no rendered text/tool + * parts). It covers the gap between sending and the first streamed token, and is + * replaced by the real assistant message once content starts arriving. + * + * Mirrors the assistant row layout in MessageItem (the dimmed "AI agent" label), + * so it reads as the assistant's bubble taking shape. + */ +export default function TypingIndicator() { + const { t } = useTranslation(); + + return ( + + + {t("AI agent")} + + + + + ); +} diff --git a/apps/client/src/features/ai-chat/utils/tool-parts.tsx b/apps/client/src/features/ai-chat/utils/tool-parts.tsx index 5d76cde1..e7705936 100644 --- a/apps/client/src/features/ai-chat/utils/tool-parts.tsx +++ b/apps/client/src/features/ai-chat/utils/tool-parts.tsx @@ -1,5 +1,3 @@ -import { buildPageUrl } from "@/features/page/page.utils.ts"; - /** * Presentation helpers for AI SDK tool UI parts. The agent writes WITHOUT * confirmation (D2), so a tool part is a LOG of what already happened — never a @@ -30,7 +28,13 @@ export type ToolRunState = "running" | "done" | "error"; export interface ToolCitation { pageId: string; title?: string; - /** Internal route; `/p/{slug}-{id}` resolves via PageRedirect by slugId. */ + /** + * Internal route. The server tools return the page UUID (no slugId), so we + * link to `/p/{uuid}` directly — `extractPageSlugId` treats a bare UUID as + * valid and returns it whole, which `PageRedirect` then resolves. The title + * is the visible label only and must NOT be folded into the slug (that would + * mangle the UUID via the trailing-segment split and 404 the link). + */ href: string; } @@ -89,9 +93,10 @@ function asString(value: unknown): string | undefined { /** * Resolve the page citation(s) a tool part references, from its input/output. * Only output-available parts (the tool returned) yield citations. Search - * returns an array of pages; the page-ops return a single page id. We build the - * link from the page id alone — the `/p/{slug}-{id}` route resolves the page by - * its slugId (PageRedirect), so the space slug is not needed here. + * returns an array of pages; the page-ops return a single page id. We link to + * `/p/{id}` with the raw page UUID — `PageRedirect` resolves it via + * `extractPageSlugId` (which returns a bare UUID unchanged), so the space slug + * and a title slug are not needed here. */ export function toolCitations(part: ToolUiPart): ToolCitation[] { if (part.state !== "output-available") return []; @@ -101,7 +106,7 @@ export function toolCitations(part: ToolUiPart): ToolCitation[] { const push = (id: string | undefined, title?: string): void => { if (!id) return; - citations.push({ pageId: id, title, href: buildPageUrl(undefined, id, title) }); + citations.push({ pageId: id, title, href: `/p/${id}` }); }; const toolName = getToolName(part); diff --git a/apps/client/src/features/page-history/components/history-item.tsx b/apps/client/src/features/page-history/components/history-item.tsx index cc56b191..a8f88a89 100644 --- a/apps/client/src/features/page-history/components/history-item.tsx +++ b/apps/client/src/features/page-history/components/history-item.tsx @@ -1,10 +1,16 @@ -import { Text, Group, UnstyledButton, Avatar, Tooltip } from "@mantine/core"; +import { Text, Group, UnstyledButton, Avatar, Tooltip, Badge } from "@mantine/core"; +import { IconSparkles } from "@tabler/icons-react"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { formattedDate } from "@/lib/time"; import classes from "./css/history.module.css"; import clsx from "clsx"; import { IPageHistory } from "@/features/page-history/types/page.types"; import { memo, useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { useSetAtom } from "jotai"; +import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; +import { activeAiChatIdAtom } from "@/features/ai-chat/atoms/ai-chat-atom.ts"; +import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts"; const MAX_VISIBLE_AVATARS = 5; @@ -17,6 +23,77 @@ interface HistoryItemProps { isActive: boolean; } +/** + * Badge marking a version written by the AI agent (provenance C3 / §7.4). It is + * ADDITIVE — shown next to the human author, never replacing them. When the + * version carries an `aiChatId`, clicking the badge deep-links into that chat: + * it sets the active-chat atom, opens the AI-chat aside tab, and closes the + * history modal. The click is contained (stopPropagation) so it does not also + * trigger the row's version-select. + */ +function AiAgentBadge({ + authorName, + aiChatId, +}: { + authorName?: string; + aiChatId?: string | null; +}) { + const { t } = useTranslation(); + const setAsideState = useSetAtom(asideStateAtom); + const setActiveChatId = useSetAtom(activeAiChatIdAtom); + const setHistoryModalOpen = useSetAtom(historyAtoms); + + 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); + setAsideState({ tab: "ai-chat", isAsideOpen: true }); + setHistoryModalOpen(false); + }, + [aiChatId, setActiveChatId, setAsideState, setHistoryModalOpen], + ); + + const badge = ( + } + style={aiChatId ? { cursor: "pointer" } : undefined} + {...(aiChatId + ? { + // Keep the default Badge root element (not a