- Replace TextEditor+onKeyPress with NSTextView that properly intercepts Return (send) vs Shift+Return (newline) - Only show streaming content and live tool calls while isStreaming is true, preventing duplicate rendering after messages are persisted - Fix registry search JSON parsing: API returns env as [String] not objects - Add importUrl from search API response instead of constructing it - Add message count badge to sidebar conversation rows - Add "Copy JSON" toolbar button (Cmd+Shift+C) to export full conversation with all messages and tool call results as JSON
95 lines
3.2 KiB
Swift
95 lines
3.2 KiB
Swift
import SwiftUI
|
|
|
|
struct MessageList: View {
|
|
let conversation: Conversation
|
|
let streamingContent: String
|
|
let isStreaming: Bool
|
|
let liveToolCalls: [LiveToolCall]
|
|
|
|
var body: some View {
|
|
ScrollViewReader { proxy in
|
|
ScrollView {
|
|
LazyVStack(spacing: 12) {
|
|
ForEach(conversation.sortedMessages) { message in
|
|
MessageBubble(message: message)
|
|
.id(message.id)
|
|
}
|
|
|
|
// Live tool calls
|
|
ForEach(isStreaming ? liveToolCalls : []) { tc in
|
|
ToolCallView(toolCall: tc)
|
|
.id("live-tc-\(tc.id)")
|
|
}
|
|
|
|
// Streaming content
|
|
if isStreaming && !streamingContent.isEmpty {
|
|
HStack(alignment: .top) {
|
|
assistantBubble(content: streamingContent, isStreaming: true)
|
|
Spacer(minLength: 60)
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.id("streaming")
|
|
}
|
|
|
|
// Thinking indicator
|
|
if isStreaming && streamingContent.isEmpty && liveToolCalls.isEmpty {
|
|
HStack {
|
|
StreamingIndicator()
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.id("thinking")
|
|
}
|
|
|
|
// Bottom spacer for scroll padding
|
|
Color.clear.frame(height: 8)
|
|
.id("bottom")
|
|
}
|
|
.padding(.vertical, 12)
|
|
}
|
|
.onChange(of: streamingContent) {
|
|
withAnimation(.easeOut(duration: 0.15)) {
|
|
proxy.scrollTo("bottom", anchor: .bottom)
|
|
}
|
|
}
|
|
.onChange(of: conversation.messages.count) {
|
|
withAnimation(.easeOut(duration: 0.15)) {
|
|
proxy.scrollTo("bottom", anchor: .bottom)
|
|
}
|
|
}
|
|
.onChange(of: liveToolCalls.count) {
|
|
withAnimation(.easeOut(duration: 0.15)) {
|
|
proxy.scrollTo("bottom", anchor: .bottom)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func assistantBubble(content: String, isStreaming: Bool) -> some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
MarkdownView(content: content)
|
|
|
|
if isStreaming {
|
|
Rectangle()
|
|
.fill(Color.accentColor)
|
|
.frame(width: 2, height: 16)
|
|
.opacity(0.8)
|
|
.modifier(PulseAnimation())
|
|
}
|
|
}
|
|
.padding(12)
|
|
.background(.quaternary.opacity(0.5))
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
}
|
|
}
|
|
|
|
private struct PulseAnimation: ViewModifier {
|
|
@State private var isAnimating = false
|
|
|
|
func body(content: Content) -> some View {
|
|
content
|
|
.opacity(isAnimating ? 0.3 : 1.0)
|
|
.animation(.easeInOut(duration: 0.6).repeatForever(autoreverses: true), value: isAnimating)
|
|
.onAppear { isAnimating = true }
|
|
}
|
|
}
|