Initial commit
This commit is contained in:
2136
src/components/message/content-parts-renderer.tsx
Normal file
2136
src/components/message/content-parts-renderer.tsx
Normal file
File diff suppressed because it is too large
Load Diff
332
src/components/message/live-turn-stats.tsx
Normal file
332
src/components/message/live-turn-stats.tsx
Normal file
@@ -0,0 +1,332 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import type { LiveMessage } from "@/contexts/acp-connections-context"
|
||||
import { inferLiveToolName } from "@/lib/tool-call-normalization"
|
||||
import {
|
||||
countUnifiedDiffLineChanges,
|
||||
estimateChangedLineStats,
|
||||
} from "@/lib/line-change-stats"
|
||||
import { FilePenLine, Timer, Wrench } from "lucide-react"
|
||||
|
||||
function formatElapsed(ms: number): string {
|
||||
if (ms >= 60_000) return `${(ms / 60_000).toFixed(1)}m`
|
||||
return `${(ms / 1_000).toFixed(1)}s`
|
||||
}
|
||||
|
||||
interface LiveTurnStatsProps {
|
||||
message: LiveMessage
|
||||
}
|
||||
|
||||
interface LineChangeStats {
|
||||
additions: number
|
||||
deletions: number
|
||||
}
|
||||
|
||||
interface LiveEditStats extends LineChangeStats {
|
||||
files: number
|
||||
}
|
||||
|
||||
const COMPACT_NUMBER = new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1,
|
||||
})
|
||||
|
||||
function formatCompactInt(n: number): string {
|
||||
if (n < 1000) return String(n)
|
||||
return COMPACT_NUMBER.format(n).toUpperCase()
|
||||
}
|
||||
|
||||
function asObject(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
function parseInputObject(
|
||||
input: string | null
|
||||
): Record<string, unknown> | null {
|
||||
if (!input) return null
|
||||
try {
|
||||
return asObject(JSON.parse(input))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function unescapeInlineEscapes(text: string): string {
|
||||
return text
|
||||
.replace(/\\r\\n/g, "\n")
|
||||
.replace(/\\n/g, "\n")
|
||||
.replace(/\\t/g, "\t")
|
||||
}
|
||||
|
||||
function looksLikeDiffPayload(input: string): boolean {
|
||||
const normalized = unescapeInlineEscapes(input)
|
||||
return (
|
||||
normalized.includes("*** Begin Patch") ||
|
||||
normalized.includes("*** Update File:") ||
|
||||
/^diff --git /m.test(normalized) ||
|
||||
(/^--- .+/m.test(normalized) && /^\+\+\+ .+/m.test(normalized)) ||
|
||||
/^@@ /m.test(normalized)
|
||||
)
|
||||
}
|
||||
|
||||
function extractPatchText(
|
||||
rawInput: string | null,
|
||||
parsed: Record<string, unknown> | null
|
||||
): string | null {
|
||||
if (!rawInput) return null
|
||||
if (looksLikeDiffPayload(rawInput)) return unescapeInlineEscapes(rawInput)
|
||||
if (!parsed) return null
|
||||
|
||||
const candidates = [
|
||||
parsed.patch,
|
||||
parsed.diff,
|
||||
parsed.unified_diff,
|
||||
parsed.unifiedDiff,
|
||||
parsed.command,
|
||||
parsed.input,
|
||||
parsed.arguments,
|
||||
parsed.payload,
|
||||
]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate !== "string") continue
|
||||
if (looksLikeDiffPayload(candidate)) return unescapeInlineEscapes(candidate)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function addPathIfValid(paths: Set<string>, value: unknown): void {
|
||||
if (typeof value !== "string") return
|
||||
const path = value.trim()
|
||||
if (!path) return
|
||||
paths.add(path)
|
||||
}
|
||||
|
||||
function collectParsedPaths(
|
||||
parsed: Record<string, unknown> | null
|
||||
): Set<string> {
|
||||
const paths = new Set<string>()
|
||||
if (!parsed) return paths
|
||||
|
||||
addPathIfValid(
|
||||
paths,
|
||||
parsed.file_path ?? parsed.filePath ?? parsed.path ?? parsed.notebook_path
|
||||
)
|
||||
|
||||
const changes = asObject(parsed.changes)
|
||||
if (changes) {
|
||||
for (const path of Object.keys(changes)) {
|
||||
addPathIfValid(paths, path)
|
||||
}
|
||||
}
|
||||
|
||||
return paths
|
||||
}
|
||||
|
||||
function parseApplyPatchStats(patch: string): {
|
||||
files: Set<string>
|
||||
additions: number
|
||||
deletions: number
|
||||
} {
|
||||
const files = new Set<string>()
|
||||
let additions = 0
|
||||
let deletions = 0
|
||||
|
||||
for (const line of patch.split("\n")) {
|
||||
if (line.startsWith("*** Add File: ")) {
|
||||
addPathIfValid(files, line.slice(14))
|
||||
continue
|
||||
}
|
||||
if (line.startsWith("*** Update File: ")) {
|
||||
addPathIfValid(files, line.slice(17))
|
||||
continue
|
||||
}
|
||||
if (line.startsWith("*** Delete File: ")) {
|
||||
addPathIfValid(files, line.slice(17))
|
||||
continue
|
||||
}
|
||||
if (line.startsWith("+++ ")) {
|
||||
const normalized = line.slice(4).replace(/^b\//, "").trim()
|
||||
if (normalized && normalized !== "/dev/null") {
|
||||
files.add(normalized)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (line.startsWith("+") && !line.startsWith("+++")) additions += 1
|
||||
if (line.startsWith("-") && !line.startsWith("---")) deletions += 1
|
||||
}
|
||||
|
||||
return { files, additions, deletions }
|
||||
}
|
||||
|
||||
function extractEditStats(parsed: Record<string, unknown>): LineChangeStats {
|
||||
const changes = asObject(parsed.changes)
|
||||
if (changes) {
|
||||
let additions = 0
|
||||
let deletions = 0
|
||||
|
||||
for (const change of Object.values(changes)) {
|
||||
const record = asObject(change)
|
||||
if (!record) continue
|
||||
|
||||
const unifiedDiff =
|
||||
(typeof record.unifiedDiff === "string" && record.unifiedDiff) ||
|
||||
(typeof record.unified_diff === "string" && record.unified_diff) ||
|
||||
null
|
||||
|
||||
if (unifiedDiff) {
|
||||
const stats = countUnifiedDiffLineChanges(unifiedDiff)
|
||||
additions += stats.additions
|
||||
deletions += stats.deletions
|
||||
continue
|
||||
}
|
||||
|
||||
const oldString =
|
||||
(typeof record.oldText === "string" && record.oldText) ||
|
||||
(typeof record.old_string === "string" && record.old_string) ||
|
||||
""
|
||||
const newString =
|
||||
(typeof record.newText === "string" && record.newText) ||
|
||||
(typeof record.new_string === "string" && record.new_string) ||
|
||||
""
|
||||
|
||||
const estimated = estimateChangedLineStats(oldString, newString)
|
||||
additions += estimated.additions
|
||||
deletions += estimated.deletions
|
||||
}
|
||||
|
||||
return { additions, deletions }
|
||||
}
|
||||
|
||||
const oldString =
|
||||
(typeof parsed.old_string === "string" && parsed.old_string) ||
|
||||
(typeof parsed.oldText === "string" && parsed.oldText) ||
|
||||
""
|
||||
const newString =
|
||||
(typeof parsed.new_string === "string" && parsed.new_string) ||
|
||||
(typeof parsed.newText === "string" && parsed.newText) ||
|
||||
""
|
||||
|
||||
return estimateChangedLineStats(oldString, newString)
|
||||
}
|
||||
|
||||
function extractWriteStats(parsed: Record<string, unknown>): LineChangeStats {
|
||||
const content =
|
||||
(typeof parsed.content === "string" && parsed.content) ||
|
||||
(typeof parsed.new_source === "string" && parsed.new_source) ||
|
||||
""
|
||||
|
||||
const additions = content.length === 0 ? 0 : content.split("\n").length
|
||||
return { additions, deletions: 0 }
|
||||
}
|
||||
|
||||
function extractLiveEditStats(message: LiveMessage): LiveEditStats {
|
||||
const files = new Set<string>()
|
||||
let additions = 0
|
||||
let deletions = 0
|
||||
|
||||
for (const block of message.content) {
|
||||
if (block.type !== "tool_call") continue
|
||||
const toolName = inferLiveToolName({
|
||||
title: block.info.title,
|
||||
kind: block.info.kind,
|
||||
rawInput: block.info.raw_input,
|
||||
})
|
||||
if (
|
||||
toolName !== "edit" &&
|
||||
toolName !== "write" &&
|
||||
toolName !== "apply_patch"
|
||||
)
|
||||
continue
|
||||
|
||||
const parsed = parseInputObject(block.info.raw_input)
|
||||
for (const path of collectParsedPaths(parsed)) files.add(path)
|
||||
|
||||
if (toolName === "apply_patch") {
|
||||
const patch = extractPatchText(block.info.raw_input, parsed)
|
||||
if (!patch) continue
|
||||
const stats = parseApplyPatchStats(patch)
|
||||
for (const path of stats.files) files.add(path)
|
||||
additions += stats.additions
|
||||
deletions += stats.deletions
|
||||
continue
|
||||
}
|
||||
|
||||
if (!parsed) continue
|
||||
|
||||
const stats =
|
||||
toolName === "edit" ? extractEditStats(parsed) : extractWriteStats(parsed)
|
||||
additions += stats.additions
|
||||
deletions += stats.deletions
|
||||
}
|
||||
|
||||
return { files: files.size, additions, deletions }
|
||||
}
|
||||
|
||||
export function LiveTurnStats({ message }: LiveTurnStatsProps) {
|
||||
const [elapsed, setElapsed] = useState(() => Date.now() - message.startedAt)
|
||||
const editStats = useMemo(() => extractLiveEditStats(message), [message])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setElapsed(Date.now() - message.startedAt)
|
||||
}, 100)
|
||||
return () => clearInterval(timer)
|
||||
}, [message.startedAt])
|
||||
|
||||
// Count tool calls from live content
|
||||
let toolCallCount = 0
|
||||
let isThinking = false
|
||||
|
||||
for (const block of message.content) {
|
||||
if (block.type === "tool_call") {
|
||||
toolCallCount++
|
||||
} else if (block.type === "thinking") {
|
||||
isThinking = true
|
||||
}
|
||||
}
|
||||
|
||||
// If the last block is thinking, mark as currently thinking
|
||||
const lastBlock = message.content[message.content.length - 1]
|
||||
if (lastBlock?.type === "thinking") {
|
||||
isThinking = true
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-8 shrink-0 items-center justify-center gap-3 px-4 text-xs leading-none text-muted-foreground">
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-primary animate-pulse shrink-0" />
|
||||
{isThinking && message.content.length <= 1 ? (
|
||||
<span>Thinking...</span>
|
||||
) : (
|
||||
<span>Streaming</span>
|
||||
)}
|
||||
<span className="text-border leading-none">|</span>
|
||||
<span className="inline-flex items-center gap-1 leading-none">
|
||||
<Timer className="h-3 w-3 shrink-0" />
|
||||
{formatElapsed(elapsed)}
|
||||
</span>
|
||||
{editStats.files > 0 && (
|
||||
<>
|
||||
<span className="text-border leading-none">|</span>
|
||||
<span className="inline-flex items-center gap-1 leading-none">
|
||||
<FilePenLine className="h-3 w-3 shrink-0" />
|
||||
{editStats.files}F +{formatCompactInt(editStats.additions)}/-
|
||||
{formatCompactInt(editStats.deletions)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{toolCallCount > 0 && (
|
||||
<>
|
||||
<span className="text-border leading-none">|</span>
|
||||
<span className="inline-flex items-center gap-1 leading-none">
|
||||
<Wrench className="h-3 w-3 shrink-0" />
|
||||
{toolCallCount} tool {toolCallCount === 1 ? "use" : "uses"}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
60
src/components/message/message-bubble.tsx
Normal file
60
src/components/message/message-bubble.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
"use client"
|
||||
|
||||
import { memo } from "react"
|
||||
import { formatDistanceToNow } from "date-fns"
|
||||
import { User, Bot, Terminal, Cpu } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ContentPartsRenderer } from "./content-parts-renderer"
|
||||
import type { AdaptedMessage } from "@/lib/adapters/ai-elements-adapter"
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: AdaptedMessage
|
||||
}
|
||||
|
||||
function RoleIcon({ role }: { role: string }) {
|
||||
switch (role) {
|
||||
case "user":
|
||||
return <User className="h-4 w-4" />
|
||||
case "assistant":
|
||||
return <Bot className="h-4 w-4" />
|
||||
case "system":
|
||||
return <Cpu className="h-4 w-4" />
|
||||
case "tool":
|
||||
return <Terminal className="h-4 w-4" />
|
||||
default:
|
||||
return <Bot className="h-4 w-4" />
|
||||
}
|
||||
}
|
||||
|
||||
export const MessageBubble = memo(function MessageBubble({
|
||||
message,
|
||||
}: MessageBubbleProps) {
|
||||
const isUser = message.role === "user"
|
||||
const timeAgo = formatDistanceToNow(new Date(message.timestamp), {
|
||||
addSuffix: true,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className={cn("flex gap-3 px-4 py-3", isUser ? "bg-muted/30" : "")}>
|
||||
<div
|
||||
className={cn(
|
||||
"w-7 h-7 rounded-full flex items-center justify-center shrink-0 mt-0.5",
|
||||
isUser
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-secondary text-secondary-foreground"
|
||||
)}
|
||||
>
|
||||
<RoleIcon role={message.role} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium capitalize">{message.role}</span>
|
||||
<span className="text-xs text-muted-foreground">{timeAgo}</span>
|
||||
</div>
|
||||
|
||||
<ContentPartsRenderer parts={message.content} role={message.role} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
320
src/components/message/message-list-view.tsx
Normal file
320
src/components/message/message-list-view.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
"use client"
|
||||
|
||||
import { memo, useEffect, useMemo, useRef } from "react"
|
||||
import { useDbMessageDetail } from "@/hooks/use-db-message-detail"
|
||||
import { ContentPartsRenderer } from "./content-parts-renderer"
|
||||
import {
|
||||
adaptMessageTurns,
|
||||
type AdaptedMessage,
|
||||
type AdaptedContentPart,
|
||||
type MessageGroup,
|
||||
type UserResourceDisplay,
|
||||
groupAdaptedMessages,
|
||||
extractUserResourcesFromText,
|
||||
} from "@/lib/adapters/ai-elements-adapter"
|
||||
import { TurnStats } from "./turn-stats"
|
||||
import { LiveTurnStats } from "./live-turn-stats"
|
||||
import { UserResourceLinks } from "./user-resource-links"
|
||||
import { useSessionStats } from "@/contexts/session-stats-context"
|
||||
import { LiveMessageBlock } from "@/components/chat/live-message-block"
|
||||
import { AgentPlanOverlay } from "@/components/chat/agent-plan-overlay"
|
||||
import type { LiveMessage } from "@/contexts/acp-connections-context"
|
||||
import {
|
||||
MessageThread,
|
||||
MessageThreadContent,
|
||||
} from "@/components/ai-elements/message-thread"
|
||||
import { Message, MessageContent } from "@/components/ai-elements/message"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import {
|
||||
buildPlanKey,
|
||||
extractLatestPlanEntriesFromMessages,
|
||||
} from "@/lib/agent-plan"
|
||||
|
||||
import type { ConnectionStatus } from "@/lib/types"
|
||||
|
||||
interface MessageListViewProps {
|
||||
conversationId: number
|
||||
connStatus?: ConnectionStatus | null
|
||||
liveMessage?: LiveMessage | null
|
||||
pendingMessages?: AdaptedMessage[]
|
||||
onPendingClear?: () => void
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
interface ResolvedMessageGroup extends MessageGroup {
|
||||
parts: AdaptedContentPart[]
|
||||
resources: UserResourceDisplay[]
|
||||
}
|
||||
|
||||
function fallbackExtractUserResources(group: MessageGroup): {
|
||||
parts: AdaptedContentPart[]
|
||||
resources: UserResourceDisplay[]
|
||||
} {
|
||||
if (group.role !== "user") {
|
||||
return {
|
||||
parts: group.parts,
|
||||
resources: group.userResources ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
const parsedResources: UserResourceDisplay[] = []
|
||||
const parsedParts: AdaptedContentPart[] = []
|
||||
|
||||
for (const part of group.parts) {
|
||||
if (part.type !== "text") {
|
||||
parsedParts.push(part)
|
||||
continue
|
||||
}
|
||||
const extracted = extractUserResourcesFromText(part.text)
|
||||
if (extracted.resources.length > 0) {
|
||||
parsedResources.push(...extracted.resources)
|
||||
if (extracted.text.length > 0) {
|
||||
parsedParts.push({ type: "text", text: extracted.text })
|
||||
}
|
||||
} else {
|
||||
parsedParts.push(part)
|
||||
}
|
||||
}
|
||||
|
||||
const resources = [...(group.userResources ?? []), ...parsedResources]
|
||||
const dedupedResources: UserResourceDisplay[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const resource of resources) {
|
||||
const key = `${resource.name}::${resource.uri}`
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
dedupedResources.push(resource)
|
||||
}
|
||||
|
||||
if (parsedParts.length === 0 && dedupedResources.length > 0) {
|
||||
parsedParts.push({ type: "text", text: "Attached resources" })
|
||||
}
|
||||
|
||||
return { parts: parsedParts, resources: dedupedResources }
|
||||
}
|
||||
|
||||
function resolveMessageGroup(group: MessageGroup): ResolvedMessageGroup {
|
||||
const resolved = fallbackExtractUserResources(group)
|
||||
return {
|
||||
...group,
|
||||
parts: resolved.parts,
|
||||
resources: resolved.resources,
|
||||
}
|
||||
}
|
||||
|
||||
const HistoricalMessageGroup = memo(function HistoricalMessageGroup({
|
||||
group,
|
||||
}: {
|
||||
group: ResolvedMessageGroup
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
contentVisibility: "auto",
|
||||
containIntrinsicSize: "auto 120px",
|
||||
}}
|
||||
>
|
||||
<Message from={group.role}>
|
||||
<MessageContent>
|
||||
<ContentPartsRenderer parts={group.parts} role={group.role} />
|
||||
</MessageContent>
|
||||
{group.role === "user" && group.resources.length > 0 ? (
|
||||
<UserResourceLinks resources={group.resources} className="self-end" />
|
||||
) : null}
|
||||
</Message>
|
||||
{group.role === "assistant" && (
|
||||
<TurnStats
|
||||
usage={group.usage}
|
||||
duration_ms={group.duration_ms}
|
||||
model={group.model}
|
||||
models={group.models}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const PendingMessageGroup = memo(function PendingMessageGroup({
|
||||
group,
|
||||
}: {
|
||||
group: ResolvedMessageGroup
|
||||
}) {
|
||||
return (
|
||||
<div className="opacity-70">
|
||||
<Message from={group.role}>
|
||||
<MessageContent>
|
||||
<ContentPartsRenderer parts={group.parts} role={group.role} />
|
||||
</MessageContent>
|
||||
{group.role === "user" && group.resources.length > 0 ? (
|
||||
<UserResourceLinks resources={group.resources} className="self-end" />
|
||||
) : null}
|
||||
</Message>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export function MessageListView({
|
||||
conversationId,
|
||||
connStatus,
|
||||
liveMessage,
|
||||
pendingMessages,
|
||||
onPendingClear,
|
||||
isActive = true,
|
||||
}: MessageListViewProps) {
|
||||
const { detail, loading, error, refetch } = useDbMessageDetail(conversationId)
|
||||
const turnCount = detail?.turns.length ?? 0
|
||||
|
||||
// Refetch when agent turn completes (prompting → other status)
|
||||
const prevStatusRef = useRef(connStatus)
|
||||
useEffect(() => {
|
||||
const prev = prevStatusRef.current
|
||||
prevStatusRef.current = connStatus
|
||||
if (prev === "prompting" && connStatus && connStatus !== "prompting") {
|
||||
refetch()
|
||||
}
|
||||
}, [connStatus, refetch])
|
||||
|
||||
// Clear pending when detail gains new turns (new data fetched successfully)
|
||||
const prevTurnCountRef = useRef(turnCount)
|
||||
const prevConvIdRef = useRef(conversationId)
|
||||
useEffect(() => {
|
||||
if (prevConvIdRef.current !== conversationId) {
|
||||
// Conversation switched — reset baseline, don't clear
|
||||
prevConvIdRef.current = conversationId
|
||||
prevTurnCountRef.current = turnCount
|
||||
return
|
||||
}
|
||||
if (turnCount > prevTurnCountRef.current && onPendingClear) {
|
||||
onPendingClear()
|
||||
}
|
||||
prevTurnCountRef.current = turnCount
|
||||
}, [turnCount, onPendingClear, conversationId])
|
||||
|
||||
const { setSessionStats } = useSessionStats()
|
||||
const sessionStats = detail?.session_stats ?? null
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive) {
|
||||
setSessionStats(sessionStats)
|
||||
}
|
||||
}, [isActive, sessionStats, setSessionStats])
|
||||
|
||||
// Track whether the initial scroll has happened.
|
||||
// After that, disable resize-triggered scroll so tab switches
|
||||
// don't jump to the bottom.
|
||||
const shouldUseSmoothResize = !(isActive && !loading && detail)
|
||||
|
||||
const messages = useMemo(
|
||||
() => (detail ? adaptMessageTurns(detail.turns) : []),
|
||||
[detail]
|
||||
)
|
||||
|
||||
const groups = useMemo(() => groupAdaptedMessages(messages), [messages])
|
||||
const historicalPlanEntries = useMemo(
|
||||
() => extractLatestPlanEntriesFromMessages(messages),
|
||||
[messages]
|
||||
)
|
||||
const historicalPlanKey = useMemo(
|
||||
() => buildPlanKey(historicalPlanEntries),
|
||||
[historicalPlanEntries]
|
||||
)
|
||||
|
||||
const pendingGroups = useMemo(
|
||||
() =>
|
||||
pendingMessages?.length ? groupAdaptedMessages(pendingMessages) : [],
|
||||
[pendingMessages]
|
||||
)
|
||||
const resolvedGroups = useMemo(
|
||||
() => groups.map(resolveMessageGroup),
|
||||
[groups]
|
||||
)
|
||||
const resolvedPendingGroups = useMemo(
|
||||
() => pendingGroups.map(resolveMessageGroup),
|
||||
[pendingGroups]
|
||||
)
|
||||
|
||||
const showLiveMessage = Boolean(
|
||||
liveMessage &&
|
||||
(connStatus === "prompting" ||
|
||||
(liveMessage.content.length > 0 && resolvedPendingGroups.length > 0))
|
||||
)
|
||||
const agentPlanOverlayKey = liveMessage?.id ?? `history-${conversationId}`
|
||||
|
||||
if (loading && !detail) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="text-center py-12">
|
||||
<p className="text-destructive text-sm">Error: {error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!detail) return null
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full min-h-0 flex-col">
|
||||
{/* Messages */}
|
||||
<MessageThread
|
||||
className="flex-1 min-h-0"
|
||||
resize={shouldUseSmoothResize ? "smooth" : undefined}
|
||||
>
|
||||
<MessageThreadContent className="p-4 max-w-3xl mx-auto">
|
||||
{resolvedGroups.length === 0 &&
|
||||
resolvedPendingGroups.length === 0 &&
|
||||
!showLiveMessage ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No messages in this conversation.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{resolvedGroups.map((group) => (
|
||||
<HistoricalMessageGroup key={group.id} group={group} />
|
||||
))}
|
||||
{resolvedPendingGroups.map((group) => (
|
||||
<PendingMessageGroup key={group.id} group={group} />
|
||||
))}
|
||||
{resolvedPendingGroups.length > 0 && !showLiveMessage && (
|
||||
<Message from="assistant">
|
||||
<MessageContent>
|
||||
<div className="flex items-center gap-1.5 py-1">
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-muted-foreground/60 animate-[pulse_1.4s_ease-in-out_infinite]" />
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-muted-foreground/60 animate-[pulse_1.4s_ease-in-out_0.2s_infinite]" />
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-muted-foreground/60 animate-[pulse_1.4s_ease-in-out_0.4s_infinite]" />
|
||||
</div>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
)}
|
||||
{showLiveMessage && liveMessage && (
|
||||
<LiveMessageBlock message={liveMessage} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</MessageThreadContent>
|
||||
</MessageThread>
|
||||
{showLiveMessage && liveMessage && (
|
||||
<LiveTurnStats message={liveMessage} />
|
||||
)}
|
||||
<AgentPlanOverlay
|
||||
key={agentPlanOverlayKey}
|
||||
message={liveMessage ?? null}
|
||||
entries={historicalPlanEntries}
|
||||
planKey={historicalPlanKey}
|
||||
defaultExpanded={showLiveMessage}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
65
src/components/message/tool-call-block.tsx
Normal file
65
src/components/message/tool-call-block.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { ChevronRight, ChevronDown, Wrench, AlertCircle } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface ToolCallBlockProps {
|
||||
type: "tool_use" | "tool_result"
|
||||
toolName?: string
|
||||
content: string | null
|
||||
isError?: boolean
|
||||
}
|
||||
|
||||
export function ToolCallBlock({
|
||||
type,
|
||||
toolName,
|
||||
content,
|
||||
isError = false,
|
||||
}: ToolCallBlockProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border rounded-md text-xs",
|
||||
isError
|
||||
? "border-destructive/30 bg-destructive/5"
|
||||
: "border-border bg-muted/30"
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-left hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3 shrink-0" />
|
||||
)}
|
||||
{type === "tool_use" ? (
|
||||
<>
|
||||
<Wrench className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
<span className="font-medium">{toolName || "Tool"}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{isError ? (
|
||||
<AlertCircle className="h-3 w-3 shrink-0 text-destructive" />
|
||||
) : (
|
||||
<Wrench className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="font-medium">{isError ? "Error" : "Result"}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{expanded && content && (
|
||||
<div className="px-3 pb-2 border-t border-border">
|
||||
<pre className="text-xs text-muted-foreground whitespace-pre-wrap break-all mt-2 max-h-64 overflow-auto">
|
||||
{content}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
52
src/components/message/turn-stats.tsx
Normal file
52
src/components/message/turn-stats.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { TurnUsage } from "@/lib/types"
|
||||
|
||||
function formatTokenCount(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
|
||||
return String(n)
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms >= 60_000) return `${(ms / 60_000).toFixed(1)}m`
|
||||
return `${(ms / 1_000).toFixed(1)}s`
|
||||
}
|
||||
|
||||
interface TurnStatsProps {
|
||||
usage?: TurnUsage | null
|
||||
duration_ms?: number | null
|
||||
model?: string | null
|
||||
models?: string[]
|
||||
}
|
||||
|
||||
export function TurnStats({
|
||||
usage,
|
||||
duration_ms,
|
||||
model,
|
||||
models,
|
||||
}: TurnStatsProps) {
|
||||
if (!usage && !duration_ms) return null
|
||||
|
||||
const displayModels = models?.length ? models : model ? [model] : []
|
||||
|
||||
const parts: string[] = []
|
||||
if (displayModels.length > 0) parts.push(displayModels.join(", "))
|
||||
if (usage) {
|
||||
parts.push(`${formatTokenCount(usage.input_tokens)} input`)
|
||||
parts.push(`${formatTokenCount(usage.output_tokens)} output`)
|
||||
if (usage.cache_read_input_tokens > 0)
|
||||
parts.push(
|
||||
`${formatTokenCount(usage.cache_read_input_tokens)} cache read`
|
||||
)
|
||||
if (usage.cache_creation_input_tokens > 0)
|
||||
parts.push(
|
||||
`${formatTokenCount(usage.cache_creation_input_tokens)} cache write`
|
||||
)
|
||||
}
|
||||
if (duration_ms) parts.push(formatDuration(duration_ms))
|
||||
|
||||
return (
|
||||
<div className="text-xs text-muted-foreground mt-2">
|
||||
{"[ " + parts.join(" · ") + " ]"}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
32
src/components/message/user-resource-links.tsx
Normal file
32
src/components/message/user-resource-links.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import { FileSearch } from "lucide-react"
|
||||
import type { UserResourceDisplay } from "@/lib/adapters/ai-elements-adapter"
|
||||
|
||||
interface UserResourceLinksProps {
|
||||
resources: UserResourceDisplay[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function UserResourceLinks({
|
||||
resources,
|
||||
className,
|
||||
}: UserResourceLinksProps) {
|
||||
if (resources.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{resources.map((resource, index) => (
|
||||
<div
|
||||
key={`${resource.uri}-${index}`}
|
||||
className="inline-flex items-center gap-1 rounded-full border border-border/70 bg-muted/40 px-2 py-1 text-xs text-muted-foreground"
|
||||
>
|
||||
<FileSearch className="h-3 w-3" />
|
||||
<span className="max-w-56 truncate">{resource.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user