fix: polish web chat empty/error state

This commit is contained in:
Peter Steinberger
2026-01-01 11:40:11 +01:00
parent 1a539b9830
commit bdf6a23de9
3 changed files with 220 additions and 9 deletions

View File

@@ -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).

View File

@@ -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 }

View File

@@ -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)))
}
}