Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 30 additions & 13 deletions src/features/workspaces/ai/ai-tool-presentation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export type AiToolVisibility = "hidden" | "visible";
export type AiToolActivityIconKind = "code" | "edit" | "file" | "search" | "web";

interface AiToolPresentation {
title?: string;
Expand All @@ -10,22 +11,22 @@ const defaultToolPresentation = {
} as const satisfies AiToolPresentation;

const aiToolPresentationByName: Readonly<Record<string, AiToolPresentation>> = {
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 {
Expand All @@ -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("_")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Skeleton } from "#/components/ui/skeleton";

export default function WorkspaceCardSkeleton() {
return (
<div className="relative flex overflow-hidden rounded-xl bg-muted/35 shadow-none ring-0 sm:block sm:bg-card sm:shadow-xs sm:ring-1 sm:ring-foreground/10 dark:bg-muted/20">
<div className="relative flex overflow-hidden rounded-xl bg-muted/35 shadow-none sm:block sm:bg-card sm:shadow-xs dark:bg-muted/20">
<Skeleton className="size-14 shrink-0 rounded-none bg-muted/45 sm:aspect-[5/2] sm:size-auto sm:w-full" />
<Skeleton className="absolute top-1/2 right-2 size-8 -translate-y-1/2 rounded-md bg-muted/55 sm:top-2 sm:translate-y-0" />
<div className="min-w-0 flex-1 space-y-2 px-3 py-2.5 pr-12 sm:px-4 sm:py-3 sm:pr-4">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { ReactNode } from "react";
import { RefreshCw } from "lucide-react";

import { Bubble, BubbleContent } from "#/components/ui/bubble";
Expand Down Expand Up @@ -33,6 +34,10 @@ function AiChatAssistantPendingBody({ pending }: { pending: AssistantPendingKind
);
}

if (pending === "working") {
return <AiChatWorkingLoader />;
}

return <AiChatThinkingLoader />;
}

Expand All @@ -47,6 +52,16 @@ function AiChatThinkingLoader() {
);
}

function AiChatWorkingLoader() {
return (
<Marker role="status" aria-label="Still working" aria-live="polite" className="gap-0 py-1.5">
<MarkerIcon className="size-[18px]">
<ThinkExThinkingMark />
</MarkerIcon>
</Marker>
);
}

export function ThinkExThinkingMark({ className }: { className?: string }) {
return (
<svg
Expand All @@ -56,30 +71,30 @@ export function ThinkExThinkingMark({ className }: { className?: string }) {
}
aria-hidden="true"
>
<g className="thinkex-thinking-block" style={{ animationDelay: "0ms" }}>
<ThinkExThinkingBlock name="top-left">
<rect fill="currentColor" width="139.636" height="139.636" rx="18.5818" />
</g>
<g className="thinkex-thinking-block" style={{ animationDelay: "95ms" }}>
</ThinkExThinkingBlock>
<ThinkExThinkingBlock name="top-middle">
<rect fill="currentColor" x="186.182" width="139.636" height="116.364" rx="18.5818" />
</g>
<g className="thinkex-thinking-block" style={{ animationDelay: "215ms" }}>
</ThinkExThinkingBlock>
<ThinkExThinkingBlock name="top-right">
<rect fill="currentColor" x="372.364" width="139.636" height="139.636" rx="18.5818" />
</g>
<g className="thinkex-thinking-block" style={{ animationDelay: "355ms" }}>
</ThinkExThinkingBlock>
<ThinkExThinkingBlock name="right-middle">
<path
fill="#5C8BD6"
fillRule="evenodd"
d="M 387.9458 183.1820 H 493.4182 Q 512.0000 183.1820 512.0000 201.7638 V 356.7822 Q 512.0000 375.3640 493.4182 375.3640 H 387.9458 Q 369.3640 375.3640 369.3640 356.7822 V 201.7638 Q 369.3640 183.1820 387.9458 183.1820 Z M 398.8640 205.1820 Q 391.3640 205.1820 391.3640 212.6820 V 345.8640 Q 391.3640 353.3640 398.8640 353.3640 H 485.5000 Q 493.0000 353.3640 493.0000 345.8640 V 212.6820 Q 493.0000 205.1820 485.5000 205.1820 H 398.8640 Z"
/>
</g>
<g className="thinkex-thinking-block" style={{ animationDelay: "520ms" }}>
</ThinkExThinkingBlock>
<ThinkExThinkingBlock name="bottom-right">
<path
fill="#F7B53B"
fillRule="evenodd"
d="M 387.9458 415.9090 H 493.4182 Q 512.0000 415.9090 512.0000 434.4908 V 493.4182 Q 512.0000 512.0000 493.4182 512.0000 H 387.9458 Q 369.3640 512.0000 369.3640 493.4182 V 434.4908 Q 369.3640 415.9090 387.9458 415.9090 Z M 398.8640 437.9090 Q 391.3640 437.9090 391.3640 445.4090 V 485.4999 Q 391.3640 492.9999 398.8640 492.9999 H 485.5000 Q 493.0000 492.9999 493.0000 485.4999 V 445.4090 Q 493.0000 437.9090 485.5000 437.9090 H 398.8640 Z"
/>
</g>
<g className="thinkex-thinking-block" style={{ animationDelay: "700ms" }}>
</ThinkExThinkingBlock>
<ThinkExThinkingBlock name="center">
<rect
fill="currentColor"
x="186.182"
Expand All @@ -88,21 +103,41 @@ export function ThinkExThinkingMark({ className }: { className?: string }) {
height="349.091"
rx="18.5818"
/>
</g>
<g className="thinkex-thinking-block" style={{ animationDelay: "905ms" }}>
</ThinkExThinkingBlock>
<ThinkExThinkingBlock name="bottom-left">
<path
fill="#73BF7A"
fillRule="evenodd"
d="M 18.5818 322.8180 H 124.0542 Q 142.6360 322.8180 142.6360 341.3998 V 493.4182 Q 142.6360 512.0000 124.0542 512.0000 H 18.5818 Q 0.0000 512.0000 0.0000 493.4182 V 341.3998 Q 0.0000 322.8180 18.5818 322.8180 Z M 26.5000 344.8180 Q 19.0000 344.8180 19.0000 352.3180 V 485.5000 Q 19.0000 493.0000 26.5000 493.0000 H 113.1360 Q 120.6360 493.0000 120.6360 485.5000 V 352.3180 Q 120.6360 344.8180 113.1360 344.8180 H 26.5000 Z"
/>
</g>
<g className="thinkex-thinking-block" style={{ animationDelay: "1140ms" }}>
</ThinkExThinkingBlock>
<ThinkExThinkingBlock name="red-left">
<path
fill="#DA4944"
fillRule="evenodd"
d="M 18.5818 183.1820 H 124.0542 Q 142.6360 183.1820 142.6360 201.7638 V 263.6911 Q 142.6360 282.2729 124.0542 282.2729 H 18.5818 Q 0.0000 282.2729 0.0000 263.6911 V 201.7638 Q 0.0000 183.1820 18.5818 183.1820 Z M 26.5000 205.1820 Q 19.0000 205.1820 19.0000 212.6820 V 252.7729 Q 19.0000 260.2729 26.5000 260.2729 H 113.1360 Q 120.6360 260.2729 120.6360 252.7729 V 212.6820 Q 120.6360 205.1820 113.1360 205.1820 H 26.5000 Z"
/>
</g>
</ThinkExThinkingBlock>
</svg>
);
}

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 <g className={`thinkex-thinking-block thinkex-thinking-block--${name}`}>{children}</g>;
}
113 changes: 97 additions & 16 deletions src/features/workspaces/components/ai-chat/AiChatMessageList.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<HTMLMotionProps<"div">, "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<HTMLMotionProps<"div">, "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;
Expand Down Expand Up @@ -74,6 +94,7 @@ interface AiChatMessageListProps {
messages: AiChatMessage[];
onRegenerateLastResponse?: () => void;
presentation: AiChatPresentation;
sentMessageAnimationId?: string | null;
workspaceId: string;
}

Expand All @@ -82,13 +103,15 @@ export default function AiChatMessageList({
messages,
onRegenerateLastResponse,
presentation,
sentMessageAnimationId,
workspaceId,
}: AiChatMessageListProps) {
const { lastAssistantMessageId, status } = presentation;
const rows = getAiChatListRows(messages, presentation, assistantError);
const hasAssistantContent = hasLatestAssistantContent(rows);
const isStreamActive = isAiChatStreamActive(status);
const listRef = useRef<HTMLDivElement>(null);
const shouldReduceMotion = useReducedMotion();
const [selectedText, setSelectedText] = useState<SelectedText | null>(null);
const showEmptyState = rows.length === 0 && !assistantError;

Expand Down Expand Up @@ -125,22 +148,30 @@ export default function AiChatMessageList({
aria-busy={isStreamActive}
className={aiChatMessageScrollerContentClassName}
>
{rows.map((row) => (
<MessageScrollerItem
key={row.key}
messageId={getAiChatRowMessageId(row)}
scrollAnchor={isAiChatRowScrollAnchor(row)}
>
<AiChatListRowView
canRetry={Boolean(onRegenerateLastResponse)}
hasAssistantContent={hasAssistantContent}
lastAssistantMessageId={lastAssistantMessageId}
row={row}
status={status}
onRegenerateLastResponse={onRegenerateLastResponse}
/>
</MessageScrollerItem>
))}
<LazyMotion features={domAnimation}>
{rows.map((row) => (
<AiChatMessageScrollerItem
key={row.key}
entryAnimation={
shouldReduceMotion
? null
: getAiChatRowEntryAnimation(row, sentMessageAnimationId)
}
enableLayoutMotion={!shouldReduceMotion}
messageId={getAiChatRowMessageId(row)}
scrollAnchor={isAiChatRowScrollAnchor(row)}
>
<AiChatListRowView
canRetry={Boolean(onRegenerateLastResponse)}
hasAssistantContent={hasAssistantContent}
lastAssistantMessageId={lastAssistantMessageId}
row={row}
status={status}
onRegenerateLastResponse={onRegenerateLastResponse}
/>
</AiChatMessageScrollerItem>
))}
</LazyMotion>
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton className={aiChatMessageScrollerButtonClassName} />
Expand All @@ -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 (
<MessageScrollerItem messageId={messageId} scrollAnchor={scrollAnchor}>
<m.div
layout={enableLayoutMotion ? "position" : false}
className="min-w-0"
{...animationProps}
>
{children}
</m.div>
</MessageScrollerItem>
);
}

function AiChatListRowView({
canRetry,
hasAssistantContent,
Expand Down Expand Up @@ -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";
}
Expand Down
Loading
Loading