From bdf6a23de99d10e320ee89bf9a7735dc9b4f7190 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 1 Jan 2026 11:40:11 +0100 Subject: [PATCH] fix: polish web chat empty/error state --- CHANGELOG.md | 2 +- .../Sources/ClawdisChatUI/ChatComposer.swift | 21 +- .../Sources/ClawdisChatUI/ChatView.swift | 206 ++++++++++++++++++ 3 files changed, 220 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62052b741..f5a79a236 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,7 +41,7 @@ - Android Chat UI: use `onPrimary` for user bubble text to preserve contrast (thanks @Syhids). - Control UI: sync sidebar navigation with the URL for deep-linking, and auto-scroll chat to the latest message. - Control UI: disable Web Chat + Talk when no iOS/Android node is connected; refreshed Web Chat styling and keyboard send. -- macOS Web Chat: fix composer layout so the connection pill and send button stay inside the input field. +- macOS Web Chat: improve empty/error states, focus message field on open, and keep pill/send inside the input field. - macOS: bundle Control UI assets into the app relay so the packaged app can serve them (thanks @mbelinky). - Talk Mode: wait for chat history to surface the assistant reply before starting TTS (macOS/iOS/Android). - iOS Talk Mode: fix chat completion wait to time out even if no events arrive (prevents “Thinking…” hangs). diff --git a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatComposer.swift b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatComposer.swift index 6723067e8..d1a4f64a4 100644 --- a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatComposer.swift +++ b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatComposer.swift @@ -15,6 +15,8 @@ struct ClawdisChatComposer: View { #if !os(macOS) @State private var pickerItems: [PhotosPickerItem] = [] @FocusState private var isFocused: Bool + #else + @State private var shouldFocusTextView = false #endif var body: some View { @@ -33,13 +35,6 @@ struct ClawdisChatComposer: View { } self.editor - - if let error = self.viewModel.errorText, !error.isEmpty { - Text(error) - .font(.footnote) - .foregroundStyle(.red) - .lineLimit(2) - } } .padding(self.composerPadding) .background( @@ -53,6 +48,9 @@ struct ClawdisChatComposer: View { .onDrop(of: [.fileURL], isTargeted: nil) { providers in self.handleDrop(providers) } + .onAppear { + self.shouldFocusTextView = true + } #endif } @@ -185,7 +183,7 @@ struct ClawdisChatComposer: View { } #if os(macOS) - ChatComposerTextView(text: self.$viewModel.input) { + ChatComposerTextView(text: self.$viewModel.input, shouldFocus: self.$shouldFocusTextView) { self.viewModel.send() } .frame(minHeight: self.textMinHeight, idealHeight: self.textMinHeight, maxHeight: self.textMaxHeight) @@ -336,6 +334,7 @@ import UniformTypeIdentifiers private struct ChatComposerTextView: NSViewRepresentable { @Binding var text: String + @Binding var shouldFocus: Bool var onSend: () -> Void func makeCoordinator() -> Coordinator { Coordinator(self) } @@ -382,6 +381,12 @@ private struct ChatComposerTextView: NSViewRepresentable { func updateNSView(_ scrollView: NSScrollView, context: Context) { guard let textView = scrollView.documentView as? ChatComposerNSTextView else { return } + + if self.shouldFocus, let window = scrollView.window { + window.makeFirstResponder(textView) + self.shouldFocus = false + } + let isEditing = scrollView.window?.firstResponder == textView if isEditing { return } diff --git a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatView.swift b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatView.swift index 899150d94..f53654d4e 100644 --- a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatView.swift +++ b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatView.swift @@ -92,6 +92,8 @@ public struct ClawdisChatView: View { .controlSize(.large) .frame(maxWidth: .infinity, maxHeight: .infinity) } + + self.messageListOverlay } // Ensure the message list claims vertical space on the first layout pass. .frame(maxHeight: .infinity, alignment: .top) @@ -160,6 +162,112 @@ public struct ClawdisChatView: View { return self.mergeToolResults(in: base) } + @ViewBuilder + private var messageListOverlay: some View { + if self.viewModel.isLoading { + EmptyView() + } else if let error = self.activeErrorText { + let presentation = self.errorPresentation(for: error) + if self.hasVisibleMessageListContent { + VStack(spacing: 0) { + ChatNoticeBanner( + systemImage: presentation.systemImage, + title: presentation.title, + message: error, + tint: presentation.tint, + dismiss: { self.viewModel.errorText = nil }, + refresh: { self.viewModel.refresh() }) + Spacer(minLength: 0) + } + .padding(.horizontal, 10) + .padding(.top, 8) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } else { + ChatNoticeCard( + systemImage: presentation.systemImage, + title: presentation.title, + message: error, + tint: presentation.tint, + actionTitle: "Refresh", + action: { self.viewModel.refresh() }) + .padding(.horizontal, 24) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } else if self.showsEmptyState { + ChatNoticeCard( + systemImage: "bubble.left.and.bubble.right.fill", + title: self.emptyStateTitle, + message: self.emptyStateMessage, + tint: .accentColor, + actionTitle: nil, + action: nil) + .padding(.horizontal, 24) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + private var activeErrorText: String? { + guard let text = self.viewModel.errorText? + .trimmingCharacters(in: .whitespacesAndNewlines), + !text.isEmpty + else { + return nil + } + return text + } + + private var hasVisibleMessageListContent: Bool { + if !self.visibleMessages.isEmpty { + return true + } + if let text = self.viewModel.streamingAssistantText, + !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + return true + } + if self.viewModel.pendingRunCount > 0 { + return true + } + if !self.viewModel.pendingToolCalls.isEmpty { + return true + } + return false + } + + private var showsEmptyState: Bool { + self.viewModel.messages.isEmpty && + (self.viewModel.streamingAssistantText?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) && + self.viewModel.pendingRunCount == 0 && + self.viewModel.pendingToolCalls.isEmpty + } + + private var emptyStateTitle: String { + #if os(macOS) + "Web Chat" + #else + "Chat" + #endif + } + + private var emptyStateMessage: String { + #if os(macOS) + "Type a message below to start.\nReturn sends • Shift-Return adds a line break." + #else + "Type a message below to start." + #endif + } + + private func errorPresentation(for error: String) -> (title: String, systemImage: String, tint: Color) { + let lower = error.lowercased() + if lower.contains("not connected") || lower.contains("socket") { + return ("Disconnected", "wifi.slash", .orange) + } + if lower.contains("timed out") { + return ("Timed out", "clock.badge.exclamationmark", .orange) + } + return ("Error", "exclamationmark.triangle.fill", .orange) + } + private func mergeToolResults(in messages: [ClawdisChatMessage]) -> [ClawdisChatMessage] { var result: [ClawdisChatMessage] = [] result.reserveCapacity(messages.count) @@ -243,3 +351,101 @@ public struct ClawdisChatView: View { return parts.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) } } + +private struct ChatNoticeCard: View { + let systemImage: String + let title: String + let message: String + let tint: Color + let actionTitle: String? + let action: (() -> Void)? + + var body: some View { + VStack(spacing: 12) { + ZStack { + Circle() + .fill(self.tint.opacity(0.16)) + Image(systemName: self.systemImage) + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(self.tint) + } + .frame(width: 52, height: 52) + + Text(self.title) + .font(.headline) + + Text(self.message) + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .lineLimit(4) + .frame(maxWidth: 360) + + if let actionTitle, let action { + Button(actionTitle, action: action) + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + } + .padding(18) + .background( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(ClawdisChatTheme.subtleCard) + .overlay( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .strokeBorder(Color.white.opacity(0.12), lineWidth: 1))) + .shadow(color: .black.opacity(0.14), radius: 18, y: 8) + } +} + +private struct ChatNoticeBanner: View { + let systemImage: String + let title: String + let message: String + let tint: Color + let dismiss: () -> Void + let refresh: () -> Void + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: self.systemImage) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(self.tint) + .padding(.top, 1) + + VStack(alignment: .leading, spacing: 3) { + Text(self.title) + .font(.caption.weight(.semibold)) + + Text(self.message) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + + Spacer(minLength: 0) + + Button(action: self.refresh) { + Image(systemName: "arrow.clockwise") + } + .buttonStyle(.bordered) + .controlSize(.small) + .help("Refresh") + + Button(action: self.dismiss) { + Image(systemName: "xmark") + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .help("Dismiss") + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(ClawdisChatTheme.subtleCard) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder(Color.white.opacity(0.12), lineWidth: 1))) + } +}