fix(chat-ui): improve typing dots and composer

This commit is contained in:
Peter Steinberger
2025-12-16 20:13:23 +01:00
parent 74b19843ae
commit 49a9f74753
3 changed files with 93 additions and 32 deletions

View File

@@ -130,8 +130,14 @@ struct ClawdisChatComposer: View {
.background( .background(
RoundedRectangle(cornerRadius: 12, style: .continuous) RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(ClawdisChatTheme.card)) .fill(ClawdisChatTheme.card))
.overlay(self.editorOverlay) .overlay(alignment: .topLeading) {
.frame(maxHeight: 140) self.editorOverlay
}
.overlay(alignment: .bottomTrailing) {
self.sendButton
.padding(8)
}
.frame(minHeight: 44, idealHeight: 44, maxHeight: 96)
} }
private var editorOverlay: some View { private var editorOverlay: some View {
@@ -140,16 +146,16 @@ struct ClawdisChatComposer: View {
Text("Message Clawd…") Text("Message Clawd…")
.foregroundStyle(.tertiary) .foregroundStyle(.tertiary)
.padding(.horizontal, 10) .padding(.horizontal, 10)
.padding(.vertical, 8) .padding(.vertical, 6)
} }
#if os(macOS) #if os(macOS)
ChatComposerTextView(text: self.$viewModel.input) { ChatComposerTextView(text: self.$viewModel.input) {
self.viewModel.send() self.viewModel.send()
} }
.frame(minHeight: 44, maxHeight: 120) .frame(minHeight: 32, idealHeight: 32, maxHeight: 72)
.padding(.horizontal, 8) .padding(.horizontal, 8)
.padding(.vertical, 6) .padding(.vertical, 5)
.padding(.trailing, 44) .padding(.trailing, 44)
#else #else
TextEditor(text: self.$viewModel.input) TextEditor(text: self.$viewModel.input)
@@ -159,28 +165,23 @@ struct ClawdisChatComposer: View {
.padding(.vertical, 8) .padding(.vertical, 8)
.focused(self.$isFocused) .focused(self.$isFocused)
#endif #endif
}
}
VStack { private var sendButton: some View {
Spacer() Button {
HStack { self.viewModel.send()
Spacer() } label: {
Button { if self.viewModel.isSending {
self.viewModel.send() ProgressView().controlSize(.small)
} label: { } else {
if self.viewModel.isSending { Image(systemName: "arrow.up")
ProgressView().controlSize(.small) .font(.system(size: 13, weight: .semibold))
} else {
Image(systemName: "arrow.up")
.font(.system(size: 13, weight: .semibold))
}
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
.disabled(!self.viewModel.canSend)
.padding(8)
}
} }
} }
.buttonStyle(.borderedProminent)
.controlSize(.small)
.disabled(!self.viewModel.canSend)
} }
#if os(macOS) #if os(macOS)
@@ -252,7 +253,7 @@ private struct ChatComposerTextView: NSViewRepresentable {
textView.font = .systemFont(ofSize: 14, weight: .regular) textView.font = .systemFont(ofSize: 14, weight: .regular)
textView.textContainer?.lineBreakMode = .byWordWrapping textView.textContainer?.lineBreakMode = .byWordWrapping
textView.textContainer?.lineFragmentPadding = 0 textView.textContainer?.lineFragmentPadding = 0
textView.textContainerInset = NSSize(width: 2, height: 8) textView.textContainerInset = NSSize(width: 2, height: 6)
textView.focusRingType = .none textView.focusRingType = .none
textView.minSize = .zero textView.minSize = .zero

View File

@@ -160,6 +160,7 @@ struct ChatTypingIndicatorBubble: View {
@MainActor @MainActor
private struct TypingDots: View { private struct TypingDots: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var phase: Double = 0 @State private var phase: Double = 0
var body: some View { var body: some View {
@@ -169,19 +170,33 @@ private struct TypingDots: View {
.fill(Color.secondary.opacity(0.55)) .fill(Color.secondary.opacity(0.55))
.frame(width: 7, height: 7) .frame(width: 7, height: 7)
.scaleEffect(self.dotScale(idx)) .scaleEffect(self.dotScale(idx))
.opacity(self.dotOpacity(idx))
} }
} }
.onAppear { .onAppear {
withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) { guard !self.reduceMotion else { return }
self.phase = 1 phase = 0
withAnimation(.linear(duration: 1.05).repeatForever(autoreverses: false)) {
self.phase = .pi * 2
} }
} }
} }
private func dotScale(_ idx: Int) -> CGFloat { private func dotScale(_ idx: Int) -> CGFloat {
let base = 0.85 + (self.phase * 0.35) if self.reduceMotion { return 0.85 }
let offset = Double(idx) * 0.15 let wave = self.dotWave(idx)
return CGFloat(base - offset) return CGFloat(0.72 + (wave * 0.52))
}
private func dotOpacity(_ idx: Int) -> Double {
if self.reduceMotion { return 0.55 }
let wave = self.dotWave(idx)
return 0.35 + (wave * 0.65)
}
private func dotWave(_ idx: Int) -> Double {
let offset = (Double(idx) * (2 * Double.pi / 3))
return (sin(self.phase + offset) + 1) / 2
} }
} }

