diff --git a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatComposer.swift b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatComposer.swift index 14156ce22..eb467dd04 100644 --- a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatComposer.swift +++ b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatComposer.swift @@ -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 diff --git a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatMessageViews.swift b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatMessageViews.swift index 05e8ce90c..a39d96e60 100644 --- a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatMessageViews.swift +++ b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatMessageViews.swift @@ -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 } } diff --git a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatViewModel.swift b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatViewModel.swift index b930793f4..87f1605d2 100644 --- a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatViewModel.swift +++ b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatViewModel.swift @@ -34,6 +34,10 @@ public final class ClawdisChatViewModel { didSet { self.pendingRunCount = self.pendingRuns.count } } + @ObservationIgnored + private nonisolated(unsafe) var pendingRunTimeoutTasks: [String: Task] = [:] + 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