diff --git a/src/features/workspaces/ai/ai-tool-presentation.ts b/src/features/workspaces/ai/ai-tool-presentation.ts index c9550179..c585f490 100644 --- a/src/features/workspaces/ai/ai-tool-presentation.ts +++ b/src/features/workspaces/ai/ai-tool-presentation.ts @@ -1,4 +1,5 @@ export type AiToolVisibility = "hidden" | "visible"; +export type AiToolActivityIconKind = "code" | "edit" | "file" | "search" | "web"; interface AiToolPresentation { title?: string; @@ -10,22 +11,22 @@ const defaultToolPresentation = { } as const satisfies AiToolPresentation; const aiToolPresentationByName: Readonly> = { - compute: { title: "Computing", visibility: "visible" }, + compute: { title: "Python", visibility: "visible" }, orchestrate: { title: "Working", visibility: "visible" }, - research_deepen: { title: "Researching sources", visibility: "visible" }, - research_discover: { title: "Researching sources", visibility: "visible" }, + research_deepen: { title: "Research", visibility: "visible" }, + research_discover: { title: "Research", visibility: "visible" }, sandbox_bash: { visibility: "hidden" }, - web_links: { title: "Reading the web", visibility: "visible" }, - web_markdown: { title: "Reading the web", visibility: "visible" }, - web_search: { title: "Reading the web", visibility: "visible" }, - workspace_create_items: { title: "Updating workspace", visibility: "visible" }, - workspace_delete_items: { title: "Updating workspace", visibility: "visible" }, - workspace_edit_item: { title: "Updating workspace", visibility: "visible" }, + web_links: { title: "Web links", visibility: "visible" }, + web_markdown: { title: "Web page", visibility: "visible" }, + web_search: { title: "Web search", visibility: "visible" }, + workspace_create_items: { title: "Workspace", visibility: "visible" }, + workspace_delete_items: { title: "Workspace", visibility: "visible" }, + workspace_edit_item: { title: "Workspace", visibility: "visible" }, workspace_link_items: { visibility: "hidden" }, - workspace_list_items: { title: "Reading workspace", visibility: "visible" }, - workspace_move_items: { title: "Updating workspace", visibility: "visible" }, - workspace_read_items: { title: "Reading workspace", visibility: "visible" }, - workspace_rename_item: { title: "Updating workspace", visibility: "visible" }, + workspace_list_items: { title: "Workspace", visibility: "visible" }, + workspace_move_items: { title: "Workspace", visibility: "visible" }, + workspace_read_items: { title: "Workspace", visibility: "visible" }, + workspace_rename_item: { title: "Workspace", visibility: "visible" }, }; export function getAiToolPresentation(toolName: string): AiToolPresentation { @@ -46,6 +47,22 @@ export function getAiToolActivityTitle(input: { title?: string; toolName: string return getAiToolPresentation(input.toolName).title ?? humanizeToolName(input.toolName); } +export function getAiToolActivityIconKind(toolName: string): AiToolActivityIconKind { + if (toolName === "compute" || toolName === "orchestrate") { + return "code"; + } + + if (toolName.startsWith("web_") || toolName.startsWith("research_")) { + return toolName.includes("search") || toolName.includes("discover") ? "search" : "web"; + } + + if (toolName.startsWith("workspace_")) { + return toolName.includes("read") || toolName.includes("list") ? "file" : "edit"; + } + + return "web"; +} + function humanizeToolName(value: string) { return value .split("_") diff --git a/src/features/workspaces/components/WorkspaceCardSkeleton.tsx b/src/features/workspaces/components/WorkspaceCardSkeleton.tsx index 34807b30..286eef43 100644 --- a/src/features/workspaces/components/WorkspaceCardSkeleton.tsx +++ b/src/features/workspaces/components/WorkspaceCardSkeleton.tsx @@ -2,7 +2,7 @@ import { Skeleton } from "#/components/ui/skeleton"; export default function WorkspaceCardSkeleton() { return ( -
+
diff --git a/src/features/workspaces/components/ai-chat/AiChatAssistantPending.tsx b/src/features/workspaces/components/ai-chat/AiChatAssistantPending.tsx index bd0b5a86..7485155e 100644 --- a/src/features/workspaces/components/ai-chat/AiChatAssistantPending.tsx +++ b/src/features/workspaces/components/ai-chat/AiChatAssistantPending.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from "react"; import { RefreshCw } from "lucide-react"; import { Bubble, BubbleContent } from "#/components/ui/bubble"; @@ -33,6 +34,10 @@ function AiChatAssistantPendingBody({ pending }: { pending: AssistantPendingKind ); } + if (pending === "working") { + return ; + } + return ; } @@ -47,6 +52,16 @@ function AiChatThinkingLoader() { ); } +function AiChatWorkingLoader() { + return ( + + + + + + ); +} + export function ThinkExThinkingMark({ className }: { className?: string }) { return ( ); } + +type ThinkExThinkingBlockName = + | "top-left" + | "top-middle" + | "top-right" + | "right-middle" + | "bottom-right" + | "center" + | "bottom-left" + | "red-left"; + +function ThinkExThinkingBlock({ + children, + name, +}: { + children: ReactNode; + name: ThinkExThinkingBlockName; +}) { + return {children}; +} diff --git a/src/features/workspaces/components/ai-chat/AiChatMessageList.tsx b/src/features/workspaces/components/ai-chat/AiChatMessageList.tsx index abab4c8a..a3748e9a 100644 --- a/src/features/workspaces/components/ai-chat/AiChatMessageList.tsx +++ b/src/features/workspaces/components/ai-chat/AiChatMessageList.tsx @@ -1,5 +1,8 @@ import type { ChatErrorClassification, ChatErrorContext } from "@cloudflare/think"; import { AlertCircle, RotateCcw } from "lucide-react"; +import { LazyMotion, domAnimation, m, useReducedMotion } from "motion/react"; +import type { HTMLMotionProps } from "motion/react"; +import type { ReactNode } from "react"; import { useEffect, useRef, useState } from "react"; import ThinkExLogo from "#/components/ThinkExLogo"; import { Button } from "#/components/ui/button"; @@ -41,6 +44,23 @@ type SelectedText = { text: string; }; +const sentMessageAnimation = { + animate: { opacity: 1, scale: 1, y: 0 }, + initial: { opacity: 0, scale: 0.985, y: 18 }, + transition: { duration: 0.22, ease: [0.22, 1, 0.36, 1] }, +} satisfies Pick, "animate" | "initial" | "transition">; + +const tailRowAnimation = { + animate: { opacity: 1, y: 0 }, + initial: { opacity: 0, y: 6 }, + transition: { duration: 0.18, ease: [0.22, 1, 0.36, 1] }, +} satisfies Pick, "animate" | "initial" | "transition">; + +const rowLayoutTransition = { + duration: 0.24, + ease: [0.22, 1, 0.36, 1], +} satisfies HTMLMotionProps<"div">["transition"]; + export type AiChatAssistantErrorState = | { classification?: ChatErrorClassification | null; @@ -74,6 +94,7 @@ interface AiChatMessageListProps { messages: AiChatMessage[]; onRegenerateLastResponse?: () => void; presentation: AiChatPresentation; + sentMessageAnimationId?: string | null; workspaceId: string; } @@ -82,6 +103,7 @@ export default function AiChatMessageList({ messages, onRegenerateLastResponse, presentation, + sentMessageAnimationId, workspaceId, }: AiChatMessageListProps) { const { lastAssistantMessageId, status } = presentation; @@ -89,6 +111,7 @@ export default function AiChatMessageList({ const hasAssistantContent = hasLatestAssistantContent(rows); const isStreamActive = isAiChatStreamActive(status); const listRef = useRef(null); + const shouldReduceMotion = useReducedMotion(); const [selectedText, setSelectedText] = useState(null); const showEmptyState = rows.length === 0 && !assistantError; @@ -125,22 +148,30 @@ export default function AiChatMessageList({ aria-busy={isStreamActive} className={aiChatMessageScrollerContentClassName} > - {rows.map((row) => ( - - - - ))} + + {rows.map((row) => ( + + + + ))} + @@ -166,6 +197,41 @@ export default function AiChatMessageList({ ); } +function AiChatMessageScrollerItem({ + children, + enableLayoutMotion, + entryAnimation, + messageId, + scrollAnchor, +}: { + children: ReactNode; + enableLayoutMotion: boolean; + entryAnimation: "sent" | "tail" | null; + messageId: string; + scrollAnchor: boolean; +}) { + const animationProps: Pick< + HTMLMotionProps<"div">, + "animate" | "initial" | "transition" + > = entryAnimation === "sent" + ? sentMessageAnimation + : entryAnimation === "tail" + ? tailRowAnimation + : { initial: false, transition: rowLayoutTransition }; + + return ( + + + {children} + + + ); +} + function AiChatListRowView({ canRetry, hasAssistantContent, @@ -324,6 +390,21 @@ function getAiChatRowMessageId(row: AiChatListRow) { return row.key; } +function getAiChatRowEntryAnimation( + row: AiChatListRow, + sentMessageAnimationId?: string | null, +): "sent" | "tail" | null { + if (getAiChatRowMessageId(row) === sentMessageAnimationId) { + return "sent"; + } + + if (row.type === "pending") { + return "tail"; + } + + return null; +} + function isAiChatRowScrollAnchor(row: AiChatListRow) { return row.type === "message" && row.message.role === "user"; } diff --git a/src/features/workspaces/components/ai-chat/AiChatThreadView.tsx b/src/features/workspaces/components/ai-chat/AiChatThreadView.tsx index 2ce69ab9..d2d837c3 100644 --- a/src/features/workspaces/components/ai-chat/AiChatThreadView.tsx +++ b/src/features/workspaces/components/ai-chat/AiChatThreadView.tsx @@ -1,4 +1,5 @@ -import { useEffect } from "react"; +import { generateId } from "ai"; +import { useEffect, useState } from "react"; import type { PromptInputMessage } from "#/features/workspaces/components/ai-chat/ai-chat-prompt-input"; import type { AIInspectorSnapshot } from "#/features/workspaces/ai/ai-inspector"; @@ -36,6 +37,7 @@ export default function AiChatThreadView({ threadId: string; }) { const chat = useWorkspaceAiChat({ modelId, threadId }); + const [sentMessageAnimationId, setSentMessageAnimationId] = useState(null); const { connectionError, error, @@ -69,7 +71,7 @@ export default function AiChatThreadView({ }); const sendMessage = (message: PromptInputMessage) => { - const chatMessage = getChatMessageFromPrompt(message); + const chatMessage = getChatMessageFromPrompt(message, generateId()); if (!chatMessage) { return false; @@ -82,6 +84,7 @@ export default function AiChatThreadView({ }); if (didSend) { + setSentMessageAnimationId(chatMessage.id); clearDraftArtifacts(context.workspaceId); } @@ -94,6 +97,7 @@ export default function AiChatThreadView({ assistantError={assistantError} messages={messages} presentation={presentation} + sentMessageAnimationId={sentMessageAnimationId} workspaceId={context.workspaceId} onRegenerateLastResponse={regenerate} /> @@ -118,7 +122,10 @@ export default function AiChatThreadView({ ); } -function getChatMessageFromPrompt(message: PromptInputMessage): AiChatSendMessage | null { +function getChatMessageFromPrompt( + message: PromptInputMessage, + id: string, +): AiChatSendMessage | null { const trimmedText = message.text.trim(); const parts = [ ...(trimmedText ? [{ type: "text" as const, text: trimmedText }] : []), @@ -129,7 +136,7 @@ function getChatMessageFromPrompt(message: PromptInputMessage): AiChatSendMessag return null; } - return { role: "user", parts }; + return { id, role: "user", parts }; } function getAssistantErrorState(input: { diff --git a/src/features/workspaces/components/ai-chat/AiChatToolActivityRow.tsx b/src/features/workspaces/components/ai-chat/AiChatToolActivityRow.tsx index f034cdd9..952e9382 100644 --- a/src/features/workspaces/components/ai-chat/AiChatToolActivityRow.tsx +++ b/src/features/workspaces/components/ai-chat/AiChatToolActivityRow.tsx @@ -1,7 +1,19 @@ -import { ChevronDown } from "lucide-react"; +import { + AlertTriangle, + CheckCircle2, + ChevronDown, + Code2, + FileText, + Globe2, + LoaderCircle, + PencilLine, + Search, +} from "lucide-react"; +import { LazyMotion, domAnimation, m, useReducedMotion } from "motion/react"; +import type { ReactNode } from "react"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "#/components/ui/collapsible"; -import { Marker, MarkerContent } from "#/components/ui/marker"; +import { getAiToolActivityIconKind } from "#/features/workspaces/ai/ai-tool-presentation"; import { AiChatComputeDetails, AiChatComputeImages, @@ -11,7 +23,16 @@ import { type AiChatToolChildActivity, type AiChatToolActivity, } from "#/features/workspaces/components/ai-chat/ai-chat-display-state"; +import { + getToolSourceHostname, + getToolSourcePreviews, + type ToolSourcePreview, +} from "#/features/workspaces/components/ai-chat/ai-chat-tool-source-previews"; import type { AiChatToolPart } from "#/features/workspaces/components/ai-chat/types"; +import { cn } from "#/lib/utils"; + +const INLINE_SOURCE_LIMIT = 3; +const DETAIL_SOURCE_LIMIT = 8; export function AiChatToolActivityRow({ part, @@ -20,6 +41,7 @@ export function AiChatToolActivityRow({ part: AiChatToolPart; nestedChildren?: AiChatToolChildActivity[]; }) { + const shouldReduceMotion = useReducedMotion(); const activity = getToolActivityForPart(part); if (!activity) { @@ -28,40 +50,71 @@ export function AiChatToolActivityRow({ const details = getActivityDetails(activity); const inlineContent = getInlineActivityContent(activity); - const hasDetails = nestedChildren.length > 0 || details !== null; + const sourcePreviews = getToolSourcePreviews(activity); + const hasDetails = nestedChildren.length > 0 || details !== null || sourcePreviews.length > 0; + const content = + !hasDetails && !inlineContent && sourcePreviews.length === 0 ? ( + + ) : ( +
+ {hasDetails ? ( + +
+ + + + +
+ + {details} + {sourcePreviews.length > 0 ? : null} + {nestedChildren.length > 0 ? ( +
+ {nestedChildren.map((child) => ( +
+ {child.summary} +
+ ))} +
+ ) : null} +
+
+ ) : ( + + )} + {inlineContent} +
+ ); - if (!hasDetails && !inlineContent) { - return ; + return {content}; +} + +function ToolActivityMotion({ + children, + disabled, +}: { + children: ReactNode; + disabled: boolean | null; +}) { + if (disabled) { + return children; } return ( -
- {hasDetails ? ( - - - - - - {details} - {nestedChildren.length > 0 ? ( -
- {nestedChildren.map((child) => ( -
- {child.summary} -
- ))} -
- ) : null} -
-
- ) : ( - - )} - {inlineContent} -
+ + + {children} + + ); } @@ -84,27 +137,209 @@ function getActivityDetails(activity: AiChatToolActivity) { function ActivitySummary({ activity, canExpand = false, + sourcePreviews, }: { activity: AiChatToolActivity; canExpand?: boolean; + sourcePreviews: ToolSourcePreview[]; }) { const isRunning = activity.status === "running"; + const title = activity.title; + const summary = activity.summary === title ? "" : activity.summary; + const fullLabel = summary ? `${title} · ${summary}` : title; + + return ( +
+ + + + + + {title} + + {summary ? ( + + {" · "} + {summary} + + ) : null} + + + + {canExpand ? ( +
+ ); +} + +function InlineSourceFavicons({ sources }: { sources: ToolSourcePreview[] }) { + if (sources.length === 0) { + return null; + } + + return ( + + {sources.map((source) => ( + + ))} + + ); +} + +function SourceFaviconLink({ source }: { source: ToolSourcePreview }) { + const hostname = source.url ? getToolSourceHostname(source.url) : null; + const content = ( + + + + ); + + if (!source.url) { + return content; + } + + return ( + + {content} + + ); +} + +function SourceDetailList({ sources }: { sources: ToolSourcePreview[] }) { + const visibleSources = sources.slice(0, DETAIL_SOURCE_LIMIT); + const remainingSourceCount = sources.length - visibleSources.length; + + return ( +
+ {visibleSources.map((source) => ( + + ))} + {remainingSourceCount > 0 ? ( +
+ + {remainingSourceCount} more source{remainingSourceCount === 1 ? "" : "s"} +
+ ) : null} +
+ ); +} + +function SourceDetailItem({ source }: { source: ToolSourcePreview }) { + const hostname = source.url ? getToolSourceHostname(source.url) : null; + const title = source.url ? ( + + {source.title} + + ) : ( + source.title + ); + + return ( +
+ +
+
{title}
+ {hostname || source.kind ? ( +
+ {[source.kind, hostname].filter(Boolean).join(" · ")} +
+ ) : null} + {source.description ? ( +

+ {source.description} +

+ ) : null} +
+
+ ); +} - if (isRunning) { +function Favicon({ + className, + hostname, + title, +}: { + className?: string; + hostname: string | null; + title: string; +}) { + if (!hostname) { return ( - - {activity.summary} - {canExpand ? + + {title.charAt(0).toUpperCase()} + ); } return ( - - - {activity.summary} - - {canExpand ? + + ); +} + +function ToolActivityIcon({ toolName }: { toolName: string }) { + switch (getAiToolActivityIconKind(toolName)) { + case "code": + return