View File

@@ -34,6 +34,10 @@ public final class ClawdisChatViewModel {
didSet { self.pendingRunCount = self.pendingRuns.count } didSet { self.pendingRunCount = self.pendingRuns.count }
} }
@ObservationIgnored
private nonisolated(unsafe) var pendingRunTimeoutTasks: [String: Task<Void, Never>] = [:]
private let pendingRunTimeoutMs: UInt64 = 120_000
private var lastHealthPollAt: Date? private var lastHealthPollAt: Date?
public init(sessionKey: String, transport: any ClawdisChatTransport) { public init(sessionKey: String, transport: any ClawdisChatTransport) {
@@ -54,6 +58,9 @@ public final class ClawdisChatViewModel {
deinit { deinit {
self.eventTask?.cancel() self.eventTask?.cancel()
for (_, task) in self.pendingRunTimeoutTasks {
task.cancel()
}
} }
public func load() { public func load() {
@@ -91,6 +98,7 @@ public final class ClawdisChatViewModel {
self.isLoading = true self.isLoading = true
self.errorText = nil self.errorText = nil
self.healthOK = false self.healthOK = false
self.clearPendingRuns(reason: nil)
defer { self.isLoading = false } defer { self.isLoading = false }
do { do {
do { do {
@@ -173,6 +181,7 @@ public final class ClawdisChatViewModel {
idempotencyKey: runId, idempotencyKey: runId,
attachments: encodedAttachments) attachments: encodedAttachments)
self.pendingRuns.insert(response.runId) self.pendingRuns.insert(response.runId)
self.armPendingRunTimeout(runId: response.runId)
} catch { } catch {
self.errorText = error.localizedDescription self.errorText = error.localizedDescription
chatUILogger.error("chat.send failed \(error.localizedDescription, privacy: .public)") chatUILogger.error("chat.send failed \(error.localizedDescription, privacy: .public)")
@@ -193,6 +202,7 @@ public final class ClawdisChatViewModel {
self.handleChatEvent(chat) self.handleChatEvent(chat)
case .seqGap: case .seqGap:
self.errorText = "Event stream interrupted; try refreshing." self.errorText = "Event stream interrupted; try refreshing."
self.clearPendingRuns(reason: nil)
} }
} }
@@ -214,18 +224,53 @@ public final class ClawdisChatViewModel {
self.messages.append(msg) self.messages.append(msg)
} }
if let runId = chat.runId { if let runId = chat.runId {
self.pendingRuns.remove(runId) self.clearPendingRun(runId)
} else if self.pendingRuns.count <= 1 {
self.clearPendingRuns(reason: nil)
} }
case "error": case "error":
self.errorText = chat.errorMessage ?? "Chat failed" self.errorText = chat.errorMessage ?? "Chat failed"
if let runId = chat.runId { if let runId = chat.runId {
self.pendingRuns.remove(runId) self.clearPendingRun(runId)
} else if self.pendingRuns.count <= 1 {
self.clearPendingRuns(reason: nil)
} }
default: default:
break break
} }
} }
private func armPendingRunTimeout(runId: String) {
self.pendingRunTimeoutTasks[runId]?.cancel()
self.pendingRunTimeoutTasks[runId] = Task { [weak self] in
let timeoutMs = await MainActor.run { self?.pendingRunTimeoutMs ?? 0 }
try? await Task.sleep(nanoseconds: timeoutMs * 1_000_000)
await MainActor.run { [weak self] in
guard let self else { return }
guard self.pendingRuns.contains(runId) else { return }
self.clearPendingRun(runId)
self.errorText = "Timed out waiting for a reply; try again or refresh."
}
}
}
private func clearPendingRun(_ runId: String) {
self.pendingRuns.remove(runId)
self.pendingRunTimeoutTasks[runId]?.cancel()
self.pendingRunTimeoutTasks[runId] = nil
}
private func clearPendingRuns(reason: String?) {
for runId in self.pendingRuns {
self.pendingRunTimeoutTasks[runId]?.cancel()
}
self.pendingRunTimeoutTasks.removeAll()
self.pendingRuns.removeAll()
if let reason, !reason.isEmpty {
self.errorText = reason
}
}
private func pollHealthIfNeeded(force: Bool) async { private func pollHealthIfNeeded(force: Bool) async {
if !force, let last = self.lastHealthPollAt, Date().timeIntervalSince(last) < 10 { if !force, let last = self.lastHealthPollAt, Date().timeIntervalSince(last) < 10 {
return return