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

View File

@@ -160,6 +160,7 @@ struct ChatTypingIndicatorBubble: View {
@MainActor
private struct TypingDots: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var phase: Double = 0
var body: some View {
@@ -169,19 +170,33 @@ private struct TypingDots: View {
.fill(Color.secondary.opacity(0.55))
.frame(width: 7, height: 7)
.scaleEffect(self.dotScale(idx))
.opacity(self.dotOpacity(idx))
}
}
.onAppear {
withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) {
self.phase = 1
guard !self.reduceMotion else { return }
phase = 0
withAnimation(.linear(duration: 1.05).repeatForever(autoreverses: false)) {
self.phase = .pi * 2
}
}
}
private func dotScale(_ idx: Int) -> CGFloat {
let base = 0.85 + (self.phase * 0.35)
let offset = Double(idx) * 0.15
return CGFloat(base - offset)
if self.reduceMotion { return 0.85 }
let wave = self.dotWave(idx)
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 }
}
@ObservationIgnored
private nonisolated(unsafe) var pendingRunTimeoutTasks: [String: Task<Void, Never>] = [:]
private let pendingRunTimeoutMs: UInt64 = 120_000
private var lastHealthPollAt: Date?
public init(sessionKey: String, transport: any ClawdisChatTransport) {
@@ -54,6 +58,9 @@ public final class ClawdisChatViewModel {
deinit {
self.eventTask?.cancel()
for (_, task) in self.pendingRunTimeoutTasks {
task.cancel()
}
}
public func load() {
@@ -91,6 +98,7 @@ public final class ClawdisChatViewModel {
self.isLoading = true
self.errorText = nil
self.healthOK = false
self.clearPendingRuns(reason: nil)
defer { self.isLoading = false }
do {
do {
@@ -173,6 +181,7 @@ public final class ClawdisChatViewModel {
idempotencyKey: runId,
attachments: encodedAttachments)
self.pendingRuns.insert(response.runId)
self.armPendingRunTimeout(runId: response.runId)
} catch {
self.errorText = error.localizedDescription
chatUILogger.error("chat.send failed \(error.localizedDescription, privacy: .public)")
@@ -193,6 +202,7 @@ public final class ClawdisChatViewModel {
self.handleChatEvent(chat)
case .seqGap:
self.errorText = "Event stream interrupted; try refreshing."
self.clearPendingRuns(reason: nil)
}
}
@@ -214,18 +224,53 @@ public final class ClawdisChatViewModel {
self.messages.append(msg)
}
if let runId = chat.runId {
self.pendingRuns.remove(runId)
self.clearPendingRun(runId)
} else if self.pendingRuns.count <= 1 {
self.clearPendingRuns(reason: nil)
}
case "error":
self.errorText = chat.errorMessage ?? "Chat failed"
if let runId = chat.runId {
self.pendingRuns.remove(runId)
self.clearPendingRun(runId)
} else if self.pendingRuns.count <= 1 {
self.clearPendingRuns(reason: nil)
}
default:
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 {
if !force, let last = self.lastHealthPollAt, Date().timeIntervalSince(last) < 10 {
return