fix(chat-ui): improve typing dots and composer
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user