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
11 changes: 10 additions & 1 deletion web/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,7 @@

/* Shiki Themes */
html.dark .shiki,
html.dark .shiki span {
html.dark .shiki span:not([data-diff-line]) {
color: var(--shiki-dark) !important;
background-color: var(--shiki-dark-bg) !important;
/* Optional, if you also want font styles */
Expand Down Expand Up @@ -433,3 +433,12 @@ html.dark .shiki span {
opacity: 0 !important;
transition: opacity 0.15s ease-out;
}

/* Shimmer animation for streaming code blocks */
@keyframes shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
.animate-shimmer {
animation: shimmer 1.5s ease-in-out infinite;
}
103 changes: 90 additions & 13 deletions web/components/project/chat/components/message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@ import {
RefreshCcw,
} from "lucide-react"
import * as React from "react"
import type { UIMessage } from "ai"
import { CodeApplyProvider } from "../contexts/code-apply-context"
import { stringifyContent } from "../lib/utils"
import { useChat } from "../providers/chat-provider"
import { ContextTab } from "./context-tab"
import { ToolInvocation } from "./tool-invocation"

export type MessageProps = {
messageId?: string
Expand Down Expand Up @@ -114,12 +116,14 @@ const MessageAvatar = ({ src, alt, className }: MessageAvatarProps) => {
export type MessageContentProps = {
children: React.ReactNode
className?: string
parts?: UIMessage["parts"]
} & React.ComponentProps<typeof Markdown> &
React.HTMLProps<HTMLDivElement>

const MessageContent = ({
children,
className,
parts,
...props
}: MessageContentProps) => {
const { role, context, onOpenFile } = useMessage()
Expand All @@ -128,19 +132,92 @@ const MessageContent = ({
const stringifiedContent = React.useMemo(() => {
return stringifyContent(children)
}, [children])

const classNames = cn(
"text-sm rounded-lg p-2 break-words whitespace-normal w-full rounded-lg p-2",
isAssistant
? "bg-background text-foreground"
: "bg-secondary text-secondary-foreground",
className,
)

// For assistant messages with parts, render each part individually
const isToolPart = (type: string) =>
type === "dynamic-tool" || type.startsWith("tool-")
const hasToolParts =
isAssistant &&
parts &&
parts.some((p) => isToolPart(p.type))


const renderedContent = React.useMemo(() => {
const classNames = cn(
"text-sm rounded-lg p-2 break-words whitespace-normal w-full rounded-lg p-2",
isAssistant
? "bg-background text-foreground"
: "bg-secondary text-secondary-foreground",
className,
)
return isAssistant ? (
<Markdown className={classNames} onOpenFile={onOpenFile} {...props}>
{children as string}
</Markdown>
) : (
if (hasToolParts && parts) {
// Separate tool parts from text parts so we can render them distinctly
const elements: React.ReactNode[] = []
let toolGroup: React.ReactNode[] = []

const flushToolGroup = () => {
if (toolGroup.length > 0) {
elements.push(
<div key={`tools-${elements.length}`} className="flex flex-col gap-0.5 py-1.5 px-2">
{toolGroup}
</div>,
)
toolGroup = []
}
}

for (let i = 0; i < parts.length; i++) {
const part = parts[i]
if (part.type === "text") {
flushToolGroup()
if (!part.text) continue
elements.push(
<Markdown
key={i}
className={classNames}
onOpenFile={onOpenFile}
collapsibleCodeBlocks
{...props}
>
{part.text}
</Markdown>,
)
} else if (isToolPart(part.type)) {
const toolPart = part as unknown as {
type: string
toolName: string
toolCallId: string
state: string
input?: Record<string, unknown>
output?: unknown
errorText?: string
}
const toolName =
toolPart.toolName ?? part.type.replace(/^tool-/, "")
toolGroup.push(
<ToolInvocation
key={toolPart.toolCallId}
part={{ ...toolPart, toolName }}
/>,
)
}
// skip step-start and other non-renderable parts
}
flushToolGroup()

return <div className="flex flex-col w-full">{elements}</div>
}

if (isAssistant) {
return (
<Markdown className={classNames} onOpenFile={onOpenFile} collapsibleCodeBlocks {...props}>
{children as string}
</Markdown>
)
}

return (
<div className="relative">
<svg
width="16"
Expand All @@ -155,7 +232,7 @@ const MessageContent = ({
</div>
</div>
)
}, [role, className, children, onOpenFile, props])
}, [hasToolParts, parts, classNames, isAssistant, onOpenFile, props, children])

return (
<div
Expand Down
130 changes: 130 additions & 0 deletions web/components/project/chat/components/tool-invocation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
"use client"

import { cn } from "@/lib/utils"
import {
AlertCircle,
Check,
ChevronRight,
FileText,
FolderTree,
Globe,
Loader2,
Search,
} from "lucide-react"
import { useState } from "react"

export type ToolPart = {
type: string
toolName: string
toolCallId: string
state: string
input?: Record<string, unknown>
output?: unknown
errorText?: string
}

function getToolLabel(toolName: string, input?: Record<string, unknown>) {
switch (toolName) {
case "readFile":
return input?.filePath ? String(input.filePath) : "file"
case "listFiles":
return "project files"
case "searchFiles":
return String(input?.pattern ?? input?.query ?? "files")
case "webSearch":
return String(input?.query ?? "the web")
default:
return toolName
}
}

function getToolVerb(toolName: string, isDone: boolean) {
switch (toolName) {
case "readFile":
return isDone ? "Read" : "Reading"
case "listFiles":
return isDone ? "Listed" : "Listing"
case "searchFiles":
return isDone ? "Searched" : "Searching"
case "webSearch":
return isDone ? "Searched" : "Searching"
default:
return isDone ? "Ran" : "Running"
}
}

function getToolIcon(toolName: string) {
switch (toolName) {
case "readFile":
return FileText
case "listFiles":
return FolderTree
case "searchFiles":
return Search
case "webSearch":
return Globe
default:
return FileText
}
}

export function ToolInvocation({ part }: { part: ToolPart }) {
const [expanded, setExpanded] = useState(false)
const Icon = getToolIcon(part.toolName)
const label = getToolLabel(part.toolName, part.input)
const isLoading =
part.state === "input-available" || part.state === "input-streaming"
const isError = part.state === "output-error"
const isDone = part.state === "output-available"
const verb = getToolVerb(part.toolName, isDone || isError)

return (
<div className="group/tool">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="flex items-center gap-1.5 py-0.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{isLoading && (
<Loader2 size={12} className="shrink-0 animate-spin" />
)}
{isDone && <Icon size={12} className="shrink-0 text-muted-foreground" />}
{isError && (
<AlertCircle size={12} className="shrink-0 text-destructive" />
)}
<span>
{verb}{" "}
<code className="text-[11px] font-mono text-muted-foreground">
{label}
</code>
</span>
<ChevronRight
size={10}
className={cn(
"shrink-0 transition-transform opacity-0 group-hover/tool:opacity-100",
expanded && "rotate-90 opacity-100",
)}
/>
</button>
{expanded && (
<div className="ml-4 mt-1 mb-1 border-l border-border pl-3 text-xs">
{part.input && (
<pre className="rounded-md bg-muted/60 p-2 overflow-x-auto text-[11px] font-mono text-muted-foreground">
{JSON.stringify(part.input, null, 2)}
</pre>
)}
{isDone && part.output != null && (
<pre className="mt-1 rounded-md bg-muted/60 p-2 overflow-x-auto max-h-48 overflow-y-auto text-[11px] font-mono text-muted-foreground">
{typeof part.output === "string"
? part.output
: JSON.stringify(part.output, null, 2)}
</pre>
)}
{isError && part.errorText && (
<p className="text-destructive mt-1">{part.errorText}</p>
)}
</div>
)}
</div>
)
}
4 changes: 3 additions & 1 deletion web/components/project/chat/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,9 @@ function MainChatContent({
onRejectCode={onRejectCode}
onOpenFile={onOpenFile}
>
<MessageContent>{message.content}</MessageContent>
<MessageContent parts={message.parts}>
{message.content}
</MessageContent>
</Message>
)
})}
Expand Down
3 changes: 3 additions & 0 deletions web/components/project/chat/lib/types.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import type { UIMessage } from "ai"

export interface Message {
id?: string
role: "user" | "assistant"
content: string
context?: ContextTab[]
parts?: UIMessage["parts"]
}

export type ContextTab =
Expand Down
21 changes: 15 additions & 6 deletions web/components/project/chat/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,13 +217,22 @@ const pathMatchesTab = (
}

function shouldTreatAsContext(text: string) {
const long = text.length > 400
const paragraphs = text.split("\n").length > 2
const markdown = /[#>*`]|-{2,}|```/.test(text)
const code = /(function|\{|}|\(|\)|=>|class )/.test(text)
const lists = /^[0-9]+\./m.test(text) || /^[-*•]\s/m.test(text)
// Very long text is always context
if (text.length > 1500) return true

return long || paragraphs || markdown || code || lists
// Fenced code blocks are a strong signal
if (/```[\s\S]*```/.test(text)) return true

// Count weak signals — require multiple to trigger
let signals = 0
if (text.length > 500) signals++
if (text.split("\n").length > 8) signals++
if (/^#{1,3}\s/m.test(text)) signals++ // markdown headings (not bare #)
if (/^[-*•]\s.+\n[-*•]\s/m.test(text)) signals++ // actual list with 2+ items
if (/\b(function|const|let|var|class|import|export)\b.*[{(]/.test(text)) signals++ // code patterns
if (/=>\s*[{(]/.test(text)) signals++ // arrow functions

return signals >= 2
}

export {
Expand Down
14 changes: 10 additions & 4 deletions web/components/project/chat/providers/chat-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ function fromUIMessage(
role: aiMsg.role as "user" | "assistant",
content: getTextContent(aiMsg),
context: contextMap.get(aiMsg.id),
parts: aiMsg.parts,
}
}

Expand Down Expand Up @@ -145,6 +146,7 @@ function ChatProvider({ children }: ChatProviderProps) {
// so prepareSendMessagesRequest always reads fresh values
const requestBodyRef = useRef({
contextContent: "",
projectId,
projectType,
activeFileContent,
fileTree,
Expand All @@ -153,6 +155,7 @@ function ChatProvider({ children }: ChatProviderProps) {
})
requestBodyRef.current = {
contextContent: requestBodyRef.current.contextContent,
projectId,
projectType,
activeFileContent,
fileTree,
Expand All @@ -178,11 +181,14 @@ function ChatProvider({ children }: ChatProviderProps) {
// message from `msgs` before calling this.
return {
body: {
messages: msgs.map((m) => ({
role: m.role,
content: getTextContent(m),
})),
messages: msgs
.map((m) => ({
role: m.role,
content: getTextContent(m),
}))
.filter((m) => m.content.length > 0),
context: {
projectId: ref.projectId,
templateType: ref.projectType,
activeFileContent: ref.activeFileContent,
fileTree: ref.fileTree,
Expand Down
Loading