From ce457917032d7ed1ec62e41ee456da5606f8a476 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:42:28 -0400 Subject: [PATCH 1/3] feat(workspace): include structural outline in ai context --- .../model/workspace-ai-context-outline.ts | 159 ++++++++++++++++++ .../model/workspace-ai-context-prompt.ts | 75 +++++++++ .../model/workspace-ai-context-snapshot.ts | 2 + .../model/workspace-ai-context-types.ts | 22 +++ .../model/workspace-ai-context-validation.ts | 61 +++++++ 5 files changed, 319 insertions(+) create mode 100644 src/features/workspaces/model/workspace-ai-context-outline.ts diff --git a/src/features/workspaces/model/workspace-ai-context-outline.ts b/src/features/workspaces/model/workspace-ai-context-outline.ts new file mode 100644 index 00000000..d281f925 --- /dev/null +++ b/src/features/workspaces/model/workspace-ai-context-outline.ts @@ -0,0 +1,159 @@ +import { getWorkspaceItemTypeMeta } from "#/features/workspaces/defaults"; +import { buildWorkspaceKernelItemPathIndex } from "#/features/workspaces/kernel/workspace-kernel-paths"; +import type { WorkspaceItem } from "#/features/workspaces/model/types"; + +import type { + WorkspaceAiContextOutline, + WorkspaceAiContextOutlineItem, + WorkspaceAiContextScope, +} from "./workspace-ai-context-types"; + +export const WORKSPACE_AI_CONTEXT_OUTLINE_ITEM_LIMIT = 50; + +type WorkspaceAiContextOutlineRow = { + item: WorkspaceItem; + outlineItem: WorkspaceAiContextOutlineItem; +}; + +export function buildWorkspaceAiContextOutline( + context: WorkspaceAiContextScope, +): WorkspaceAiContextOutline { + const rows = getWorkspaceAiContextOutlineRows(context); + const totalItems = rows.length; + + if (totalItems <= WORKSPACE_AI_CONTEXT_OUTLINE_ITEM_LIMIT) { + return { + status: "included", + totalItems, + items: rows.map((row) => row.outlineItem), + }; + } + + const items = summarizeWorkspaceAiContextOutlineRows({ + rows, + limit: WORKSPACE_AI_CONTEXT_OUTLINE_ITEM_LIMIT, + }).map((row) => row.outlineItem); + + return { + status: "summarized", + totalItems, + limit: WORKSPACE_AI_CONTEXT_OUTLINE_ITEM_LIMIT, + omittedItems: totalItems - items.length, + items, + }; +} + +function getWorkspaceAiContextOutlineRows( + context: WorkspaceAiContextScope, +): WorkspaceAiContextOutlineRow[] { + const items = Array.from(context.itemsById.values()); + const pathsByItemId = buildWorkspaceKernelItemPathIndex(items); + const childCountsByItemId = getWorkspaceAiContextOutlineChildCounts(items); + const descendantCountsByItemId = getWorkspaceAiContextOutlineDescendantCounts(items); + + return Array.from(pathsByItemId, ([itemId, path]) => { + const item = context.itemsById.get(itemId); + + if (!item) { + throw new Error("Workspace outline path index returned an unknown item."); + } + + return { + item, + outlineItem: { + ...(item.type === "folder" + ? { + childCount: childCountsByItemId.get(item.id) ?? 0, + descendantCount: descendantCountsByItemId.get(item.id) ?? 0, + } + : {}), + path, + type: getWorkspaceItemTypeMeta(item.type), + }, + }; + }); +} + +function summarizeWorkspaceAiContextOutlineRows(input: { + rows: WorkspaceAiContextOutlineRow[]; + limit: number; +}) { + const rows: WorkspaceAiContextOutlineRow[] = []; + const selectedItemIds = new Set(); + + const addRow = (row: WorkspaceAiContextOutlineRow) => { + if (rows.length >= input.limit || selectedItemIds.has(row.item.id)) { + return; + } + + selectedItemIds.add(row.item.id); + rows.push(row); + }; + + for (const row of input.rows) { + if (row.item.type === "folder") { + addRow(row); + } + } + + for (const row of input.rows) { + if (row.item.parentId === null && row.item.type !== "folder") { + addRow(row); + } + } + + for (const row of input.rows) { + addRow(row); + } + + return rows; +} + +function getWorkspaceAiContextOutlineChildCounts(items: WorkspaceItem[]) { + const childCountsByItemId = new Map(); + + for (const item of items) { + if (!item.parentId) { + continue; + } + + childCountsByItemId.set(item.parentId, (childCountsByItemId.get(item.parentId) ?? 0) + 1); + } + + return childCountsByItemId; +} + +function getWorkspaceAiContextOutlineDescendantCounts(items: WorkspaceItem[]) { + const childrenByParentId = new Map(); + const descendantCountsByItemId = new Map(); + + for (const item of items) { + const children = childrenByParentId.get(item.parentId) ?? []; + children.push(item); + childrenByParentId.set(item.parentId, children); + } + + const countDescendants = (itemId: string): number => { + const cachedCount = descendantCountsByItemId.get(itemId); + + if (cachedCount !== undefined) { + return cachedCount; + } + + const count = (childrenByParentId.get(itemId) ?? []).reduce( + (total, child) => total + 1 + countDescendants(child.id), + 0, + ); + + descendantCountsByItemId.set(itemId, count); + return count; + }; + + for (const item of items) { + if (item.type === "folder") { + countDescendants(item.id); + } + } + + return descendantCountsByItemId; +} diff --git a/src/features/workspaces/model/workspace-ai-context-prompt.ts b/src/features/workspaces/model/workspace-ai-context-prompt.ts index 2620fff6..43d768ec 100644 --- a/src/features/workspaces/model/workspace-ai-context-prompt.ts +++ b/src/features/workspaces/model/workspace-ai-context-prompt.ts @@ -1,4 +1,5 @@ import type { + WorkspaceAiContextOutline, WorkspaceAiContextPaneReference, WorkspaceAiContextPresentationReference, WorkspaceAiContextSnapshotSelectedQuote, @@ -15,6 +16,9 @@ import { formatWorkspaceAiContextItemViewStateSuffix, } from "./workspace-item-view-state"; +export const WORKSPACE_AI_CONTEXT_OUTLINE_PROMPT_CHAR_LIMIT = 6000; +export const WORKSPACE_AI_CONTEXT_OUTLINE_PATH_CHAR_LIMIT = 180; + export function formatWorkspaceAiContextForPrompt(value: unknown) { if (!isWorkspaceAiContextSnapshot(value)) { return ""; @@ -42,6 +46,12 @@ export function formatWorkspaceAiContextForPrompt(value: unknown) { } } + const outline = value.workspace.outline; + + if (outline) { + lines.push(...formatWorkspaceAiContextOutline(outline)); + } + if (openTabs.length > 0) { lines.push("- Open workspace tabs:"); for (const tab of openTabs) { @@ -62,6 +72,71 @@ export function formatWorkspaceAiContextForPrompt(value: unknown) { return lines.join("\n"); } +function formatWorkspaceAiContextOutline(outline: WorkspaceAiContextOutline) { + const itemLines = limitWorkspaceAiContextOutlineLines( + outline.items.map(formatWorkspaceAiContextOutlineItem), + ); + const omittedItems = outline.totalItems - itemLines.length; + const isComplete = outline.status === "included" && omittedItems === 0; + const lines = isComplete + ? [ + `- Workspace outline: ${outline.totalItems} ${outline.totalItems === 1 ? "item" : "items"} complete, paths/types and folder counts only. Item bodies are not included.`, + ] + : [ + `- Workspace outline: ${outline.totalItems} items total. Showing ${itemLines.length} structural paths, folder-first; this is not complete. Item bodies are not included.`, + ]; + + lines.push(...itemLines); + + if (!isComplete) { + lines.push( + ` - ${omittedItems} items omitted. Use workspace_list_items on a folder before assuming its contents.`, + ); + } + + return lines; +} + +function formatWorkspaceAiContextOutlineItem(item: WorkspaceAiContextOutline["items"][number]) { + return ` - ${truncateWorkspaceAiContextOutlinePath(item.path)} (${formatWorkspaceAiContextOutlineItemMeta(item)})`; +} + +function formatWorkspaceAiContextOutlineItemMeta(item: WorkspaceAiContextOutline["items"][number]) { + const counts = + item.childCount === undefined || item.descendantCount === undefined + ? "" + : `, ${item.childCount} direct ${item.childCount === 1 ? "child" : "children"}, ${item.descendantCount} total ${item.descendantCount === 1 ? "descendant" : "descendants"}`; + + return `${item.type}${counts}`; +} + +function limitWorkspaceAiContextOutlineLines(lines: string[]) { + const selectedLines: string[] = []; + let size = 0; + + for (const line of lines) { + const nextSize = size + line.length + 1; + + if (selectedLines.length > 0 && nextSize > WORKSPACE_AI_CONTEXT_OUTLINE_PROMPT_CHAR_LIMIT) { + break; + } + + selectedLines.push(line); + size = nextSize; + } + + return selectedLines; +} + +function truncateWorkspaceAiContextOutlinePath(path: string) { + if (path.length <= WORKSPACE_AI_CONTEXT_OUTLINE_PATH_CHAR_LIMIT) { + return path; + } + + const edgeLength = Math.floor((WORKSPACE_AI_CONTEXT_OUTLINE_PATH_CHAR_LIMIT - 3) / 2); + return `${path.slice(0, edgeLength)}...${path.slice(-edgeLength)}`; +} + function formatWorkspaceAiContextTab(tab: WorkspaceAiContextTabReference) { const active = tab.active ? "active, " : ""; diff --git a/src/features/workspaces/model/workspace-ai-context-snapshot.ts b/src/features/workspaces/model/workspace-ai-context-snapshot.ts index 9da650fc..98ff22b9 100644 --- a/src/features/workspaces/model/workspace-ai-context-snapshot.ts +++ b/src/features/workspaces/model/workspace-ai-context-snapshot.ts @@ -9,6 +9,7 @@ import { getWorkspaceAiContextItemReference, getWorkspaceAiContextVisibleItemIds, } from "./workspace-ai-context-reference"; +import { buildWorkspaceAiContextOutline } from "./workspace-ai-context-outline"; import type { WorkspaceAiContextPaneReference, WorkspaceAiContextPresentationReference, @@ -39,6 +40,7 @@ export function buildWorkspaceAiContextSnapshot( return { workspace: { name: context.workspaceName, + outline: buildWorkspaceAiContextOutline(context), }, view: { activeItem: activeItem diff --git a/src/features/workspaces/model/workspace-ai-context-types.ts b/src/features/workspaces/model/workspace-ai-context-types.ts index 2af6b715..211dfebd 100644 --- a/src/features/workspaces/model/workspace-ai-context-types.ts +++ b/src/features/workspaces/model/workspace-ai-context-types.ts @@ -23,6 +23,7 @@ export type WorkspaceAiContextScope = { export type WorkspaceAiContextSnapshot = { workspace: { name: string; + outline?: WorkspaceAiContextOutline; }; view: { activeItem?: WorkspaceAiContextItemReference; @@ -35,6 +36,27 @@ export type WorkspaceAiContextSnapshot = { contentIncluded: false; }; +export type WorkspaceAiContextOutline = + | { + status: "included"; + totalItems: number; + items: WorkspaceAiContextOutlineItem[]; + } + | { + status: "summarized"; + totalItems: number; + limit: number; + omittedItems: number; + items: WorkspaceAiContextOutlineItem[]; + }; + +export type WorkspaceAiContextOutlineItem = { + childCount?: number; + descendantCount?: number; + path: string; + type: string; +}; + export type WorkspaceAiContextSnapshotSelectedQuote = { label: string; order: number; diff --git a/src/features/workspaces/model/workspace-ai-context-validation.ts b/src/features/workspaces/model/workspace-ai-context-validation.ts index a6580063..08154bdf 100644 --- a/src/features/workspaces/model/workspace-ai-context-validation.ts +++ b/src/features/workspaces/model/workspace-ai-context-validation.ts @@ -1,5 +1,6 @@ import type { WorkspaceAiContextItemReference, + WorkspaceAiContextOutline, WorkspaceAiContextPaneReference, WorkspaceAiContextPresentationReference, WorkspaceAiContextSelectedItem, @@ -16,6 +17,8 @@ export function isWorkspaceAiContextSnapshot(value: unknown): value is Workspace return ( isRecord(value.workspace) && typeof value.workspace.name === "string" && + (value.workspace.outline === undefined || + isWorkspaceAiContextOutline(value.workspace.outline)) && Array.isArray(value.selectedItems) && Array.isArray(value.openTabs) && Array.isArray(value.selectedQuotes) && @@ -25,6 +28,64 @@ export function isWorkspaceAiContextSnapshot(value: unknown): value is Workspace ); } +function isWorkspaceAiContextOutline(value: unknown): value is WorkspaceAiContextOutline { + if (!isRecord(value)) { + return false; + } + + if ( + typeof value.totalItems !== "number" || + !Number.isInteger(value.totalItems) || + value.totalItems < 0 + ) { + return false; + } + + if (value.status === "summarized") { + return ( + typeof value.limit === "number" && + Number.isInteger(value.limit) && + value.limit > 0 && + value.totalItems > value.limit && + Array.isArray(value.items) && + value.items.length <= value.limit && + typeof value.omittedItems === "number" && + Number.isInteger(value.omittedItems) && + value.omittedItems === value.totalItems - value.items.length && + value.omittedItems > 0 && + value.items.every(isWorkspaceAiContextOutlineItem) + ); + } + + return ( + value.status === "included" && + Array.isArray(value.items) && + value.items.length === value.totalItems && + value.items.every(isWorkspaceAiContextOutlineItem) + ); +} + +function isWorkspaceAiContextOutlineItem(value: unknown) { + if (!isRecord(value)) { + return false; + } + + const hasChildCount = value.childCount !== undefined; + const hasDescendantCount = value.descendantCount !== undefined; + + return ( + typeof value.path === "string" && + typeof value.type === "string" && + hasChildCount === hasDescendantCount && + (value.childCount === undefined || isNonNegativeInteger(value.childCount)) && + (value.descendantCount === undefined || isNonNegativeInteger(value.descendantCount)) + ); +} + +function isNonNegativeInteger(value: unknown) { + return typeof value === "number" && Number.isInteger(value) && value >= 0; +} + export function isWorkspaceAiContextSelectedItem( value: unknown, ): value is WorkspaceAiContextSelectedItem { From ed2a0be5f97985c623147617d448abb03c10b6e4 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:43:01 -0400 Subject: [PATCH 2/3] feat(ai-chat): smooth workspace chat activity states --- .../components/WorkspaceCardSkeleton.tsx | 2 +- .../ai-chat/AiChatAssistantPending.tsx | 67 +++++++++++---- .../components/ai-chat/AiChatMessageList.tsx | 83 ++++++++++++++++++- .../components/ai-chat/AiChatThreadView.tsx | 15 +++- .../ai-chat/ai-chat-display-state.ts | 16 ++-- .../workspaces/components/ai-chat/types.ts | 1 + src/styles.css | 51 +++++++++--- 7 files changed, 193 insertions(+), 42 deletions(-) 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..871472df 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 { motion, 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; @@ -126,8 +149,14 @@ export default function AiChatMessageList({ className={aiChatMessageScrollerContentClassName} > {rows.map((row) => ( - @@ -139,7 +168,7 @@ export default function AiChatMessageList({ status={status} onRegenerateLastResponse={onRegenerateLastResponse} /> - + ))} @@ -166,6 +195,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 +388,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/ai-chat-display-state.ts b/src/features/workspaces/components/ai-chat/ai-chat-display-state.ts index 7593e1f9..b7c4b956 100644 --- a/src/features/workspaces/components/ai-chat/ai-chat-display-state.ts +++ b/src/features/workspaces/components/ai-chat/ai-chat-display-state.ts @@ -12,7 +12,7 @@ import { } from "#/features/workspaces/ai/ai-tool-presentation"; import { getFinishedToolReceipt } from "#/features/workspaces/components/ai-chat/ai-chat-tool-receipts"; -export type AssistantPendingKind = "thinking" | "recovering"; +export type AssistantPendingKind = "thinking" | "working" | "recovering"; export interface AiChatToolChildActivity { summary: string; toolName: string; @@ -75,14 +75,16 @@ export function deriveAiChatPresentation( const hasAssistantTail = lastMessage?.role === "assistant"; const assistantTailIsEmpty = lastMessage?.role === "assistant" && getDisplayableParts(lastMessage).length === 0; + const hasVisibleAssistantTail = hasAssistantTail && !assistantTailIsEmpty; const tailPending = isRecovering - ? hasAssistantTail && !assistantTailIsEmpty - ? null + ? hasVisibleAssistantTail + ? "working" : "recovering" - : !isToolContinuation && - (awaitingFirstToken || (isBusy && (!hasAssistantTail || assistantTailIsEmpty))) - ? "thinking" - : null; + : !isBusy + ? null + : awaitingFirstToken || !hasVisibleAssistantTail + ? "thinking" + : "working"; return { isBusy, diff --git a/src/features/workspaces/components/ai-chat/types.ts b/src/features/workspaces/components/ai-chat/types.ts index 997d557b..5ae2f92c 100644 --- a/src/features/workspaces/components/ai-chat/types.ts +++ b/src/features/workspaces/components/ai-chat/types.ts @@ -8,6 +8,7 @@ export type AiChatToolPart = ToolUIPart | DynamicToolUIPart; export type AiChatModelId = WorkspaceAiChatModelId; export interface AiChatSendMessage { + id: string; role: "user"; parts: AiChatMessagePart[]; } diff --git a/src/styles.css b/src/styles.css index 3bb04e30..e9c6e1df 100644 --- a/src/styles.css +++ b/src/styles.css @@ -358,15 +358,48 @@ } .thinkex-thinking-mark { + --thinkex-thinking-cycle: 2.2s; overflow: visible; } .thinkex-thinking-block { - animation: thinkex-thinking-block 2.3s ease-in-out infinite both; + animation: thinkex-thinking-block-loop var(--thinkex-thinking-cycle) ease-in-out infinite both; opacity: 0; will-change: opacity; } + .thinkex-thinking-block--red-left { + animation-delay: 840ms; + } + + .thinkex-thinking-block--top-left { + animation-delay: 0ms; + } + + .thinkex-thinking-block--top-middle { + animation-delay: 120ms; + } + + .thinkex-thinking-block--top-right { + animation-delay: 240ms; + } + + .thinkex-thinking-block--right-middle { + animation-delay: 360ms; + } + + .thinkex-thinking-block--bottom-right { + animation-delay: 480ms; + } + + .thinkex-thinking-block--center { + animation-delay: 600ms; + } + + .thinkex-thinking-block--bottom-left { + animation-delay: 720ms; + } + @media (prefers-reduced-motion: reduce) { .workspace-ask-selection-button { animation: none; @@ -390,27 +423,21 @@ } } - @keyframes thinkex-thinking-block { + /* One traveling segment with a brief full-logo beat, then a quiet gap before the next loop. */ + @keyframes thinkex-thinking-block-loop { 0% { opacity: 0; } - 12% { - opacity: 0; - } - - 24% { + 7% { opacity: 1; } - 68% { + 54% { opacity: 1; } - 82% { - opacity: 0; - } - + 64%, 100% { opacity: 0; } From 2759d1a512ccf14c1e58567622ac02f9b6a760ee Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:43:18 -0400 Subject: [PATCH 3/3] feat(ai-chat): render tool activity inline --- .../workspaces/ai/ai-tool-presentation.ts | 43 ++- .../components/ai-chat/AiChatMessageList.tsx | 52 +-- .../ai-chat/AiChatToolActivityRow.tsx | 321 +++++++++++++++--- .../ai-chat/ai-chat-display-state.ts | 27 +- .../ai-chat/ai-chat-tool-receipts.ts | 137 +++++++- .../ai-chat/ai-chat-tool-source-previews.ts | 143 ++++++++ 6 files changed, 617 insertions(+), 106 deletions(-) create mode 100644 src/features/workspaces/components/ai-chat/ai-chat-tool-source-previews.ts 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/ai-chat/AiChatMessageList.tsx b/src/features/workspaces/components/ai-chat/AiChatMessageList.tsx index 871472df..a3748e9a 100644 --- a/src/features/workspaces/components/ai-chat/AiChatMessageList.tsx +++ b/src/features/workspaces/components/ai-chat/AiChatMessageList.tsx @@ -1,6 +1,6 @@ import type { ChatErrorClassification, ChatErrorContext } from "@cloudflare/think"; import { AlertCircle, RotateCcw } from "lucide-react"; -import { motion, useReducedMotion } from "motion/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"; @@ -148,28 +148,30 @@ export default function AiChatMessageList({ aria-busy={isStreamActive} className={aiChatMessageScrollerContentClassName} > - {rows.map((row) => ( - - - - ))} + + {rows.map((row) => ( + + + + ))} + @@ -219,13 +221,13 @@ function AiChatMessageScrollerItem({ return ( - {children} - + ); } 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