feat: improve ios
This commit is contained in:
@@ -11,6 +11,7 @@ enum ChatMessageType {
|
||||
case user
|
||||
case assistant(success: Bool, costCents: Int, model: String?)
|
||||
case thinking(done: Bool, startTime: Date)
|
||||
case phase(phase: String, detail: String?, agent: String?)
|
||||
case toolUI(name: String)
|
||||
case system
|
||||
case error
|
||||
@@ -51,11 +52,21 @@ struct ChatMessage: Identifiable {
|
||||
return false
|
||||
}
|
||||
|
||||
var isPhase: Bool {
|
||||
if case .phase = type { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
var thinkingDone: Bool {
|
||||
if case .thinking(let done, _) = type { return done }
|
||||
return false
|
||||
}
|
||||
|
||||
var thinkingStartTime: Date? {
|
||||
if case .thinking(_, let startTime) = type { return startTime }
|
||||
return nil
|
||||
}
|
||||
|
||||
var displayModel: String? {
|
||||
if case .assistant(_, _, let model) = type {
|
||||
if let model = model {
|
||||
@@ -96,3 +107,46 @@ enum ControlRunState: String, Codable {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Execution Progress
|
||||
|
||||
struct ExecutionProgress {
|
||||
let total: Int
|
||||
let completed: Int
|
||||
let current: String?
|
||||
let depth: Int
|
||||
|
||||
var displayText: String {
|
||||
"Subtask \(completed + 1)/\(total)"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Phase Labels
|
||||
|
||||
enum AgentPhase: String {
|
||||
case estimatingComplexity = "estimating_complexity"
|
||||
case selectingModel = "selecting_model"
|
||||
case splittingTask = "splitting_task"
|
||||
case executing = "executing"
|
||||
case verifying = "verifying"
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .estimatingComplexity: return "Analyzing task"
|
||||
case .selectingModel: return "Selecting model"
|
||||
case .splittingTask: return "Decomposing task"
|
||||
case .executing: return "Executing"
|
||||
case .verifying: return "Verifying"
|
||||
}
|
||||
}
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .estimatingComplexity: return "brain"
|
||||
case .selectingModel: return "cpu"
|
||||
case .splittingTask: return "arrow.triangle.branch"
|
||||
case .executing: return "play.circle"
|
||||
case .verifying: return "checkmark.shield"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,9 @@ struct ControlView: View {
|
||||
@State private var streamTask: Task<Void, Never>?
|
||||
@State private var showMissionMenu = false
|
||||
@State private var shouldScrollToBottom = false
|
||||
@State private var progress: ExecutionProgress?
|
||||
@State private var isAtBottom = true
|
||||
@State private var copiedMessageId: String?
|
||||
|
||||
@FocusState private var isInputFocused: Bool
|
||||
|
||||
@@ -60,6 +63,15 @@ struct ControlView: View {
|
||||
.font(.caption2)
|
||||
.foregroundStyle(Theme.textTertiary)
|
||||
}
|
||||
|
||||
// Progress indicator
|
||||
if let progress = progress, progress.total > 0 {
|
||||
Text("•")
|
||||
.foregroundStyle(Theme.textMuted)
|
||||
Text(progress.displayText)
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(Theme.success)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -158,50 +170,153 @@ struct ControlView: View {
|
||||
// MARK: - Messages
|
||||
|
||||
private var messagesView: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 16) {
|
||||
if messages.isEmpty && !isLoading {
|
||||
emptyStateView
|
||||
} else if isLoading {
|
||||
LoadingView(message: "Loading conversation...")
|
||||
.frame(height: 200)
|
||||
} else {
|
||||
ForEach(messages) { message in
|
||||
MessageBubble(message: message)
|
||||
ZStack(alignment: .bottom) {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 16) {
|
||||
if messages.isEmpty && !isLoading {
|
||||
// Show working indicator when agent is running but no messages yet
|
||||
if runState == .running {
|
||||
agentWorkingIndicator
|
||||
} else {
|
||||
emptyStateView
|
||||
}
|
||||
} else if isLoading {
|
||||
LoadingView(message: "Loading conversation...")
|
||||
.frame(height: 200)
|
||||
} else {
|
||||
// Show working indicator at top when running but no active streaming item
|
||||
if runState == .running && !hasActiveStreamingItem {
|
||||
agentWorkingIndicator
|
||||
}
|
||||
|
||||
ForEach(messages) { message in
|
||||
MessageBubble(
|
||||
message: message,
|
||||
isCopied: copiedMessageId == message.id,
|
||||
onCopy: { copyMessage(message) }
|
||||
)
|
||||
.id(message.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom anchor for scrolling past last message
|
||||
Color.clear
|
||||
.frame(height: 1)
|
||||
.id(bottomAnchorId)
|
||||
}
|
||||
|
||||
// Bottom anchor for scrolling past last message
|
||||
Color.clear
|
||||
.frame(height: 1)
|
||||
.id(bottomAnchorId)
|
||||
.padding()
|
||||
.background(
|
||||
GeometryReader { geo in
|
||||
Color.clear.preference(
|
||||
key: ScrollOffsetPreferenceKey.self,
|
||||
value: geo.frame(in: .named("scroll")).maxY
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.onTapGesture {
|
||||
// Dismiss keyboard when tapping on messages area
|
||||
isInputFocused = false
|
||||
}
|
||||
.onChange(of: messages.count) { _, _ in
|
||||
scrollToBottom(proxy: proxy)
|
||||
}
|
||||
.onChange(of: shouldScrollToBottom) { _, shouldScroll in
|
||||
if shouldScroll {
|
||||
scrollToBottom(proxy: proxy)
|
||||
shouldScrollToBottom = false
|
||||
.coordinateSpace(name: "scroll")
|
||||
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { maxY in
|
||||
// Check if we're at the bottom (within 100 points)
|
||||
isAtBottom = maxY < UIScreen.main.bounds.height + 100
|
||||
}
|
||||
.onTapGesture {
|
||||
// Dismiss keyboard when tapping on messages area
|
||||
isInputFocused = false
|
||||
}
|
||||
.onChange(of: messages.count) { _, _ in
|
||||
if isAtBottom {
|
||||
scrollToBottom(proxy: proxy)
|
||||
}
|
||||
}
|
||||
.onChange(of: shouldScrollToBottom) { _, shouldScroll in
|
||||
if shouldScroll {
|
||||
scrollToBottom(proxy: proxy)
|
||||
shouldScrollToBottom = false
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .bottom) {
|
||||
// Scroll to bottom button
|
||||
if !isAtBottom && !messages.isEmpty {
|
||||
Button {
|
||||
withAnimation(.spring(duration: 0.3)) {
|
||||
proxy.scrollTo(bottomAnchorId, anchor: .bottom)
|
||||
}
|
||||
isAtBottom = true
|
||||
} label: {
|
||||
Image(systemName: "arrow.down")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 36, height: 36)
|
||||
.background(.ultraThinMaterial)
|
||||
.clipShape(Circle())
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(Theme.border, lineWidth: 1)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.2), radius: 8, y: 4)
|
||||
}
|
||||
.padding(.bottom, 16)
|
||||
.transition(.scale.combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var hasActiveStreamingItem: Bool {
|
||||
messages.contains { msg in
|
||||
(msg.isThinking && !msg.thinkingDone) || msg.isPhase
|
||||
}
|
||||
}
|
||||
|
||||
private var agentWorkingIndicator: some View {
|
||||
HStack(spacing: 12) {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
.tint(Theme.accent)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Agent is working...")
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
|
||||
Text("Updates will appear here as they arrive")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textTertiary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(16)
|
||||
.background(.ultraThinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.stroke(Theme.accent.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.95)))
|
||||
}
|
||||
|
||||
private func scrollToBottom(proxy: ScrollViewProxy) {
|
||||
withAnimation {
|
||||
proxy.scrollTo(bottomAnchorId, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
|
||||
private func copyMessage(_ message: ChatMessage) {
|
||||
UIPasteboard.general.string = message.content
|
||||
copiedMessageId = message.id
|
||||
HapticService.lightTap()
|
||||
|
||||
// Reset after delay
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||
if copiedMessageId == message.id {
|
||||
copiedMessageId = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyStateView: some View {
|
||||
VStack(spacing: 32) {
|
||||
Spacer()
|
||||
@@ -442,7 +557,13 @@ struct ControlView: View {
|
||||
switch type {
|
||||
case "status":
|
||||
if let state = data["state"] as? String {
|
||||
runState = ControlRunState(rawValue: state) ?? .idle
|
||||
let newState = ControlRunState(rawValue: state) ?? .idle
|
||||
runState = newState
|
||||
|
||||
// Clear progress when idle
|
||||
if newState == .idle {
|
||||
progress = nil
|
||||
}
|
||||
}
|
||||
if let queue = data["queue_len"] as? Int {
|
||||
queueLength = queue
|
||||
@@ -462,8 +583,8 @@ struct ControlView: View {
|
||||
let costCents = data["cost_cents"] as? Int ?? 0
|
||||
let model = data["model"] as? String
|
||||
|
||||
// Remove any incomplete thinking messages
|
||||
messages.removeAll { $0.isThinking && !$0.thinkingDone }
|
||||
// Remove any incomplete thinking messages and phase messages
|
||||
messages.removeAll { ($0.isThinking && !$0.thinkingDone) || $0.isPhase }
|
||||
|
||||
let message = ChatMessage(
|
||||
id: id,
|
||||
@@ -477,13 +598,17 @@ struct ControlView: View {
|
||||
if let content = data["content"] as? String {
|
||||
let done = data["done"] as? Bool ?? false
|
||||
|
||||
// Remove phase items when thinking starts
|
||||
messages.removeAll { $0.isPhase }
|
||||
|
||||
// Find existing thinking message or create new
|
||||
if let index = messages.lastIndex(where: { $0.isThinking && !$0.thinkingDone }) {
|
||||
let existingStartTime = messages[index].thinkingStartTime ?? Date()
|
||||
messages[index].content += "\n\n---\n\n" + content
|
||||
if done {
|
||||
messages[index] = ChatMessage(
|
||||
id: messages[index].id,
|
||||
type: .thinking(done: true, startTime: Date()),
|
||||
type: .thinking(done: true, startTime: existingStartTime),
|
||||
content: messages[index].content
|
||||
)
|
||||
}
|
||||
@@ -497,6 +622,37 @@ struct ControlView: View {
|
||||
}
|
||||
}
|
||||
|
||||
case "agent_phase":
|
||||
let phase = data["phase"] as? String ?? ""
|
||||
let detail = data["detail"] as? String
|
||||
let agent = data["agent"] as? String
|
||||
|
||||
// Remove existing phase messages
|
||||
messages.removeAll { $0.isPhase }
|
||||
|
||||
// Add new phase message
|
||||
let message = ChatMessage(
|
||||
id: "phase-\(Date().timeIntervalSince1970)",
|
||||
type: .phase(phase: phase, detail: detail, agent: agent),
|
||||
content: ""
|
||||
)
|
||||
messages.append(message)
|
||||
|
||||
case "progress":
|
||||
let total = data["total_subtasks"] as? Int ?? 0
|
||||
let completed = data["completed_subtasks"] as? Int ?? 0
|
||||
let current = data["current_subtask"] as? String
|
||||
let depth = data["current_depth"] as? Int ?? 0
|
||||
|
||||
if total > 0 {
|
||||
progress = ExecutionProgress(
|
||||
total: total,
|
||||
completed: completed,
|
||||
current: current,
|
||||
depth: depth
|
||||
)
|
||||
}
|
||||
|
||||
case "error":
|
||||
if let errorMessage = data["message"] as? String {
|
||||
let message = ChatMessage(
|
||||
@@ -529,10 +685,21 @@ struct ControlView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Scroll Offset Preference Key
|
||||
|
||||
private struct ScrollOffsetPreferenceKey: PreferenceKey {
|
||||
static var defaultValue: CGFloat = 0
|
||||
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
||||
value = nextValue()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Message Bubble
|
||||
|
||||
private struct MessageBubble: View {
|
||||
let message: ChatMessage
|
||||
var isCopied: Bool = false
|
||||
var onCopy: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
@@ -540,7 +707,10 @@ private struct MessageBubble: View {
|
||||
Spacer(minLength: 60)
|
||||
userBubble
|
||||
} else if message.isThinking {
|
||||
thinkingBubble
|
||||
ThinkingBubble(message: message)
|
||||
Spacer(minLength: 60)
|
||||
} else if message.isPhase {
|
||||
PhaseBubble(message: message)
|
||||
Spacer(minLength: 60)
|
||||
} else if message.isToolUI {
|
||||
toolUIBubble
|
||||
@@ -560,95 +730,266 @@ private struct MessageBubble: View {
|
||||
}
|
||||
|
||||
private var userBubble: some View {
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
Text(message.content)
|
||||
.font(.body)
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(Theme.accent)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||
.clipShape(
|
||||
.rect(
|
||||
topLeadingRadius: 20,
|
||||
bottomLeadingRadius: 20,
|
||||
bottomTrailingRadius: 6,
|
||||
topTrailingRadius: 20
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
// Copy button
|
||||
if !message.content.isEmpty {
|
||||
CopyButton(isCopied: isCopied, onCopy: onCopy)
|
||||
}
|
||||
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
Text(message.content)
|
||||
.font(.body)
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(Theme.accent)
|
||||
.clipShape(
|
||||
.rect(
|
||||
topLeadingRadius: 20,
|
||||
bottomLeadingRadius: 20,
|
||||
bottomTrailingRadius: 6,
|
||||
topTrailingRadius: 20
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var assistantBubble: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
// Status header for assistant messages
|
||||
if case .assistant(let success, _, _) = message.type {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: success ? "checkmark.circle.fill" : "xmark.circle.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(success ? Theme.success : Theme.error)
|
||||
|
||||
if let model = message.displayModel {
|
||||
Text(model)
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(Theme.textTertiary)
|
||||
}
|
||||
|
||||
if let cost = message.costFormatted {
|
||||
Text("•")
|
||||
.foregroundStyle(Theme.textMuted)
|
||||
Text(cost)
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(Theme.success)
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
// Status header for assistant messages
|
||||
if case .assistant(let success, _, _) = message.type {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: success ? "checkmark.circle.fill" : "xmark.circle.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(success ? Theme.success : Theme.error)
|
||||
|
||||
if let model = message.displayModel {
|
||||
Text(model)
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(Theme.textTertiary)
|
||||
}
|
||||
|
||||
if let cost = message.costFormatted {
|
||||
Text("•")
|
||||
.foregroundStyle(Theme.textMuted)
|
||||
Text(cost)
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(Theme.success)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MarkdownText(message.content)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(.ultraThinMaterial)
|
||||
.clipShape(
|
||||
.rect(
|
||||
topLeadingRadius: 20,
|
||||
bottomLeadingRadius: 6,
|
||||
bottomTrailingRadius: 20,
|
||||
topTrailingRadius: 20
|
||||
)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.stroke(Theme.border, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
|
||||
MarkdownText(message.content)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(.ultraThinMaterial)
|
||||
.clipShape(
|
||||
.rect(
|
||||
topLeadingRadius: 20,
|
||||
bottomLeadingRadius: 6,
|
||||
bottomTrailingRadius: 20,
|
||||
topTrailingRadius: 20
|
||||
)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.stroke(Theme.border, lineWidth: 0.5)
|
||||
)
|
||||
// Copy button
|
||||
if !message.content.isEmpty {
|
||||
CopyButton(isCopied: isCopied, onCopy: onCopy)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Copy Button
|
||||
|
||||
private struct CopyButton: View {
|
||||
let isCopied: Bool
|
||||
let onCopy: (() -> Void)?
|
||||
|
||||
private var thinkingBubble: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "brain")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.accent)
|
||||
.symbolEffect(.pulse, options: message.thinkingDone ? .nonRepeating : .repeating)
|
||||
|
||||
Text(message.thinkingDone ? "Thought" : "Thinking...")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Theme.accent.opacity(0.1))
|
||||
.clipShape(Capsule())
|
||||
var body: some View {
|
||||
Button {
|
||||
onCopy?()
|
||||
} label: {
|
||||
Image(systemName: isCopied ? "checkmark" : "doc.on.doc")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(isCopied ? Theme.success : Theme.textMuted)
|
||||
.frame(width: 28, height: 28)
|
||||
.background(Theme.backgroundSecondary)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.opacity(0.7)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Phase Bubble
|
||||
|
||||
private struct PhaseBubble: View {
|
||||
let message: ChatMessage
|
||||
|
||||
var body: some View {
|
||||
if case .phase(let phase, let detail, let agent) = message.type {
|
||||
let agentPhase = AgentPhase(rawValue: phase)
|
||||
|
||||
if !message.content.isEmpty {
|
||||
HStack(spacing: 12) {
|
||||
// Icon with pulse animation
|
||||
Image(systemName: agentPhase?.icon ?? "gear")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundStyle(Theme.accent)
|
||||
.symbolEffect(.pulse, options: .repeating)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(Theme.accent.opacity(0.1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
Text(agentPhase?.label ?? phase.replacingOccurrences(of: "_", with: " ").capitalized)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(Theme.accent)
|
||||
|
||||
if let agent = agent {
|
||||
Text(agent)
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(Theme.textMuted)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Theme.backgroundTertiary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
if let detail = detail {
|
||||
Text(detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textTertiary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Spinner
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
.scaleEffect(0.7)
|
||||
.tint(Theme.accent.opacity(0.5))
|
||||
}
|
||||
.padding(12)
|
||||
.background(.ultraThinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.stroke(Theme.accent.opacity(0.15), lineWidth: 1)
|
||||
)
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.95)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Thinking Bubble
|
||||
|
||||
private struct ThinkingBubble: View {
|
||||
let message: ChatMessage
|
||||
@State private var isExpanded: Bool = true
|
||||
@State private var elapsedSeconds: Int = 0
|
||||
@State private var hasAutoCollapsed = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
// Compact header button
|
||||
Button {
|
||||
withAnimation(.spring(duration: 0.25)) {
|
||||
isExpanded.toggle()
|
||||
}
|
||||
HapticService.selectionChanged()
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "brain")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.accent)
|
||||
.symbolEffect(.pulse, options: message.thinkingDone ? .nonRepeating : .repeating)
|
||||
|
||||
Text(message.thinkingDone ? "Thought for \(formattedDuration)" : "Thinking for \(formattedDuration)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(Theme.textMuted)
|
||||
.rotationEffect(.degrees(isExpanded ? 90 : 0))
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Theme.accent.opacity(0.1))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
// Expandable content
|
||||
if isExpanded && !message.content.isEmpty {
|
||||
Text(message.content)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textTertiary)
|
||||
.lineLimit(message.thinkingDone ? 3 : nil)
|
||||
.lineLimit(message.thinkingDone ? 8 : nil)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.white.opacity(0.02))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.stroke(Theme.border, lineWidth: 0.5)
|
||||
)
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.95, anchor: .top)))
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
startTimer()
|
||||
}
|
||||
.onChange(of: message.thinkingDone) { _, done in
|
||||
if done && !hasAutoCollapsed {
|
||||
// Auto-collapse after a brief delay
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
withAnimation(.spring(duration: 0.25)) {
|
||||
isExpanded = false
|
||||
hasAutoCollapsed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var formattedDuration: String {
|
||||
if elapsedSeconds < 60 {
|
||||
return "\(elapsedSeconds)s"
|
||||
} else {
|
||||
let mins = elapsedSeconds / 60
|
||||
let secs = elapsedSeconds % 60
|
||||
return secs > 0 ? "\(mins)m \(secs)s" : "\(mins)m"
|
||||
}
|
||||
}
|
||||
|
||||
private func startTimer() {
|
||||
guard !message.thinkingDone else {
|
||||
// Calculate elapsed from start time
|
||||
if let startTime = message.thinkingStartTime {
|
||||
elapsedSeconds = Int(Date().timeIntervalSince(startTime))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Update every second while thinking
|
||||
Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { timer in
|
||||
if message.thinkingDone {
|
||||
timer.invalidate()
|
||||
} else if let startTime = message.thinkingStartTime {
|
||||
elapsedSeconds = Int(Date().timeIntervalSince(startTime))
|
||||
} else {
|
||||
elapsedSeconds += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user